Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8b44f5100 | |||
| 47f00dae4d |
@@ -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 / running;dry-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 / running;dry-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-run;apply 必须追加 `--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. 兼容与回滚
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;',
|
||||
|
||||
@@ -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!(
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user