Compare commits

..

2 Commits

Author SHA1 Message Date
kdletters a8b44f5100 修复画布历史资源空来源修复
允许资源 remap 精确匹配显式 null 的 sourceResourceId
修正 SpacetimeDB CLI procedure 的 SHA 字段与 Option wire 编码
补充 Rust 与 Node 回归测试并重新生成 Rust bindings
更新画布迁移方案和共享排障约定
2026-07-27 22:28:00 +08:00
kdletters 47f00dae4d 修复维护页品牌图片加载
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m2s
Project CI / Native shell tests (push) Successful in 2m28s
Project CI / Backend tests (push) Successful in 3m3s
Nginx 精确放行维护页背景图与品牌图标
Pingora 同步维护资产白名单
补充维护模式网关回归测试
更新生产运维与共享决策文档
2026-07-27 21:57:30 +08:00
13 changed files with 273 additions and 28 deletions
@@ -9,6 +9,17 @@ if ($genarrative_internal_client) {
set $genarrative_maintenance 0;
}
# 维护页自身依赖的品牌图片必须在维护期间保持可读;只放行精确文件,避免扩大公网静态面。
location = /branding/taonier-maintenance-page.png {
root /srv/genarrative/web;
try_files /branding/taonier-maintenance-page.png =404;
}
location = /branding/taonier-product-ip.png {
root /srv/genarrative/web;
try_files /branding/taonier-product-ip.png =404;
}
location = /maintenance.html {
root /var/lib/genarrative/maintenance;
try_files /page.html @genarrative_default_maintenance;
@@ -4307,7 +4307,7 @@
## 2026-07-13 临时维护公告改为 release 外运行态覆盖
- 背景:一次性停服公告曾直接提交到 `public/maintenance.html`,后续 Web Build 将它持续打入 `web.tar.gz`,每次 Web Deploy 或再次进入维护都会重新显示已经过期的公告。
- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。
- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。维护期间只精确放行 `/branding/taonier-maintenance-page.png``/branding/taonier-product-ip.png`,不得扩大到整个品牌或静态资源目录;网关 smoke 必须验证这两个路径仍返回 PNG,同时其它公网页面、API 与后台静态资源继续命中维护门禁。
- 生命周期:新维护窗口未提供 `--page-file` 时清理 marker 外残留公告;同一窗口内 Stdb / API 发布重复调用 `maintenance-on.sh` 时保留已安装公告;`maintenance-off.sh` 同时清理 marker 和公告页。Web Deploy 不再拥有临时公告事实源。
- 影响范围:默认维护页、维护开关脚本、Nginx snippet、Pingora 配置与 smoke、生产 Web 发布包门禁和生产运维文档。
- 验证方式:`npm run check:maintenance-page``npm run check:nginx-spa-routes``cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml``npm run check:pingora-gateway-smoke``npm run check:production-ops``npm run check:encoding``git diff --check`
@@ -50,7 +50,7 @@
- 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。
- 原因:同步接口 worker 化时把原请求整体序列化到 `external_generation_job.request_payload_json`,而前端又把已有 `objectKey` 下载成 Data URL 提交。任务表也是正式持久化边界;列表 procedure 若先收集完整任务行再截断,还会把 request/result 大字段在 SpacetimeDB、SDK mapper 和 BFF 多次持有。
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / runningdry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id``owner_user_id``completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / runningdry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id``owner_user_id``completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。运维 plan 可以保留人工契约字段,但组装 CLI procedure input 时必须按生成 schema 的 wire 名称映射;例如 Rust 字段 `expected_canvas_layout_sha256` 的 CLI wire 名为 `expected_canvas_layout_sha_256`,不能直接透传 plan key。
- 发布门禁:生产发布入口必须固定 `--delete-data=never` 与 scoped `--yes=migrate,break-clients`,普通 Jenkins 参数不得暴露清库开关;需要删数据的 schema 冲突必须直接阻断并重新检查 artifact/schema,不能靠裸 `--yes` 放行。
- 验证:构造嵌套 Data URL、Blob URL 和超限 JSON 确认入队失败;检查正式 UI procedure / client record 不含 request/result payload;用 dry-run 和 apply 测试确认活动任务不变、终态普通提示词保留且内联媒体被替换;至少带一次非空 `--cursor-job-id``--completed-before-micros` 验证 CLI Option 编码,而不是只测首批空 cursor。
- 关联:`server-rs/crates/api-server/src/editor_generation_queue.rs``server-rs/crates/spacetime-module/src/external_generation.rs``server-rs/crates/api-server/src/external_generation.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -92,6 +92,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
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,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
维护门禁启用时,Nginx 与 Pingora 只精确放行默认维护页依赖的 `/branding/taonier-maintenance-page.png``/branding/taonier-product-ip.png`;不得放开整个 `/branding/``/assets/` 或后台静态目录。公网验收除主站、后台和 API 继续返回维护响应外,还必须确认这两个品牌图片返回 `200 image/png`,避免维护页 HTML 正常但背景图请求被再次改写成 `503`
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `source_module = editor-canvas``job_kind = worker_smoke_unsupported` 的测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,用队列概览 BFF、单 job 状态接口和 worker 日志确认任务从 queued/running 收敛到预期失败态。
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev``npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun``POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
@@ -73,7 +73,7 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
若全量 backfill 审计发现普通图层缺少 `editor_project_resource`,先使用定向资源修复入口:`npm run spacetime:editor-canvas-resources:repair -- --database <db> --server <server> --plan-file </absolute/outside-repo/repair-plan.json>`。plan 必须是仓库外、当前用户持有、权限严格为 `0600` 的普通文件;每个 canvas 绑定 owner / project、expected revision、`editor_canvas.layers_json` 与 legacy `editor_project.layers_json` 两份原始字节 SHA-256,以及精确 layer/resource/sourceResourceId 或 asset_object 修复动作。默认逐 canvas dry-runapply 必须追加 `--apply --confirm-plan-sha256 <dry-run 输出>`,成功后脚本自动以同一 plan 再 dry-run,并要求全部返回 `already_repaired`
`repair_editor_canvas_resources_and_return` 只允许 migration operator 调用,并且只修尚无迁移记录的 legacy canvas。图片动作仅在缺失旧 resourceId、精确旧 sourceResourceId、同工程唯一现存资源、layout 资源元数据与 private asset_object owner/objectKey/task 谱系全部一致时,替换 `resourceId` 并删除重复 `sourceResourceId`。音频动作仅在原 resourceId 全局不存在、图层与 private asset_object 的 owner/objectKey/source job/entity/content type/长度全部匹配时,恢复 `420x120``sound-effect` / `background-music` 项目资源行;资源 `asset_kind` 分别映射 asset_object 的 `editor_sound_effect` / `editor_background_music`。apply 在一个事务内插入资源、同步两份 legacy layout、递增一次 canvas revision;任一 guard 失败整画布回滚。plan、脚本输出、测试和文档均不得包含生产真实 ID。
`repair_editor_canvas_resources_and_return` 只允许 migration operator 调用,并且只修尚无迁移记录的 legacy canvas。图片动作仅在缺失旧 resourceId、精确旧 sourceResourceId、同工程唯一现存资源、layout 资源元数据与 private asset_object owner/objectKey/task 谱系全部一致时,替换 `resourceId` 并删除重复 `sourceResourceId`plan 中 `expected_layout_source_resource_id` 为字符串时必须与 layout 原值完全一致,显式为 `null` 时则要求 layout 中该字段明确存在且值为 JSON `null`,不得把字段缺失与 `null` 混同。音频动作仅在原 resourceId 全局不存在、图层与 private asset_object 的 owner/objectKey/source job/entity/content type/长度全部匹配时,恢复 `420x120``sound-effect` / `background-music` 项目资源行;资源 `asset_kind` 分别映射 asset_object 的 `editor_sound_effect` / `editor_background_music`。apply 在一个事务内插入资源、同步两份 legacy layout、递增一次 canvas revision;任一 guard 失败整画布回滚。plan、脚本输出、测试和文档均不得包含生产真实 ID。
## 5. 兼容与回滚
+5
View File
@@ -148,6 +148,10 @@ function validateGatewayConfiguration() {
'try_files /page.html @genarrative_default_maintenance;',
'location @genarrative_default_maintenance',
'root /srv/genarrative/web;',
'location = /branding/taonier-maintenance-page.png',
'try_files /branding/taonier-maintenance-page.png =404;',
'location = /branding/taonier-product-ip.png',
'try_files /branding/taonier-product-ip.png =404;',
]) {
if (!nginxSnippet.includes(expected)) {
fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`);
@@ -161,6 +165,7 @@ function validateGatewayConfiguration() {
for (const expected of [
'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE',
'maintenance_page_file',
'is_maintenance_page_asset(path)',
]) {
if (!pingoraSource.includes(expected)) {
fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`);
+4
View File
@@ -164,6 +164,10 @@ function validateMaintenanceInternalBypass() {
fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`);
}
for (const fragment of [
'location = /branding/taonier-maintenance-page.png {',
'try_files /branding/taonier-maintenance-page.png =404;',
'location = /branding/taonier-product-ip.png {',
'try_files /branding/taonier-product-ip.png =404;',
'location = /404.html {',
'if ($http_accept !~* "text/html") {',
'try_files /404.html =404;',
+23 -1
View File
@@ -347,6 +347,19 @@ async function prepareStaticRoots(webRoot, acmeRoot) {
path.join(webRoot, 'maintenance.html'),
'<main>default-maintenance</main>',
);
await mkdir(path.join(webRoot, 'branding'), { recursive: true });
for (const fileName of [
'taonier-maintenance-page.png',
'taonier-product-ip.png',
]) {
await writeFile(
path.join(webRoot, 'branding', fileName),
Buffer.concat([
PNG_MAGIC_BYTES,
Buffer.from([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]),
]),
);
}
await writeFile(path.join(webRoot, '404.html'), '<main>not-found-page</main>');
await writeFile(
path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'),
@@ -1204,6 +1217,14 @@ async function runSmokeCases(
},
);
}
for (const path of [
'/branding/taonier-maintenance-page.png',
'/branding/taonier-product-ip.png',
]) {
await expectStaticPng(baseUrl, path, `维护模式放行品牌图片 ${path}`, {
headers: publicClientHeaders,
});
}
await rm(maintenancePageFile, { force: true });
await expectHttp(
baseUrl,
@@ -1433,9 +1454,10 @@ async function expectNotCompressedResponse(baseUrl, route, bodyNeedle, label) {
}
}
async function expectStaticPng(baseUrl, route, label) {
async function expectStaticPng(baseUrl, route, label, options = {}) {
console.log(`[pingora-gateway-smoke] ${label}`);
const response = await requestHttp(`${baseUrl}${route}`, {
headers: options.headers,
rawBody: true,
});
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import {
callSpacetimeProcedureViaCli,
encodeSpacetimeCliOption,
} from './spacetime-migration-common.mjs';
const PROCEDURE_NAME = 'repair_editor_canvas_resources_and_return';
@@ -304,11 +305,6 @@ function validateRepairAction(action, label) {
`${label}.replacement_resource_id 为复用资源动作的必填项。`,
);
}
if (!normalized.expected_layout_source_resource_id) {
throw new Error(
`${label}.expected_layout_source_resource_id 为复用资源动作的必填项。`,
);
}
if (
normalized.replacement_resource_id === normalized.expected_resource_id
) {
@@ -327,7 +323,7 @@ function validateRepairAction(action, label) {
`${label} 的补资源动作不能携带 replacement_resource_id。`,
);
}
if (normalized.expected_layout_source_resource_id) {
if (normalized.expected_layout_source_resource_id !== null) {
throw new Error(
`${label} 的补资源动作不能携带 expected_layout_source_resource_id。`,
);
@@ -415,16 +411,17 @@ export function buildProcedureInput(canvas, updatedAtMicros, dryRun) {
project_id: canvas.project_id,
owner_user_id: canvas.owner_user_id,
expected_revision: canvas.expected_revision,
expected_canvas_layout_sha256: canvas.expected_canvas_layout_sha256,
expected_project_layout_sha256: canvas.expected_project_layout_sha256,
expected_canvas_layout_sha_256: canvas.expected_canvas_layout_sha256,
expected_project_layout_sha_256: canvas.expected_project_layout_sha256,
remaps: canvas.actions
.filter((action) => action.action_kind === 'reuse_project_resource')
.map((action) => ({
layer_id: action.layer_id,
expected_missing_resource_id: action.expected_resource_id,
replacement_resource_id: action.replacement_resource_id,
expected_layout_source_resource_id:
expected_layout_source_resource_id: encodeSpacetimeCliOption(
action.expected_layout_source_resource_id,
),
})),
restores: canvas.actions
.filter((action) => action.action_kind === 'restore_project_resource')
@@ -166,6 +166,39 @@ describe('spacetime editor canvas resource repair plan', () => {
]);
});
it('accepts an explicit null layout source only for reuse actions', () => {
const plan = validateRepairPlan(
planValue([
canvas({
actions: [
action({ expected_layout_source_resource_id: null }),
],
}),
]),
);
expect(
plan.canvases[0].actions[0].expected_layout_source_resource_id,
).toBeNull();
expect(() =>
validateRepairPlan(
planValue([
canvas({
actions: [
action({
action_kind: 'restore_project_resource',
replacement_resource_id: null,
expected_layout_source_resource_id: 'unexpected-source',
asset_object_id: 'private-asset-object-id',
asset_kind: 'sound-effect',
}),
],
}),
]),
),
).toThrow('不能携带 expected_layout_source_resource_id');
});
it('rejects count mismatches, duplicate layers, and invalid kind-specific fields', () => {
expect(() =>
validateRepairPlan({ ...planValue(), expected_action_count: 2 }),
@@ -263,7 +296,10 @@ describe('spacetime editor canvas resource repair execution', () => {
layer_id: 'private-layer-id',
expected_missing_resource_id: 'missing-private-resource-id',
replacement_resource_id: 'replacement-private-resource-id',
expected_layout_source_resource_id: 'source-private-resource-id',
expected_layout_source_resource_id: [
0,
'source-private-resource-id',
],
},
]);
expect(input.restores).toEqual([
@@ -275,10 +311,37 @@ describe('spacetime editor canvas resource repair execution', () => {
},
]);
expect(input.canvas_id).toBe('private-canvas-id');
expect(input.expected_canvas_layout_sha_256).toBe(SHA_A);
expect(input.expected_project_layout_sha_256).toBe(SHA_B);
expect(input).not.toHaveProperty('expected_canvas_layout_sha256');
expect(input).not.toHaveProperty('expected_project_layout_sha256');
expect(input.updated_at_micros).toBe(123);
expect(input.dry_run).toBe(true);
});
it('encodes an explicit null expected layout source as a CLI Option null', () => {
const plan = validateRepairPlan(
planValue([
canvas({
actions: [
action({ expected_layout_source_resource_id: null }),
],
}),
]),
);
const input = buildProcedureInput(plan.canvases[0], 123, true);
expect(input.remaps).toEqual([
{
layer_id: 'private-layer-id',
expected_missing_resource_id: 'missing-private-resource-id',
replacement_resource_id: 'replacement-private-resource-id',
expected_layout_source_resource_id: null,
},
]);
});
it('calls one procedure per canvas and emits only anonymous aggregate output', async () => {
const plan = validateRepairPlan(
planValue([
@@ -1117,6 +1117,7 @@ impl ProxyHttp for GenarrativeGateway {
let internal_bypass =
allows_internal_maintenance_bypass(request_source_ip(session).as_ref());
let should_apply_maintenance = !internal_bypass
&& !is_maintenance_page_asset(path)
&& (is_admin_request_path(path) || ctx.route.applies_maintenance_gate());
if should_apply_maintenance {
respond_maintenance(
@@ -1887,6 +1888,13 @@ fn is_main_spa_path(path: &str) -> bool {
.any(|candidate| normalized.eq_ignore_ascii_case(candidate))
}
fn is_maintenance_page_asset(path: &str) -> bool {
matches!(
path,
"/branding/taonier-maintenance-page.png" | "/branding/taonier-product-ip.png"
)
}
fn classify_http_redirect_path(path: &str) -> RouteDecision {
if path.starts_with("/.well-known/acme-challenge/") {
return RouteDecision::Local(LocalResponse::Static {
@@ -3308,6 +3316,23 @@ mod tests {
assert!(!allows_internal_maintenance_bypass(None));
}
#[test]
fn maintenance_only_allows_required_branding_assets() {
for path in [
"/branding/taonier-maintenance-page.png",
"/branding/taonier-product-ip.png",
] {
assert!(is_maintenance_page_asset(path));
}
for path in [
"/branding/other.png",
"/branding/taonier-maintenance-page.png/extra",
"/assets/app.js",
] {
assert!(!is_maintenance_page_asset(path));
}
}
#[test]
fn normalizes_gateway_hosts_for_matching() {
assert_eq!(
@@ -10,7 +10,7 @@ pub struct EditorCanvasResourceRemapInput {
pub layer_id: String,
pub expected_missing_resource_id: String,
pub replacement_resource_id: String,
pub expected_layout_source_resource_id: String,
pub expected_layout_source_resource_id: Option<String>,
}
impl __sdk::InModule for EditorCanvasResourceRemapInput {
@@ -514,7 +514,7 @@ pub struct EditorCanvasResourceRemapInput {
pub layer_id: String,
pub expected_missing_resource_id: String,
pub replacement_resource_id: String,
pub expected_layout_source_resource_id: String,
pub expected_layout_source_resource_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -6669,10 +6669,13 @@ fn validate_editor_canvas_resource_repair_actions(
&action.replacement_resource_id,
"remap.replacement_resource_id",
)?;
normalize_required(
&action.expected_layout_source_resource_id,
"remap.expected_layout_source_resource_id",
)?;
if let Some(expected_layout_source_resource_id) = &action.expected_layout_source_resource_id
{
normalize_required(
expected_layout_source_resource_id,
"remap.expected_layout_source_resource_id",
)?;
}
if missing == replacement {
return Err("资源 remap 的缺失 ID 与替换 ID 不能相同".to_string());
}
@@ -6761,10 +6764,21 @@ fn patch_editor_canvas_resource_layout(
if current_resource_id != action.expected_missing_resource_id {
return Err("图片图层的原 resourceId 与修复计划不一致".to_string());
}
let source_resource_id =
json_required_string(object.get("sourceResourceId"), "sourceResourceId")?;
if source_resource_id != action.expected_layout_source_resource_id {
return Err("图片图层的 sourceResourceId 与修复计划不一致".to_string());
match &action.expected_layout_source_resource_id {
Some(expected_source_resource_id) => {
let source_resource_id =
json_required_string(object.get("sourceResourceId"), "sourceResourceId")?;
if source_resource_id != expected_source_resource_id.as_str() {
return Err("图片图层的 sourceResourceId 与修复计划不一致".to_string());
}
}
None => {
if object.get("sourceResourceId") != Some(&JsonValue::Null) {
return Err(
"图片图层的 sourceResourceId 必须明确为 null 且与修复计划一致".to_string(),
);
}
}
}
object.insert(
"resourceId".to_string(),
@@ -8040,7 +8054,7 @@ mod tests {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: "resource-source-old".to_string(),
expected_layout_source_resource_id: Some("resource-source-old".to_string()),
}];
let patch = patch_editor_canvas_resource_layout(
@@ -8074,7 +8088,7 @@ mod tests {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: "expected-source".to_string(),
expected_layout_source_resource_id: Some("expected-source".to_string()),
}];
let error = patch_editor_canvas_resource_layout(
@@ -8088,6 +8102,93 @@ mod tests {
assert!(error.contains("sourceResourceId"));
}
#[test]
fn editor_canvas_resource_repair_accepts_explicit_null_source_resource_id() {
let layout = json!([{
"layerId": "layer-target",
"resourceId": "resource-missing",
"sourceResourceId": null,
"prompt": "保留提示词"
}]);
let remaps = vec![EditorCanvasResourceRemapInput {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: None,
}];
let patch = patch_editor_canvas_resource_layout(
layout.to_string().as_str(),
remaps.as_slice(),
&[],
false,
)
.expect("explicit null source resource should match null repair plan");
let repaired: JsonValue =
serde_json::from_str(&patch.layers_json).expect("repaired layout json");
assert_eq!(repaired[0]["resourceId"], json!("resource-existing"));
assert!(repaired[0].get("sourceResourceId").is_none());
assert_eq!(repaired[0]["prompt"], json!("保留提示词"));
assert_eq!(patch.remapped_layer_count, 1);
assert_eq!(patch.removed_source_resource_id_count, 1);
}
#[test]
fn editor_canvas_resource_repair_null_plan_rejects_missing_or_string_source_resource_id() {
let remaps = vec![EditorCanvasResourceRemapInput {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: None,
}];
let missing_field = json!([{
"layerId": "layer-target",
"resourceId": "resource-missing"
}]);
let string_field = json!([{
"layerId": "layer-target",
"resourceId": "resource-missing",
"sourceResourceId": "source-old"
}]);
for layout in [missing_field, string_field] {
let error = patch_editor_canvas_resource_layout(
layout.to_string().as_str(),
remaps.as_slice(),
&[],
false,
)
.expect_err("null plan must only match an explicitly null sourceResourceId");
assert!(error.contains("明确为 null"));
}
}
#[test]
fn editor_canvas_resource_repair_string_plan_rejects_null_source_resource_id() {
let layout = json!([{
"layerId": "layer-target",
"resourceId": "resource-missing",
"sourceResourceId": null
}]);
let remaps = vec![EditorCanvasResourceRemapInput {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: Some("source-old".to_string()),
}];
let error = patch_editor_canvas_resource_layout(
layout.to_string().as_str(),
remaps.as_slice(),
&[],
false,
)
.expect_err("string plan must reject a null sourceResourceId");
assert!(error.contains("sourceResourceId"));
}
#[test]
fn editor_canvas_resource_repair_recognizes_only_exact_applied_state() {
let repaired = json!([{
@@ -8099,7 +8200,7 @@ mod tests {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: "resource-source-old".to_string(),
expected_layout_source_resource_id: Some("resource-source-old".to_string()),
}];
let patch = patch_editor_canvas_resource_layout(
@@ -8126,6 +8227,21 @@ mod tests {
)
.is_err()
);
let unsafe_null_state = json!([{
"layerId": "layer-target",
"resourceId": "resource-existing",
"sourceResourceId": null
}]);
assert!(
patch_editor_canvas_resource_layout(
unsafe_null_state.to_string().as_str(),
remaps.as_slice(),
&[],
true,
)
.is_err()
);
}
#[test]
@@ -8169,7 +8285,7 @@ mod tests {
layer_id: "layer-target".to_string(),
expected_missing_resource_id: "resource-missing".to_string(),
replacement_resource_id: "resource-existing".to_string(),
expected_layout_source_resource_id: "source-old".to_string(),
expected_layout_source_resource_id: Some("source-old".to_string()),
}];
let error = patch_editor_canvas_resource_layout(