diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 615dfb50f..bdecc6b77 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index e506b9095..34897110a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/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,前端只消费响应快照。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ec9fa748e..d93345324 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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(",