From 44604cf82c0d642a0c5a620125d1d1679211acb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 10 Aug 2026 11:50:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E7=BA=BF=E4=B8=8A=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E5=9B=BE=E7=89=87=E9=94=99=E8=AF=AF=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增受权迁移脚本分批清理 assetKind=image 并执行零残留复核。 仅新增 SpacetimeDB 迁移 procedure,不修改 API 和常规 storage 写入语义。 同步 CLI 返回契约、生成绑定、发布制品、运维门禁和数据契约。 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- .../Jenkinsfile.production-stdb-module-build | 2 +- ...Jenkinsfile.production-stdb-module-publish | 2 +- scripts/build-production-release.sh | 1 + scripts/check-production-ops-guardrails.mjs | 25 + ...pacetime-clean-editor-image-asset-kind.mjs | 264 ++++++ ...time-clean-editor-image-asset-kind.test.ts | 201 +++++ scripts/spacetime-migration-common.mjs | 27 +- scripts/spacetime-migration-common.test.ts | 49 ++ .../spacetime-client/src/module_bindings.rs | 6 + ...r_image_asset_kind_and_return_procedure.rs | 59 ++ ...tor_image_asset_kind_cleanup_input_type.rs | 19 + ...sset_kind_cleanup_procedure_result_type.rs | 27 + .../src/editor_project_storage.rs | 786 ++++++++++++++++++ 15 files changed, 1468 insertions(+), 5 deletions(-) create mode 100644 scripts/spacetime-clean-editor-image-asset-kind.mjs create mode 100644 scripts/spacetime-clean-editor-image-asset-kind.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_procedure_result_type.rs diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f4b08154e..704f4acb4 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -448,6 +448,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - `generation_inputs_json` 只保存用户生成 / 重放输入,例如 `fields`、`references`、`artSpec`;动作输出和内部背景决策色不得再写入该 JSON。背景决策审计继续走独立审计链路。 - 存量 `generation_inputs_json.characterAnimation`、已确认动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧中的 `frameIndex` 和误标动作预览 MP4,统一由 migration operator procedure `normalize_editor_character_animation_metadata_and_return` 分 `asset → project-resource → showcase → canvas` 四个 scope 迁移。顶层旧字段只有在 `asset_kind=character-animation`、正式序列字段、嵌套 `characterAnimation` 或权威 `asset_object` 已证明该行属于角色动作时才解释,普通图片 / 视频任意 JSON 中同名字段保持原样。迁移在计划态先把同 task 的已验证预览 MP4 重分类为 `video`,只有能形成正式图片序列的候选才可用于后续 scope;project-resource 自身缺帧但能用同 owner、同 task、同首帧 `assetObjectId/objectKey` 精确定位唯一最终账号素材时,dry-run 可消费 asset 的计划态正式序列,apply 仍要求 asset scope 已物理完成。canvas 同样按 project-resource 的计划态类型分类:旧库误标为动作、但规划后为 `video` 且 layout 未声明动作的 preview 图层不参与动作副本清理;layout 明确声明动作却指向该视频,或原始动作资源的规划存在 blocker,继续失败关闭。每一帧必须按首帧 bucket 下的精确对象路径匹配同 owner、同 task、`editor_character_animation` 且为图片的 `asset_object`,并从登记对象补齐 `objectKey/assetObjectId`。canvas layout 复制的 `sourceResourceId` 不参与迁移血缘校验,补建资源只采用最终账号素材的 DB 血缘;新生成链路仍严格校验直接来源。正式与旧版帧或时长冲突、最终候选为零或多于一个、帧对象无法精确验证和缺失正式帧集均作为 blocker,整批拒绝 apply;procedure 同时返回不含生成输入正文的 blocker 完整诊断,包含 scope、ID、原因、owner、project、task、对象身份和来源资源。 - 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `;默认全量 dry-run,`--apply` 时每批先 dry-run,再用返回的 batch SHA-256 写入,四个 scope 完成后从头执行零匹配 / 零 blocker 复核。迁移完成后 api-server、后台、前端和外部 helper 只读取正式字段,不再包含 legacy fallback。新建动作资源 / 素材若在 `generationInputs` 提交旧运行字段,或正式帧包含 `frameIndex`,api-server 与 SpacetimeDB storage 均失败关闭;其它素材的任意生成输入不受动作专属门禁影响。 +- 2026-08-10 前短期错误版本写入的 `asset_kind = "image"` 不走永久兼容,统一由 migration operator procedure `clean_editor_image_asset_kind_and_return` 清理。scope 固定为 `asset → project-resource → showcase → canvas`:前三者只把精确旧值改为 `None`;canvas 同时清理 `editor_canvas` / `editor_project` 双份 legacy layout 顶层 `assetKind` / `assetKindOverride` 和结构化 `editor_canvas_layer.asset_kind_override`,但不递归修改 `generationInputs.references[*].mediaType`,也不修改 MIME、`CanvasMediaType` 或 `asset_object.asset_kind`。纯分类修复保留原 `updated_at`、画布 revision 和迁移状态。双份 layout 清理后仍不一致、缺工程、owner 不一致或命中字段的画布项缺少稳定 layer / resource 身份时形成 blocker,整批拒绝 apply;诊断只返回 ID SHA-256、scope 和原因。 - 普通画布 layer 的持久化和前端响应不包含顶层 `mediaType`;渲染类型只由资源 / 素材 `assetKind` 在前端派生。`generationInputs.references[*].mediaType` 是生成参考输入契约,不属于画布 layer legacy 字段,迁移和响应清洗不得递归删除。 ## 新增编辑器 `assetKind` 接入清单(2026-08-03,2026-08-08 增补) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 995951811..0987165e6 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -94,6 +94,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。 +普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-url `,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持会写入旧值的在线 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME、媒体类型、API 或常规 storage 写入语义;完成零残留复核后恢复已修正的应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。 + 自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。 Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 @@ -597,7 +599,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. 同一 API release 随包依赖还必须包含 `scripts/check-pingora-release-readiness.mjs` 与 `scripts/check-pingora-canary-live.mjs`。前者在 current release 上以 `--release-runtime-only` 汇总运行时复核,后者支撑目标 Nginx canary live smoke;缺少任一脚本时不能进入直连切换窗口。 -`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build//spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-normalize-editor-character-actions.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。 +`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build//spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-clean-editor-image-asset-kind.mjs`、`scripts/spacetime-normalize-editor-character-actions.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。 三个 SCM Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Secret File 的原文只存在于 Jenkins Credentials;credential ID、参数默认值和定时 / 发布行为以仓库 Jenkinsfile 为事实源,不能只改 Job UI,因为 Declarative Pipeline 下一次载入会重写参数定义。旧 Secret Text `genarrative-spacetime-bootstrap-secret-dev` 继续保留给 Database Import / Export,不得原地改类型或删除。 diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index c326c9cb7..28f9c6285 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -154,7 +154,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-clean-editor-image-asset-kind.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 9e6be03ea..3aa58b036 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -93,7 +93,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", + filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-clean-editor-image-asset-kind.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", target: '.', fingerprintArtifacts: true ) diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index 455636667..2e7d60489 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本" copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-clean-editor-image-asset-kind.mjs" "${TARGET_DIR}/scripts/spacetime-clean-editor-image-asset-kind.mjs" "普通图片素材类型清理脚本" copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本" copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本" copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本" diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 05b816928..ee0975dc9 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -651,6 +651,21 @@ const checks = [ includes: "await scanScopes(options, { verifyZero: true })", reason: '角色动作规范化 apply 后必须执行全量零匹配复核。', }, + { + file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs', + includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']", + reason: '普通图片错误素材类型必须覆盖三张业务表和持久化画布副本。', + }, + { + file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs', + includes: 'expectedBatchSha256: dryRun.batch_sha256', + reason: '普通图片素材类型 apply 必须绑定同批 dry-run 返回的摘要。', + }, + { + file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs', + includes: "await scanScopes(options, { verifyZero: true })", + reason: '普通图片素材类型清理 apply 后必须执行全量零匹配复核。', + }, { file: 'scripts/spacetime-repair-editor-canvas-resources.mjs', includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)', @@ -686,6 +701,16 @@ const checks = [ includes: 'scripts/spacetime-normalize-editor-character-actions.mjs', reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs', + reason: 'Stdb Build 必须归档普通图片素材类型清理脚本。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs', + reason: 'Stdb Publish 必须从同一上游制品复制普通图片素材类型清理脚本。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs', diff --git a/scripts/spacetime-clean-editor-image-asset-kind.mjs b/scripts/spacetime-clean-editor-image-asset-kind.mjs new file mode 100644 index 000000000..92ca65ad0 --- /dev/null +++ b/scripts/spacetime-clean-editor-image-asset-kind.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +import { + callSpacetimeProcedure, + callSpacetimeProcedureViaCli, + encodeSpacetimeCliOption, + ensureProcedureOk, + parsePositiveInteger, +} from './spacetime-migration-common.mjs'; + +const PROCEDURE_NAME = 'clean_editor_image_asset_kind_and_return'; +const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']; +const DEFAULT_CHUNK_SIZE = 25; +const CANVAS_MAX_CHUNK_SIZE = 5; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function usage() { + return `用法: + node scripts/spacetime-clean-editor-image-asset-kind.mjs \\ + --database --server [--chunk-size <1-25>] [--apply] + +默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。 +脚本只把业务 assetKind 精确等于 "image" 的旧值清为空;不会修改 MIME、媒体类型或 asset_object.asset_kind。 +追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。 +apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。 +必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`; +} + +export function parseOptions(argv, env = process.env) { + const options = { + apply: false, + chunkSize: DEFAULT_CHUNK_SIZE, + database: env.GENARRATIVE_SPACETIME_DATABASE || '', + passthrough: [], + server: env.GENARRATIVE_SPACETIME_SERVER || '', + serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '', + token: env.GENARRATIVE_SPACETIME_TOKEN || '', + useHttp: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} 缺少参数值。`); + } + index += 1; + return value.trim(); + }; + if (arg === '--database') { + options.database = readValue(); + } else if (arg === '--server') { + options.server = readValue(); + } else if (arg === '--server-url') { + options.serverUrl = readValue(); + } else if (arg === '--token') { + options.token = readValue(); + } else if (arg === '--chunk-size') { + options.chunkSize = parsePositiveInteger(readValue(), arg); + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--use-http') { + options.useHttp = true; + } else if (arg === '--no-config' || arg === '--anonymous') { + options.passthrough.push(arg); + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else { + throw new Error(`未知参数: ${arg}`); + } + } + if (options.chunkSize > DEFAULT_CHUNK_SIZE) { + throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}。`); + } + return options; +} + +export function buildCleanupInput({ + scope, + cursor = null, + limit, + dryRun, + expectedBatchSha256 = null, +}) { + if (!SCOPES.includes(scope)) { + throw new Error(`未知普通图片 assetKind 清理 scope: ${scope}`); + } + if (!Number.isInteger(limit) || limit < 1) { + throw new Error('普通图片 assetKind 清理 limit 必须是正整数。'); + } + if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) { + throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}。`); + } + if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) { + throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。'); + } + return { + scope, + cursor: encodeSpacetimeCliOption(cursor), + limit, + dry_run: dryRun, + expected_batch_sha_256: encodeSpacetimeCliOption( + dryRun ? null : expectedBatchSha256, + ), + }; +} + +function scopeLimit(scope, chunkSize) { + return scope === 'canvas' + ? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE) + : chunkSize; +} + +function assertSafeBatch(result, scope) { + ensureProcedureOk(result); + if (result.scope !== scope) { + throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}。`); + } + if (result.blocker_count !== 0 || result.blocker_samples.length !== 0) { + throw new Error(`${scope} scope 存在 ${result.blocker_count} 个 blocker。`); + } + if (!SHA256_PATTERN.test(result.batch_sha256 || '')) { + throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`); + } +} + +async function callBatch(options, input) { + return options.useHttp + ? callSpacetimeProcedure(options, PROCEDURE_NAME, input) + : callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input); +} + +export async function scanScopes( + options, + { apply = false, verifyZero = false, callProcedure = callBatch } = {}, +) { + const summaries = []; + for (const scope of SCOPES) { + let cursor = null; + const seenCursors = new Set(); + const summary = { + scope, + scanned_count: 0, + matched_count: 0, + updated_count: 0, + cleaned_field_count: 0, + batches: 0, + }; + do { + const limit = scopeLimit(scope, options.chunkSize); + const dryRun = await callProcedure( + options, + buildCleanupInput({ scope, cursor, limit, dryRun: true }), + ); + assertSafeBatch(dryRun, scope); + summary.scanned_count += dryRun.scanned_count; + summary.matched_count += dryRun.matched_count; + summary.cleaned_field_count += dryRun.cleaned_field_count; + summary.batches += 1; + + if (verifyZero && dryRun.matched_count !== 0) { + throw new Error( + `${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待清理。`, + ); + } + if (apply && dryRun.matched_count > 0) { + const applied = await callProcedure( + options, + buildCleanupInput({ + scope, + cursor, + limit, + dryRun: false, + expectedBatchSha256: dryRun.batch_sha256, + }), + ); + assertSafeBatch(applied, scope); + if (applied.batch_sha256 !== dryRun.batch_sha256) { + throw new Error(`${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`); + } + if (applied.updated_count !== dryRun.matched_count) { + throw new Error( + `${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`, + ); + } + if (applied.cleaned_field_count !== dryRun.cleaned_field_count) { + throw new Error(`${scope} scope apply 返回的清理字段数与 dry-run 不一致。`); + } + summary.updated_count += applied.updated_count; + } + const nextCursor = dryRun.has_more ? dryRun.next_cursor : null; + if (dryRun.has_more && !nextCursor) { + throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`); + } + if (nextCursor && seenCursors.has(nextCursor)) { + throw new Error( + `${scope} scope 返回了重复的 next_cursor(SHA-256: ${sha256(nextCursor)})。`, + ); + } + if (nextCursor) { + seenCursors.add(nextCursor); + } + cursor = nextCursor; + } while (cursor); + summaries.push(summary); + } + return summaries; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseOptions(argv); + if (options.help) { + console.log(usage()); + return; + } + if (!options.database) { + throw new Error('必须显式传入 --database。'); + } + if (!options.server && !options.serverUrl) { + throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。'); + } + if (options.useHttp && !options.token) { + throw new Error('--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。'); + } + + const migration = await scanScopes(options, { apply: options.apply }); + const verification = options.apply + ? await scanScopes(options, { verifyZero: true }) + : null; + console.log( + JSON.stringify( + { + procedure: PROCEDURE_NAME, + applied: options.apply, + scope_order: SCOPES, + migration, + verification, + }, + null, + 2, + ), + ); + if (!options.apply) { + console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。'); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((error) => { + console.error( + `[spacetime:editor-image-asset-kind:clean] ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/spacetime-clean-editor-image-asset-kind.test.ts b/scripts/spacetime-clean-editor-image-asset-kind.test.ts new file mode 100644 index 000000000..a2dd8f528 --- /dev/null +++ b/scripts/spacetime-clean-editor-image-asset-kind.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCleanupInput, + parseOptions, + scanScopes, +} from './spacetime-clean-editor-image-asset-kind.mjs'; + +describe('普通图片 assetKind 清理脚本', () => { + it('默认 dry-run 并要求调用方显式选择 apply', () => { + expect( + parseOptions( + ['--database', 'genarrative-prod', '--server', 'prod'], + {}, + ), + ).toMatchObject({ + apply: false, + chunkSize: 25, + database: 'genarrative-prod', + server: 'prod', + }); + }); + + it('编码 cursor、限制 canvas 批量并要求 apply hash', () => { + expect( + buildCleanupInput({ + scope: 'asset', + cursor: 'asset-25', + limit: 25, + dryRun: true, + }), + ).toEqual({ + scope: 'asset', + cursor: [0, 'asset-25'], + limit: 25, + dry_run: true, + expected_batch_sha_256: null, + }); + expect(() => + buildCleanupInput({ scope: 'canvas', limit: 6, dryRun: true }), + ).toThrow('canvas scope limit'); + expect(() => + buildCleanupInput({ scope: 'showcase', limit: 25, dryRun: false }), + ).toThrow('batch SHA-256'); + }); + + it('按固定 scope 顺序执行 hash 绑定 apply 并保留字段计数', async () => { + const calls: Array> = []; + const callProcedure = async ( + _options: Record, + input: Record, + ) => { + calls.push(input); + const dryRun = input.dry_run === true; + const scope = String(input.scope); + const hashDigit = { + asset: 'a', + 'project-resource': 'b', + showcase: 'c', + canvas: 'd', + }[scope]!; + return { + ok: true, + scope, + dry_run: dryRun, + scanned_count: 1, + matched_count: 1, + updated_count: dryRun ? 0 : 1, + cleaned_field_count: scope === 'canvas' ? 3 : 1, + blocker_count: 0, + blocker_samples: [], + next_cursor: null, + has_more: false, + batch_sha256: hashDigit.repeat(64), + error_message: null, + }; + }; + + const summaries = await scanScopes( + { chunkSize: 25 }, + { apply: true, callProcedure }, + ); + + expect(summaries.map((summary) => summary.scope)).toEqual([ + 'asset', + 'project-resource', + 'showcase', + 'canvas', + ]); + expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([ + 'asset:true', + 'asset:false', + 'project-resource:true', + 'project-resource:false', + 'showcase:true', + 'showcase:false', + 'canvas:true', + 'canvas:false', + ]); + expect(summaries.at(-1)?.cleaned_field_count).toBe(3); + expect(calls.at(-2)?.limit).toBe(5); + for (let index = 1; index < calls.length; index += 2) { + expect(calls[index]?.expected_batch_sha_256).toEqual([ + 0, + String(calls[index - 1]?.scope === 'asset' + ? 'a' + : calls[index - 1]?.scope === 'project-resource' + ? 'b' + : calls[index - 1]?.scope === 'showcase' + ? 'c' + : 'd').repeat(64), + ]); + } + }); + + it('拒绝 apply 后计数漂移与复核残留', async () => { + const driftingCall = async ( + _options: Record, + input: Record, + ) => ({ + ok: true, + scope: input.scope, + dry_run: input.dry_run, + scanned_count: 1, + matched_count: 1, + updated_count: input.dry_run ? 0 : 1, + cleaned_field_count: input.dry_run ? 2 : 1, + blocker_count: 0, + blocker_samples: [], + next_cursor: null, + has_more: false, + batch_sha256: 'a'.repeat(64), + error_message: null, + }); + await expect( + scanScopes( + { chunkSize: 25 }, + { apply: true, callProcedure: driftingCall }, + ), + ).rejects.toThrow('清理字段数'); + + const residualCall = async ( + _options: Record, + input: Record, + ) => ({ + ok: true, + scope: input.scope, + dry_run: true, + scanned_count: 1, + matched_count: 1, + updated_count: 0, + cleaned_field_count: 1, + blocker_count: 0, + blocker_samples: [], + next_cursor: null, + has_more: false, + batch_sha256: 'b'.repeat(64), + error_message: null, + }); + await expect( + scanScopes( + { chunkSize: 25 }, + { verifyZero: true, callProcedure: residualCall }, + ), + ).rejects.toThrow('apply 后复核仍有'); + }); + + it('报告游标循环时不泄露原始标识', async () => { + const privateCursor = 'private-asset-id'; + const loopingCall = async ( + _options: Record, + input: Record, + ) => ({ + ok: true, + scope: input.scope, + dry_run: true, + scanned_count: 1, + matched_count: 0, + updated_count: 0, + cleaned_field_count: 0, + blocker_count: 0, + blocker_samples: [], + next_cursor: privateCursor, + has_more: true, + batch_sha256: 'c'.repeat(64), + error_message: null, + }); + + let message = ''; + try { + await scanScopes( + { chunkSize: 25 }, + { callProcedure: loopingCall }, + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain('SHA-256'); + expect(message).not.toContain(privateCursor); + }); +}); diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index e2f628ade..6cc7cbbe4 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -294,8 +294,10 @@ function normalizeProcedureResult(value, procedureName) { function normalizeSatsObject(value, procedureName) { const normalized = normalizeSatsValue(value); if ( - procedureName !== - 'normalize_editor_character_animation_metadata_and_return' || + ![ + 'normalize_editor_character_animation_metadata_and_return', + 'clean_editor_image_asset_kind_and_return', + ].includes(procedureName) || !normalized || typeof normalized !== 'object' || Array.isArray(normalized) @@ -311,6 +313,27 @@ function normalizeSatsObject(value, procedureName) { } function normalizeSatsProduct(value, procedureName) { + if ( + procedureName === 'clean_editor_image_asset_kind_and_return' && + value.length === 13 + ) { + return { + ok: normalizeSatsValue(value[0]), + scope: normalizeSatsValue(value[1]), + dry_run: normalizeSatsValue(value[2]), + scanned_count: normalizeSatsValue(value[3]), + matched_count: normalizeSatsValue(value[4]), + updated_count: normalizeSatsValue(value[5]), + cleaned_field_count: normalizeSatsValue(value[6]), + blocker_count: normalizeSatsValue(value[7]), + blocker_samples: normalizeSatsValue(value[8]), + next_cursor: normalizeSatsOption(value[9]), + has_more: normalizeSatsValue(value[10]), + batch_sha256: normalizeSatsValue(value[11]), + error_message: normalizeSatsOption(value[12]), + }; + } + if ( procedureName === 'normalize_editor_character_animation_metadata_and_return' && value.length === 19 diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 3ee503215..6317bd5d9 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -174,4 +174,53 @@ describe('SpacetimeDB CLI SATS option encoding', () => { }); expect(result).not.toHaveProperty('batch_sha_256'); }); + + it('normalizes ordinary image assetKind cleanup results', () => { + const tupleResult = parseProcedureResult( + JSON.stringify([ + true, + 'canvas', + true, + 5, + 2, + 0, + 4, + 0, + [], + [0, 'canvas-5'], + true, + 'c'.repeat(64), + [1], + ]), + 'clean_editor_image_asset_kind_and_return', + ); + expect(tupleResult).toMatchObject({ + scope: 'canvas', + cleaned_field_count: 4, + next_cursor: 'canvas-5', + batch_sha256: 'c'.repeat(64), + error_message: null, + }); + + const objectResult = parseProcedureResult( + JSON.stringify({ + ok: true, + scope: 'asset', + dry_run: true, + scanned_count: 1, + matched_count: 1, + updated_count: 0, + cleaned_field_count: 1, + blocker_count: 0, + blocker_samples: [], + next_cursor: null, + has_more: false, + batch_sha_256: 'd'.repeat(64), + error_message: null, + }), + 'clean_editor_image_asset_kind_and_return', + ); + expect(objectResult.batch_sha256).toBe('d'.repeat(64)); + expect(objectResult).not.toHaveProperty('batch_sha_256'); + }); }); diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 0e5acdd91..acf224669 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -172,6 +172,7 @@ pub mod chapter_progression_type; pub mod claim_external_generation_jobs_and_return_procedure; pub mod claim_profile_recharge_order_expiration_schedule_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; +pub mod clean_editor_image_asset_kind_and_return_procedure; pub mod clear_database_migration_import_chunks_procedure; pub mod close_profile_recharge_order_and_return_procedure; pub mod combat_outcome_type; @@ -308,6 +309,8 @@ pub mod editor_generation_runtime_identity_rotation_table; pub mod editor_generation_runtime_identity_rotation_type; pub mod editor_generation_target_preflight_input_type; pub mod editor_generation_target_preflight_result_type; +pub mod editor_image_asset_kind_cleanup_input_type; +pub mod editor_image_asset_kind_cleanup_procedure_result_type; pub mod editor_pixel_art_canvas_completion_input_type; pub mod editor_pixel_art_canvas_placeholder_input_type; pub mod editor_pixel_art_result_persist_input_type; @@ -1021,6 +1024,7 @@ pub use chapter_progression_type::ChapterProgression; pub use claim_external_generation_jobs_and_return_procedure::claim_external_generation_jobs_and_return; pub use claim_profile_recharge_order_expiration_schedule_and_return_procedure::claim_profile_recharge_order_expiration_schedule_and_return; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; +pub use clean_editor_image_asset_kind_and_return_procedure::clean_editor_image_asset_kind_and_return; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use close_profile_recharge_order_and_return_procedure::close_profile_recharge_order_and_return; pub use combat_outcome_type::CombatOutcome; @@ -1157,6 +1161,8 @@ pub use editor_generation_runtime_identity_rotation_table::*; pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation; pub use editor_generation_target_preflight_input_type::EditorGenerationTargetPreflightInput; pub use editor_generation_target_preflight_result_type::EditorGenerationTargetPreflightResult; +pub use editor_image_asset_kind_cleanup_input_type::EditorImageAssetKindCleanupInput; +pub use editor_image_asset_kind_cleanup_procedure_result_type::EditorImageAssetKindCleanupProcedureResult; pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput; pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs new file mode 100644 index 000000000..1241d9343 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/clean_editor_image_asset_kind_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_image_asset_kind_cleanup_input_type::EditorImageAssetKindCleanupInput; +use super::editor_image_asset_kind_cleanup_procedure_result_type::EditorImageAssetKindCleanupProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CleanEditorImageAssetKindAndReturnArgs { + pub input: EditorImageAssetKindCleanupInput, +} + +impl __sdk::InModule for CleanEditorImageAssetKindAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `clean_editor_image_asset_kind_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait clean_editor_image_asset_kind_and_return { + fn clean_editor_image_asset_kind_and_return(&self, input: EditorImageAssetKindCleanupInput) { + self.clean_editor_image_asset_kind_and_return_then(input, |_, _| {}); + } + + fn clean_editor_image_asset_kind_and_return_then( + &self, + input: EditorImageAssetKindCleanupInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl clean_editor_image_asset_kind_and_return for super::RemoteProcedures { + fn clean_editor_image_asset_kind_and_return_then( + &self, + input: EditorImageAssetKindCleanupInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorImageAssetKindCleanupProcedureResult>( + "clean_editor_image_asset_kind_and_return", + CleanEditorImageAssetKindAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_input_type.rs new file mode 100644 index 000000000..2a331a255 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorImageAssetKindCleanupInput { + pub scope: String, + pub cursor: Option, + pub limit: u32, + pub dry_run: bool, + pub expected_batch_sha_256: Option, +} + +impl __sdk::InModule for EditorImageAssetKindCleanupInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_procedure_result_type.rs new file mode 100644 index 000000000..8784233e7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_image_asset_kind_cleanup_procedure_result_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorImageAssetKindCleanupProcedureResult { + pub ok: bool, + pub scope: String, + pub dry_run: bool, + pub scanned_count: u32, + pub matched_count: u32, + pub updated_count: u32, + pub cleaned_field_count: u32, + pub blocker_count: u32, + pub blocker_samples: Vec, + pub next_cursor: Option, + pub has_more: bool, + pub batch_sha_256: String, + pub error_message: Option, +} + +impl __sdk::InModule for EditorImageAssetKindCleanupProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 7ccce2f48..cbb36a7c1 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -21,6 +21,9 @@ const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25; const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5; +const EDITOR_IMAGE_ASSET_KIND_CLEANUP_MAX_BATCH_SIZE: u32 = 25; +const EDITOR_IMAGE_ASSET_KIND_CLEANUP_CANVAS_MAX_BATCH_SIZE: u32 = 5; +const EDITOR_LEGACY_IMAGE_ASSET_KIND: &str = "image"; // 透明图集最多产出 64 个切片,另有 provider 原图和透明整图两个正式 item。 const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 66; const EDITOR_GENERATION_OPERATION_KINDS: [&str; 10] = [ @@ -671,6 +674,32 @@ pub struct EditorCharacterAnimationNormalizationProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorImageAssetKindCleanupInput { + pub scope: String, + pub cursor: Option, + pub limit: u32, + pub dry_run: bool, + pub expected_batch_sha256: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorImageAssetKindCleanupProcedureResult { + pub ok: bool, + pub scope: String, + pub dry_run: bool, + pub scanned_count: u32, + pub matched_count: u32, + pub updated_count: u32, + pub cleaned_field_count: u32, + pub blocker_count: u32, + pub blocker_samples: Vec, + pub next_cursor: Option, + pub has_more: bool, + pub batch_sha256: String, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceCreateInput { pub resource_id: String, @@ -1594,6 +1623,25 @@ pub fn normalize_editor_character_animation_metadata_and_return( } } +#[spacetimedb::procedure] +pub fn clean_editor_image_asset_kind_and_return( + ctx: &mut ProcedureContext, + input: EditorImageAssetKindCleanupInput, +) -> EditorImageAssetKindCleanupProcedureResult { + let caller = ctx.sender(); + let fallback_scope = input.scope.clone(); + let fallback_dry_run = input.dry_run; + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + clean_editor_image_asset_kind(tx, input.clone()) + }) { + Ok(result) => result, + Err(message) => { + failed_editor_image_asset_kind_cleanup_result(fallback_scope, fallback_dry_run, message) + } + } +} + #[spacetimedb::procedure] pub fn rename_editor_project_and_return( ctx: &mut ProcedureContext, @@ -12421,6 +12469,680 @@ fn failed_editor_character_animation_normalization_result( ) } +#[derive(Default)] +struct EditorImageAssetKindCleanupStats { + scanned_count: u32, + matched_count: u32, + updated_count: u32, + cleaned_field_count: u32, + blocker_samples: Vec, + next_cursor: Option, + has_more: bool, + batch_sha256: String, +} + +#[derive(Clone, Debug)] +struct EditorImageAssetKindLayerPatch { + layer_key: String, + item_json: String, + clear_override: bool, +} + +#[derive(Clone, Debug)] +struct EditorImageAssetKindCanvasPlan { + canvas_layers_json: String, + project_layers_json: String, + layer_patches: Vec, + source_fingerprint: String, + cleaned_field_count: u32, + changed: bool, + blocker: Option, +} + +fn clean_editor_image_asset_kind( + ctx: &ReducerContext, + input: EditorImageAssetKindCleanupInput, +) -> Result { + let scope = input.scope.trim(); + if !matches!(scope, "asset" | "project-resource" | "showcase" | "canvas") { + return Err( + "普通图片 asset_kind 清理 scope 只支持 asset、project-resource、showcase、canvas" + .to_string(), + ); + } + let maximum = if scope == "canvas" { + EDITOR_IMAGE_ASSET_KIND_CLEANUP_CANVAS_MAX_BATCH_SIZE + } else { + EDITOR_IMAGE_ASSET_KIND_CLEANUP_MAX_BATCH_SIZE + }; + if input.limit == 0 || input.limit > maximum { + return Err(format!( + "普通图片 asset_kind 清理 limit 必须在 1 到 {maximum} 之间" + )); + } + if input.dry_run && input.expected_batch_sha256.is_some() { + return Err("dry-run 不能提交 expected_batch_sha256".to_string()); + } + if !input.dry_run + && input + .expected_batch_sha256 + .as_deref() + .is_none_or(str::is_empty) + { + return Err("apply 必须提交 dry-run 返回的 expected_batch_sha256".to_string()); + } + + let stats = match scope { + "asset" => clean_editor_image_asset_kind_assets(ctx, &input)?, + "project-resource" => clean_editor_image_asset_kind_project_resources(ctx, &input)?, + "showcase" => clean_editor_image_asset_kind_showcases(ctx, &input)?, + "canvas" => clean_editor_image_asset_kind_canvases(ctx, &input)?, + _ => unreachable!(), + }; + let error_message = (!stats.blocker_samples.is_empty()).then(|| { + let operation = if input.dry_run { + "dry-run 无法继续" + } else { + "拒绝整批 apply" + }; + format!( + "普通图片 asset_kind 清理批次包含 {} 个 blocker,{operation}:\n{}", + stats.blocker_samples.len(), + stats.blocker_samples.join("\n") + ) + }); + Ok(editor_image_asset_kind_cleanup_result( + scope.to_string(), + input.dry_run, + stats, + error_message, + )) +} + +fn editor_image_asset_kind_cleanup_result( + scope: String, + dry_run: bool, + stats: EditorImageAssetKindCleanupStats, + error_message: Option, +) -> EditorImageAssetKindCleanupProcedureResult { + EditorImageAssetKindCleanupProcedureResult { + ok: error_message.is_none(), + scope, + dry_run, + scanned_count: stats.scanned_count, + matched_count: stats.matched_count, + updated_count: stats.updated_count, + cleaned_field_count: stats.cleaned_field_count, + blocker_count: stats.blocker_samples.len() as u32, + blocker_samples: stats.blocker_samples, + next_cursor: stats.next_cursor, + has_more: stats.has_more, + batch_sha256: stats.batch_sha256, + error_message, + } +} + +fn failed_editor_image_asset_kind_cleanup_result( + scope: String, + dry_run: bool, + message: String, +) -> EditorImageAssetKindCleanupProcedureResult { + editor_image_asset_kind_cleanup_result( + scope, + dry_run, + EditorImageAssetKindCleanupStats::default(), + Some(message), + ) +} + +fn editor_image_asset_kind_cleanup_cursor( + input: &EditorImageAssetKindCleanupInput, +) -> Option<&str> { + input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn update_editor_image_asset_kind_cleanup_hash( + hasher: &mut Sha256, + scope: &str, + id: &str, + source_fingerprint: &str, + plan_fingerprint: &str, +) { + for value in [scope, id, source_fingerprint, plan_fingerprint] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } +} + +fn finalize_editor_image_asset_kind_cleanup_apply_gate( + input: &EditorImageAssetKindCleanupInput, + stats: &mut EditorImageAssetKindCleanupStats, + hasher: Sha256, +) -> Result<(), String> { + stats.batch_sha256 = format!("{:x}", hasher.finalize()); + if input.dry_run { + return Ok(()); + } + if input.expected_batch_sha256.as_deref() != Some(stats.batch_sha256.as_str()) { + return Err("普通图片 asset_kind 清理批次 hash 已变化,拒绝 apply".to_string()); + } + Ok(()) +} + +fn editor_image_asset_kind_cleanup_blocker_sample(scope: &str, id: &str, reason: &str) -> String { + json!({ + "scope": scope, + "idSha256": sha256_hex(id.as_bytes()), + "reason": reason, + }) + .to_string() +} + +fn clean_editor_image_asset_kind_assets( + ctx: &ReducerContext, + input: &EditorImageAssetKindCleanupInput, +) -> Result { + let cursor = editor_image_asset_kind_cleanup_cursor(input); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_asset() + .by_editor_asset_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.asset_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorImageAssetKindCleanupStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.asset_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + for row in &rows { + let matched = row.asset_kind.as_deref() == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND); + stats.matched_count = stats.matched_count.saturating_add(u32::from(matched)); + stats.cleaned_field_count = stats.cleaned_field_count.saturating_add(u32::from(matched)); + update_editor_image_asset_kind_cleanup_hash( + &mut hasher, + "asset", + row.asset_id.as_str(), + row.asset_kind.as_deref().unwrap_or_default(), + if matched { "clear" } else { "keep" }, + ); + } + finalize_editor_image_asset_kind_cleanup_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run { + for mut row in rows { + if row.asset_kind.as_deref() != Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) { + continue; + } + row.asset_kind = None; + ctx.db.editor_asset().asset_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn clean_editor_image_asset_kind_project_resources( + ctx: &ReducerContext, + input: &EditorImageAssetKindCleanupInput, +) -> Result { + let cursor = editor_image_asset_kind_cleanup_cursor(input); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_project_resource() + .by_editor_project_resource_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.resource_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorImageAssetKindCleanupStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.resource_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + for row in &rows { + let matched = row.asset_kind.as_deref() == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND); + stats.matched_count = stats.matched_count.saturating_add(u32::from(matched)); + stats.cleaned_field_count = stats.cleaned_field_count.saturating_add(u32::from(matched)); + update_editor_image_asset_kind_cleanup_hash( + &mut hasher, + "project-resource", + row.resource_id.as_str(), + row.asset_kind.as_deref().unwrap_or_default(), + if matched { "clear" } else { "keep" }, + ); + } + finalize_editor_image_asset_kind_cleanup_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run { + for mut row in rows { + if row.asset_kind.as_deref() != Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) { + continue; + } + row.asset_kind = None; + ctx.db.editor_project_resource().resource_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn clean_editor_image_asset_kind_showcases( + ctx: &ReducerContext, + input: &EditorImageAssetKindCleanupInput, +) -> Result { + let cursor = editor_image_asset_kind_cleanup_cursor(input); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_showcase_asset() + .by_editor_showcase_asset_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.showcase_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorImageAssetKindCleanupStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.showcase_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + for row in &rows { + let matched = row.asset_kind.as_deref() == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND); + stats.matched_count = stats.matched_count.saturating_add(u32::from(matched)); + stats.cleaned_field_count = stats.cleaned_field_count.saturating_add(u32::from(matched)); + update_editor_image_asset_kind_cleanup_hash( + &mut hasher, + "showcase", + row.showcase_id.as_str(), + row.asset_kind.as_deref().unwrap_or_default(), + if matched { "clear" } else { "keep" }, + ); + } + finalize_editor_image_asset_kind_cleanup_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run { + for mut row in rows { + if row.asset_kind.as_deref() != Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) { + continue; + } + row.asset_kind = None; + ctx.db.editor_showcase_asset().showcase_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn layout_may_contain_legacy_image_asset_kind(layers_json: &str) -> bool { + layers_json.contains("\"image\"") + && (layers_json.contains("\"assetKind\"") || layers_json.contains("\"assetKindOverride\"")) +} + +fn clean_legacy_image_asset_kind_from_layer_object( + object: &mut JsonMap, + require_layer_identity: bool, +) -> Result { + let matched_fields = ["assetKind", "assetKindOverride"] + .into_iter() + .filter(|field| { + object.get(*field).and_then(JsonValue::as_str) == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) + }) + .collect::>(); + if matched_fields.is_empty() { + return Ok(0); + } + if require_layer_identity { + for required_field in ["layerId", "resourceId"] { + json_required_string(object.get(required_field), required_field).map_err(|_| { + format!("包含 assetKind=image 的画布项缺少有效 {required_field},拒绝推断其语义") + })?; + } + } + for field in &matched_fields { + object.remove(*field); + } + Ok(matched_fields.len() as u32) +} + +fn clean_legacy_image_asset_kind_from_layout(layers_json: &str) -> Result<(String, u32), String> { + if !layout_may_contain_legacy_image_asset_kind(layers_json) { + return Ok((layers_json.to_string(), 0)); + } + let mut value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut cleaned_field_count = 0u32; + for item in items { + let Some(object) = item.as_object_mut() else { + continue; + }; + cleaned_field_count = cleaned_field_count.saturating_add( + clean_legacy_image_asset_kind_from_layer_object(object, true)?, + ); + } + if cleaned_field_count == 0 { + return Ok((layers_json.to_string(), 0)); + } + let cleaned = serde_json::to_string(&value) + .map_err(|_| "普通图片 asset_kind 清理后的画布布局无法序列化".to_string())?; + if cleaned.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { + return Err("普通图片 asset_kind 清理后的画布布局超过 2 MiB".to_string()); + } + Ok((cleaned, cleaned_field_count)) +} + +fn clean_legacy_image_asset_kind_from_layer_item_json( + item_json: &str, +) -> Result<(String, u32), String> { + if !layout_may_contain_legacy_image_asset_kind(item_json) { + return Ok((item_json.to_string(), 0)); + } + let mut value = serde_json::from_str::(item_json) + .map_err(|_| "结构化画布图层 item_json 不是合法 JSON".to_string())?; + let object = value + .as_object_mut() + .ok_or_else(|| "结构化画布图层 item_json 必须是对象".to_string())?; + let cleaned_field_count = clean_legacy_image_asset_kind_from_layer_object(object, false)?; + if cleaned_field_count == 0 { + return Ok((item_json.to_string(), 0)); + } + let cleaned = serde_json::to_string(&value) + .map_err(|_| "普通图片 asset_kind 清理后的结构化图层无法序列化".to_string())?; + if cleaned.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { + return Err("普通图片 asset_kind 清理后的结构化图层超过 512 KiB".to_string()); + } + Ok((cleaned, cleaned_field_count)) +} + +fn plan_editor_image_asset_kind_canvas( + ctx: &ReducerContext, + canvas: &EditorCanvas, +) -> EditorImageAssetKindCanvasPlan { + let project = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id); + let mut layer_rows = ctx + .db + .editor_canvas_layer() + .by_editor_canvas_layer_canvas_id() + .filter(&canvas.canvas_id) + .collect::>(); + layer_rows.sort_by(|left, right| left.layer_key.cmp(&right.layer_key)); + + let mut source_hasher = Sha256::new(); + for value in [ + canvas.canvas_id.as_str(), + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + canvas.layers_json.as_str(), + project + .as_ref() + .map(|row| row.layers_json.as_str()) + .unwrap_or_default(), + ] { + source_hasher.update(value.as_bytes()); + source_hasher.update([0]); + } + for row in &layer_rows { + for value in [ + row.layer_key.as_str(), + row.item_json.as_str(), + row.asset_kind_override.as_deref().unwrap_or_default(), + ] { + source_hasher.update(value.as_bytes()); + source_hasher.update([0]); + } + } + let source_fingerprint = format!("{:x}", source_hasher.finalize()); + + let (canvas_layers_json, canvas_cleaned_count) = + match clean_legacy_image_asset_kind_from_layout(canvas.layers_json.as_str()) { + Ok(result) => result, + Err(message) => { + return EditorImageAssetKindCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project + .as_ref() + .map(|row| row.layers_json.clone()) + .unwrap_or_default(), + layer_patches: Vec::new(), + source_fingerprint, + cleaned_field_count: 0, + changed: false, + blocker: Some(message), + }; + } + }; + let (project_layers_json, project_cleaned_count) = match project.as_ref() { + Some(project) => { + match clean_legacy_image_asset_kind_from_layout(project.layers_json.as_str()) { + Ok(result) => result, + Err(message) => { + return EditorImageAssetKindCanvasPlan { + canvas_layers_json, + project_layers_json: project.layers_json.clone(), + layer_patches: Vec::new(), + source_fingerprint, + cleaned_field_count: canvas_cleaned_count, + changed: canvas_cleaned_count > 0, + blocker: Some(message), + }; + } + } + } + None => (String::new(), 0), + }; + + let mut layer_patches = Vec::new(); + let mut layer_cleaned_count = 0u32; + for row in &layer_rows { + let clear_override = + row.asset_kind_override.as_deref() == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND); + let (item_json, item_cleaned_count) = + match clean_legacy_image_asset_kind_from_layer_item_json(row.item_json.as_str()) { + Ok(result) => result, + Err(message) => { + return EditorImageAssetKindCanvasPlan { + canvas_layers_json, + project_layers_json, + layer_patches, + source_fingerprint, + cleaned_field_count: canvas_cleaned_count + .saturating_add(project_cleaned_count) + .saturating_add(layer_cleaned_count), + changed: true, + blocker: Some(message), + }; + } + }; + let cleaned_field_count = item_cleaned_count.saturating_add(u32::from(clear_override)); + if cleaned_field_count == 0 { + continue; + } + layer_cleaned_count = layer_cleaned_count.saturating_add(cleaned_field_count); + layer_patches.push(EditorImageAssetKindLayerPatch { + layer_key: row.layer_key.clone(), + item_json, + clear_override, + }); + } + + let cleaned_field_count = canvas_cleaned_count + .saturating_add(project_cleaned_count) + .saturating_add(layer_cleaned_count); + if cleaned_field_count == 0 { + return EditorImageAssetKindCanvasPlan { + canvas_layers_json, + project_layers_json, + layer_patches, + source_fingerprint, + cleaned_field_count, + changed: false, + blocker: None, + }; + } + let blocker = match project.as_ref() { + None => Some("命中普通图片旧值的画布缺少对应 editor_project".to_string()), + Some(project) if project.owner_user_id != canvas.owner_user_id => { + Some("命中普通图片旧值的 editor_canvas 与 editor_project owner 不一致".to_string()) + } + Some(_) => match ( + parse_legacy_layout_value(canvas_layers_json.as_str()), + parse_legacy_layout_value(project_layers_json.as_str()), + ) { + (Ok(canvas_value), Ok(project_value)) if canvas_value == project_value => None, + (Ok(_), Ok(_)) => { + Some("清理后 editor_canvas 与 editor_project 的布局仍不一致".to_string()) + } + (Err(message), _) | (_, Err(message)) => Some(message), + }, + }; + EditorImageAssetKindCanvasPlan { + canvas_layers_json, + project_layers_json, + layer_patches, + source_fingerprint, + cleaned_field_count, + changed: true, + blocker, + } +} + +fn apply_editor_image_asset_kind_canvas_plan( + ctx: &ReducerContext, + mut canvas: EditorCanvas, + plan: EditorImageAssetKindCanvasPlan, +) -> Result<(), String> { + let mut project = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id) + .ok_or_else(|| "图片画布对应的工程不存在".to_string())?; + if canvas.layers_json != plan.canvas_layers_json { + canvas.layers_json = plan.canvas_layers_json; + ctx.db.editor_canvas().canvas_id().update(canvas); + } + if project.layers_json != plan.project_layers_json { + project.layers_json = plan.project_layers_json; + ctx.db.editor_project().project_id().update(project); + } + for patch in plan.layer_patches { + let mut row = ctx + .db + .editor_canvas_layer() + .layer_key() + .find(&patch.layer_key) + .ok_or_else(|| "待清理的结构化画布图层不存在".to_string())?; + if patch.clear_override { + row.asset_kind_override = None; + } + row.item_json = patch.item_json; + ctx.db.editor_canvas_layer().layer_key().update(row); + } + Ok(()) +} + +fn clean_editor_image_asset_kind_canvases( + ctx: &ReducerContext, + input: &EditorImageAssetKindCleanupInput, +) -> Result { + let cursor = editor_image_asset_kind_cleanup_cursor(input); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_canvas() + .by_editor_canvas_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.canvas_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorImageAssetKindCleanupStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.canvas_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let plan = plan_editor_image_asset_kind_canvas(ctx, row); + stats.matched_count = stats.matched_count.saturating_add(u32::from(plan.changed)); + stats.cleaned_field_count = stats + .cleaned_field_count + .saturating_add(plan.cleaned_field_count); + if let Some(reason) = plan.blocker.as_deref() { + stats + .blocker_samples + .push(editor_image_asset_kind_cleanup_blocker_sample( + "canvas", + row.canvas_id.as_str(), + reason, + )); + } + update_editor_image_asset_kind_cleanup_hash( + &mut hasher, + "canvas", + row.canvas_id.as_str(), + plan.source_fingerprint.as_str(), + format!("{plan:?}").as_str(), + ); + plans.push(plan); + } + finalize_editor_image_asset_kind_cleanup_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_samples.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + debug_assert!(plan.blocker.is_none()); + apply_editor_image_asset_kind_canvas_plan(ctx, row, plan)?; + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + fn normalize_editor_character_animation_assets( ctx: &ReducerContext, input: &EditorCharacterAnimationNormalizationInput, @@ -18921,4 +19643,68 @@ mod tests { assert!(!resolver.contains(".iter()")); assert!(!resolver.contains(".filter(")); } + + #[test] + fn ordinary_image_asset_kind_cleanup_only_removes_layer_semantic_fields() { + let source = json!([ + { + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image", + "mediaType": "image", + "generationInputs": { + "references": [{"mediaType": "image"}] + } + }, + { + "layerId": "layer-2", + "resourceId": "resource-2", + "assetKind": "scene" + } + ]) + .to_string(); + + let (cleaned, cleaned_field_count) = + clean_legacy_image_asset_kind_from_layout(source.as_str()).unwrap(); + let value = serde_json::from_str::(&cleaned).unwrap(); + + assert_eq!(cleaned_field_count, 1); + assert!(value[0].get("assetKind").is_none()); + assert_eq!(value[0]["mediaType"], json!("image")); + assert_eq!( + value[0]["generationInputs"]["references"][0]["mediaType"], + json!("image") + ); + assert_eq!(value[1]["assetKind"], json!("scene")); + } + + #[test] + fn ordinary_image_asset_kind_cleanup_fails_closed_for_unidentified_layout_item() { + let source = json!([{"resourceId": "resource-1", "assetKind": "image"}]).to_string(); + assert!( + clean_legacy_image_asset_kind_from_layout(source.as_str()) + .unwrap_err() + .contains("layerId") + ); + + let item_json = json!({"assetKindOverride": "image", "x": 12}).to_string(); + let (cleaned, cleaned_field_count) = + clean_legacy_image_asset_kind_from_layer_item_json(item_json.as_str()).unwrap(); + assert_eq!(cleaned_field_count, 1); + assert!( + serde_json::from_str::(&cleaned).unwrap()["assetKindOverride"].is_null() + ); + } + + #[test] + fn ordinary_image_asset_kind_cleanup_blocker_samples_hash_identifiers() { + let sample = editor_image_asset_kind_cleanup_blocker_sample( + "canvas", + "private-canvas-id", + "布局不一致", + ); + assert!(!sample.contains("private-canvas-id")); + assert!(sample.contains("idSha256")); + assert!(sample.contains("布局不一致")); + } }