完美像素归属校验单次取数并纳入处理预算
合法 assetId 且不带 sourceResourceId 时,取得端点准入许可后到 OSS 下载之间会
顺序执行 8 次 SpacetimeDB 调用,全是裸 await,第一次真正应用 30 秒预算的是下载。
其中 list_editor_projects + get_editor_asset_library 这对全账号扫描重复了三轮:
resolve_editor_reference_object_key_for_owner 内部两个子函数各扫一轮,
resolve_editor_pixel_art_source_for_owner 再扫第三轮,拉的是同一份数据。
预算从 handler 入口「起算」不等于「覆盖」。deadline 是绝对时刻,早起算只让后续
余额更少;中间不检查就不会在该阶段返回 504,请求会一路走到下载才失败并返回下载
相关文案,误导排障,同时准入许可全程被这些无界调用占用,把并发闸自身变成瓶颈。
去重参照抠图入口 resolve_editor_background_removal_source 的既有范式:扫一次,
注册 ID 解析、归属校验和跨记录 assetKind 收集全部交给 _from_records 纯函数在内存
里完成。归属校验命中已登记记录即短路,只有两份记录都查不到才回落点查。老包装
resolve_editor_reference_object_key_for_owner 保持不动,它服务的是没有任何上下文
的调用方。全账号 RPC 由 6 次降为 2 次,整段由 8 次降为 3 次。
预算侧把 get_editor_project 到来源解析结束整体包进 timeout_at,超时返回 504 且
文案指向归属校验。不重复写 Instant::now() 预检——紧邻的 permit 获取已做过,且本块
首个 await 是网络 IO 不会立即就绪,不构成 timeout_at 先 poll 再判超时的陷阱。
参照 937378ab9 用 assert_function_occurrence_count 把两处全账号扫描各钉为 1 次,
并禁止该函数重新调用取数包装。后者断言调用形式而非裸函数名,否则会命中生产代码里
说明「老包装保持不动」的注释——该陷阱在编写时即由测试抓出。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5941,3 +5941,12 @@
|
||||
- 无障碍:保存态名称不得直接复用拆分图集的「素材类型保存中」。`icon-spritesheet` 图层会同时渲染两个按钮,撞名后读屏用户无法区分控件,既有测试也会因 `getByRole` 命中多个元素而失败。完美像素改用与「完美像素处理中」同构的「完美像素等待素材类型保存」。
|
||||
- 影响边界:`400` 发生在纯函数前置校验阶段,此时尚无 OSS PUT 与资源创建,不写坏数据;用户可在资源保存完成后重试成功,代价是需要手动清理失败占位。仅当「非空类型改为另一个非空类型」时触发——权威类型为空时走兜底分支不比较。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-01 完美像素归属校验单次取数并纳入处理预算
|
||||
|
||||
- 缺陷:`POST /api/editor/images/pixel-art-snaps` 在合法 `assetId` 且不带 `sourceResourceId` 时,取得端点准入许可后到 OSS 下载之间会顺序执行 8 次 SpacetimeDB 调用,且全是裸 `await`,第一次真正应用 30 秒预算的是下载。其中 `list_editor_projects` + `get_editor_asset_library` 这对全账号扫描重复了三轮——`resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,`resolve_editor_pixel_art_source_for_owner` 再扫第三轮,拉的是同一份数据。
|
||||
- 契约澄清:预算从 handler 入口「起算」不等于「覆盖」。此前文档写成覆盖 SpacetimeDB 读取是错的:deadline 是绝对时刻,早起算只让后续余额更少,中间不检查就不会在该阶段返回 `504`,请求会一路走到下载才失败并返回下载相关文案,误导排障。同时端点准入许可全程被这些无界调用占用,把并发闸自身变成瓶颈。
|
||||
- 决策一(去重):参照抠图入口 `resolve_editor_background_removal_source` 的既有范式——扫一次,注册 ID 解析、归属校验和跨记录 `asset_kind` 收集全部交给 `_from_records` 纯函数在内存里完成。`resolve_editor_pixel_art_source_for_owner` 自行取一次 owner 快照后复用,不再调用 `resolve_editor_reference_object_key_for_owner`;该包装是给没有任何上下文的调用方用的,保持不动。归属校验命中已登记记录即短路,只有两份记录都查不到时才回落 `ensure_editor_reference_asset_object_owned` 点查。全账号 RPC 由 6 次降为 2 次,整段由 8 次降为 3 次(未登记对象兜底时 4 次)。
|
||||
- 决策二(预算):`get_editor_project` 到来源解析结束整体包进 `tokio::time::timeout_at`,超时返回 `504` 且文案指向归属校验而非下载。不重复写 `Instant::now() >= deadline` 预检——紧邻的 `acquire_editor_pixel_art_snap_permit` 已做该预检,成功即意味着未超预算,且本块首个 await 是网络 IO 不会立即就绪,不构成 `timeout_at` 先 poll 再判超时的陷阱。
|
||||
- 验证:顺序断言新增 `tokio::time::timeout_at(` 与超时文案;参照 `937378ab9` 的做法用 `assert_function_occurrence_count` 把 `resolve_editor_pixel_art_source_for_owner` 内的 `.list_editor_projects(` 和 `.get_editor_asset_library(` 各钉为 1 次,并用 `assert_function_not_contains` 禁止该函数重新调用取数包装。后者断言的是调用形式 `resolve_editor_reference_object_key_for_owner(state` 而非裸函数名,否则会命中生产代码里说明「老包装保持不动」的注释——该陷阱在编写时即由测试抓出。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
- `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。
|
||||
- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。
|
||||
- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。权威 `canvasCompletion.dialogId` 仍存在时在源图右侧完成占位;其删除已先持久化时只跳过画布回填,不删除已经成功写入的资源或账号素材。
|
||||
- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。权威 `canvasCompletion.dialogId` 仍存在时在源图右侧完成占位;其删除已先持久化时只跳过画布回填,不删除已经成功写入的资源或账号素材。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。
|
||||
- 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
|
||||
@@ -4595,9 +4595,60 @@ async fn resolve_editor_pixel_art_source_for_owner(
|
||||
)?,
|
||||
None => None,
|
||||
};
|
||||
// 中文注释:没有已鉴权 source_resource 时,注册 ID 解析、归属校验和下面的跨记录
|
||||
// asset_kind 扫描需要的是同一份全账号快照。此前这三件事各自取数——
|
||||
// `resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,本函数再扫
|
||||
// 第三轮——同一份数据被 `list_editor_projects` + `get_editor_asset_library` 拉了 6 次,
|
||||
// 全部在 30 秒预算和端点准入名额之内顺序执行。参照抠图入口
|
||||
// `resolve_editor_background_removal_source` 的做法:扫一次,后续解析全部交给
|
||||
// `_from_records` 纯函数在内存里完成。老包装 `resolve_editor_reference_object_key_for_owner`
|
||||
// 保持不动,它服务的是没有任何上下文的调用方。
|
||||
let owner_records = if resolved_without_lookup.is_some() {
|
||||
None
|
||||
} else {
|
||||
let projects = state
|
||||
.spacetime_client()
|
||||
.list_editor_projects(owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
let library = state
|
||||
.spacetime_client()
|
||||
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
Some((projects, library))
|
||||
};
|
||||
let object_key = match resolved_without_lookup.clone() {
|
||||
Some(object_key) => object_key,
|
||||
None => resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?,
|
||||
None => {
|
||||
let (projects, library) = owner_records
|
||||
.as_ref()
|
||||
.expect("owner records are loaded whenever the lookup-free path is unavailable");
|
||||
let object_key = match normalize_editor_reference_object_key(source) {
|
||||
Ok(object_key) => object_key,
|
||||
Err(error) => find_editor_reference_object_key_by_registered_id_from_records(
|
||||
projects.as_slice(),
|
||||
library.assets.as_slice(),
|
||||
source.trim(),
|
||||
)
|
||||
.ok_or(error)?,
|
||||
};
|
||||
// 中文注释:命中 owner 已登记记录就不再发 RPC;只有两份记录都查不到时才回落到
|
||||
// asset object 点查兜底,与抠图入口的短路一致。
|
||||
if !editor_reference_object_key_is_registered_for_owner(
|
||||
projects.as_slice(),
|
||||
library.assets.as_slice(),
|
||||
object_key.as_str(),
|
||||
) {
|
||||
ensure_editor_reference_asset_object_owned(
|
||||
state,
|
||||
owner_user_id,
|
||||
object_key.as_str(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
object_key
|
||||
}
|
||||
};
|
||||
if resolved_without_lookup.is_none()
|
||||
&& let Some(source_resource) = source_resource
|
||||
@@ -4638,12 +4689,7 @@ async fn resolve_editor_pixel_art_source_for_owner(
|
||||
// objectKey 在别处登记为动画/音视频时被静态请求掩盖;有 source_resource 时该语义
|
||||
// 权威已经确定,当前项目内的同键记录仍在上面按内存扫过。字节级门禁
|
||||
// (validate_editor_pixel_art_static_raster)在下载后照常执行,不受此影响。
|
||||
if resolved_without_lookup.is_none() {
|
||||
let projects = state
|
||||
.spacetime_client()
|
||||
.list_editor_projects(owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
if let Some((projects, library)) = owner_records.as_ref() {
|
||||
for resource in projects
|
||||
.iter()
|
||||
.flat_map(|project| project.resources.iter())
|
||||
@@ -4660,11 +4706,6 @@ async fn resolve_editor_pixel_art_source_for_owner(
|
||||
resource.asset_kind.as_deref(),
|
||||
);
|
||||
}
|
||||
let library = state
|
||||
.spacetime_client()
|
||||
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
for asset in library.assets.iter().filter(|asset| {
|
||||
editor_record_object_key_matches(
|
||||
asset.object_key.as_deref(),
|
||||
@@ -4764,43 +4805,63 @@ pub async fn snap_editor_image_to_pixel_art(
|
||||
// 都在许可覆盖范围内,许可随 handler 返回自动释放。
|
||||
let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?;
|
||||
let owner_user_id = current_owner_user_id(&authenticated);
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.get_editor_project(EditorProjectGetRecordInput {
|
||||
project_id: project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
// 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口起算,
|
||||
// 起算不等于覆盖——此前这段里的 SpacetimeDB 调用全是裸 await,第一次真正应用预算
|
||||
// 是下载。SpacetimeDB 慢时请求会一路走到下载才发现预算早已耗尽,返回的还是下载相关
|
||||
// 文案,同时全程占着端点准入名额,把并发闸变成瓶颈。
|
||||
// 这里不重复写 `Instant::now() >= deadline` 预检:紧邻上一行的
|
||||
// `acquire_editor_pixel_art_snap_permit` 已经做过该预检,成功即意味着尚未超预算,
|
||||
// 且本块首个 await 是网络 IO 不会立即就绪,不构成 timeout_at 先 poll 再判超时的陷阱。
|
||||
let source_resource_id = normalize_optional_string(payload.source_resource_id.clone());
|
||||
let source =
|
||||
tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), async {
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.get_editor_project(EditorProjectGetRecordInput {
|
||||
project_id: project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
let project = editor_project_payload_from_record(project);
|
||||
validate_editor_pixel_art_snap_placeholder_exists(
|
||||
&project.layers,
|
||||
&payload.canvas_completion,
|
||||
)?;
|
||||
let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() {
|
||||
Some(
|
||||
project
|
||||
.resources
|
||||
.iter()
|
||||
.find(|resource| resource.resource_id.trim() == source_resource_id)
|
||||
.ok_or_else(|| {
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
"来源项目资源不存在或不属于当前画布项目。",
|
||||
)
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
resolve_editor_pixel_art_source_for_owner(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
project_id.as_str(),
|
||||
payload.source_image_src.as_str(),
|
||||
&project,
|
||||
source_resource,
|
||||
payload.asset_kind.as_deref(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
let project = editor_project_payload_from_record(project);
|
||||
validate_editor_pixel_art_snap_placeholder_exists(&project.layers, &payload.canvas_completion)?;
|
||||
let source_resource_id = normalize_optional_string(payload.source_resource_id.clone());
|
||||
let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() {
|
||||
Some(
|
||||
project
|
||||
.resources
|
||||
.iter()
|
||||
.find(|resource| resource.resource_id.trim() == source_resource_id)
|
||||
.ok_or_else(|| {
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
"来源项目资源不存在或不属于当前画布项目。",
|
||||
)
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let source = resolve_editor_pixel_art_source_for_owner(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
project_id.as_str(),
|
||||
payload.source_image_src.as_str(),
|
||||
&project,
|
||||
source_resource,
|
||||
payload.asset_kind.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
.map_err(|_| {
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"完美像素来源归属校验超出处理预算。",
|
||||
)
|
||||
})??;
|
||||
let source_object_key = source.object_key;
|
||||
let asset_kind = source.asset_kind;
|
||||
let source_image = download_editor_persisted_image_object_within_deadline(
|
||||
@@ -12346,9 +12407,15 @@ mod tests {
|
||||
// 中文注释:并发闸必须排在第一次 IO 之前。挪到 .get_editor_project 之后,
|
||||
// 全账号扫描与下载就重新回到闸外,等于闸在资源被消耗之后才检查。
|
||||
"acquire_editor_pixel_art_snap_permit",
|
||||
// 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口
|
||||
// 起算,起算不等于覆盖——这段里的 SpacetimeDB 调用一旦退回裸 await,第一次
|
||||
// 真正应用预算就又变成下载,请求会一路走到下载才发现预算早已耗尽,而且全程
|
||||
// 占着端点准入名额。
|
||||
"tokio::time::timeout_at(",
|
||||
".get_editor_project",
|
||||
"validate_editor_pixel_art_snap_placeholder_exists",
|
||||
"resolve_editor_pixel_art_source_for_owner",
|
||||
"完美像素来源归属校验超出处理预算。",
|
||||
"download_editor_persisted_image_object_within_deadline",
|
||||
"validate_editor_pixel_art_static_raster",
|
||||
"snap_editor_pixel_art_strict",
|
||||
@@ -12365,6 +12432,34 @@ mod tests {
|
||||
"persist_editor_generated_image_owned(",
|
||||
1,
|
||||
);
|
||||
// 中文注释:全账号扫描各只能出现一次。此前解析链路是三个各自取数的函数串联
|
||||
// (resolve_editor_reference_object_key + ensure_editor_reference_object_key_owned
|
||||
// 各扫一轮,本解析器再扫第三轮),同一份数据被拉了 6 次全账号 RPC,全部顺序执行
|
||||
// 在预算和准入名额之内。改为扫一次后交给 `_from_records` 纯函数在内存里解析;
|
||||
// 一旦有人重新引入取数包装,这两条计数会立刻失败。
|
||||
for (scan, expected_occurrences) in [
|
||||
(".list_editor_projects(", 1),
|
||||
(".get_editor_asset_library(", 1),
|
||||
] {
|
||||
assert_function_occurrence_count(
|
||||
source,
|
||||
"async fn resolve_editor_pixel_art_source_for_owner",
|
||||
"fn resolve_editor_pixel_art_asset_folder_id",
|
||||
scan,
|
||||
expected_occurrences,
|
||||
);
|
||||
}
|
||||
assert_function_not_contains(
|
||||
source,
|
||||
"async fn resolve_editor_pixel_art_source_for_owner",
|
||||
"fn resolve_editor_pixel_art_asset_folder_id",
|
||||
&[
|
||||
// 中文注释:这个包装内部自带两轮全账号扫描,已经持有 owner 快照的调用方
|
||||
// 不能再走它,否则同一份数据会被重复拉取三倍。断言调用形式而不是裸函数名,
|
||||
// 否则会命中生产代码里说明「老包装保持不动」的那句注释。
|
||||
"resolve_editor_reference_object_key_for_owner(state",
|
||||
],
|
||||
);
|
||||
assert_function_not_contains(
|
||||
source,
|
||||
"pub async fn snap_editor_image_to_pixel_art(",
|
||||
|
||||
Reference in New Issue
Block a user