完美像素按钮补齐素材类型保存门禁

完美像素按钮自新增之日起未接入 isPersistingAssetKind,handler 也未复用
persistingAssetKindLayerIdsRef 同步守卫。把图层的非空 assetKind 改成另一个
非空值后,在异步资源保存完成前点击该按钮,请求会同时带上新 assetKind 和旧
sourceResourceId,后端 resolve_editor_pixel_art_snap_asset_kind 检出两者不一致
直接返回 400。

两道防护与相邻的拆分图集按钮在完美像素按钮加入前就已存在,属于新入口漏配既有
约定。disabled 只挡下一帧,同步 ref 才挡得住 setState 生效前的那一次点击,两者
缺一不可。

保存态的无障碍名称没有直接复用拆分图集的「素材类型保存中」:icon-spritesheet
图层会同时渲染两个按钮,撞名后读屏用户无法区分控件,既有的拆分测试也会因
getByRole 命中多个元素而失败。完美像素改用与「完美像素处理中」同构的
「完美像素等待素材类型保存」。

影响边界:400 发生在纯函数前置校验阶段,尚无 OSS PUT 与资源创建,不写坏数据;
用户可在资源保存完成后重试成功,代价是需要手动清理失败占位。仅当「非空类型改为
另一个非空类型」时触发。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 10:42:56 +00:00
parent 5916b7d509
commit ad5b44b93c
5 changed files with 68 additions and 10 deletions
@@ -5933,3 +5933,11 @@
- 影响边界:只能污染攻击者自己的记录(`owner_user_id` 取自 access token,不可控),不构成越权、信息泄露或计费漏洞;该端点 `generation_cost_mud_points = 0`。危害限于按这些字段做的后台统计、排障与审计出现假数据。
- 验证:sanitizer 单元测试 `editor_client_generation_inputs_cannot_forge_internal_audit_fields` 已覆盖字段剥离与其余字段保留;端点接线由 `explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 的顺序断言钉住,`sanitize_editor_client_generation_inputs` 必须排在 `resolve_editor_pixel_art_processing_deadline` 及之后全部 IO 之前,被挪到 IO 之后会直接失败。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
## 2026-08-01 完美像素按钮补齐素材类型保存门禁
- 缺陷:完美像素按钮自新增之日起未接入 `isPersistingAssetKind`,handler 也未复用 `persistingAssetKindLayerIdsRef` 同步守卫。把图层的非空 `assetKind` 改成另一个非空值后,在异步资源保存完成前点击该按钮,请求会同时带上新 `assetKind` 和旧 `sourceResourceId`,后端 `resolve_editor_pixel_art_snap_asset_kind` 检出请求类型与来源权威类型不一致直接返回 `400`。两道防护与相邻的拆分图集按钮在完美像素按钮加入前就已存在,属于新入口漏配既有约定。
- 决策:完美像素按钮的 `disabled / aria-busy` 与拆分图集共用同一套门禁(`isPersistingAssetKind || isPerfectPixelProcessing`),handler 侧同样先查 `persistingAssetKindLayerIdsRef` 再提交——`disabled` 只挡下一帧,同步 ref 才挡得住 `setState` 生效前的那一次点击。
- 无障碍:保存态名称不得直接复用拆分图集的「素材类型保存中」。`icon-spritesheet` 图层会同时渲染两个按钮,撞名后读屏用户无法区分控件,既有测试也会因 `getByRole` 命中多个元素而失败。完美像素改用与「完美像素处理中」同构的「完美像素等待素材类型保存」。
- 影响边界:`400` 发生在纯函数前置校验阶段,此时尚无 OSS PUT 与资源创建,不写坏数据;用户可在资源保存完成后重试成功,代价是需要手动清理失败占位。仅当「非空类型改为另一个非空类型」时触发——权威类型为空时走兜底分支不比较。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
File diff suppressed because one or more lines are too long
@@ -2429,7 +2429,14 @@ export function ImageCanvasEditorView({
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onPerfectPixel: snapSelectedLayerToPerfectPixels,
// 中文注释:disabled 只挡下一帧,同步 ref 才挡得住 setState 生效前的那一次点击。
// 素材类型保存在途时提交会让 assetKind 与后端权威 resource 类型不一致,直接 400。
onPerfectPixel: (layer: CanvasLayer) => {
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
return;
}
void snapSelectedLayerToPerfectPixels(layer);
},
onSplitIconSpritesheet: (layer: CanvasLayer) => {
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
return;
@@ -226,6 +226,29 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke perfect pixel while the selected asset kind is persisting', () => {
// 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时
// 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」
// 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
selectedLayer: layer,
isPersistingAssetKind: true,
});
const button = screen.getByRole('button', {
name: '完美像素等待素材类型保存',
});
expect(button.getAttribute('aria-busy')).toBe('true');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
expect(button.textContent).toContain('保存中');
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
@@ -127,24 +127,44 @@ export function ImageCanvasSelectedLayerToolbarView({
/>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label={isPerfectPixelProcessing ? '完美像素处理中' : '完美像素'}
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: '完美像素'
}
title={
isPerfectPixelProcessing
? '完美像素处理中'
: '自动识别并规整像素网格'
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: '自动识别并规整像素网格'
}
icon={
isPerfectPixelProcessing ? (
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
disabled={isPerfectPixelProcessing}
aria-busy={isPerfectPixelProcessing}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={isPersistingAssetKind || isPerfectPixelProcessing}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>{isPerfectPixelProcessing ? '处理中' : '完美像素'}</span>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: '完美像素'}
</span>
</PlatformIconButton>
</>
) : null}