From 9ae3f539899aa3f79b90a2c5bb927a52ee07b816 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 16 Jul 2026 18:00:06 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=AE=8C=E5=96=84SpacetimeDB=E9=80=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A2=9E=E9=87=8F=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增data-dir逐文件CAS基线、history归档与安全清理 发布并校验OSS latest pointer,支持异机自动恢复 接入dev和release可选的files-history定时备份profile 补齐备份测试、生产门禁、环境示例与运维文档 --- deploy/env/api-server.env.example | 6 + ...rrative-database-backup-files-history.conf | 3 + .../shared-memory/decision-log.md | 13 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 56 +- .../Jenkinsfile.production-server-provision | 10 + scripts/check-database-backup-to-oss.mjs | 895 +++++++- scripts/check-production-ops-guardrails.mjs | 71 + scripts/database-backup-to-oss.mjs | 1992 ++++++++++++++++- scripts/jenkins-server-provision.sh | 71 +- 10 files changed, 3052 insertions(+), 73 deletions(-) create mode 100644 deploy/systemd/genarrative-database-backup-files-history.conf diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 617553225..0b0b38798 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -157,8 +157,14 @@ GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET= GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false +# 可选:files 为逐文件 CAS + catalog,不生成 tar.gz;archive 保留旧全量压缩包兼容行为。 +GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT=archive # 可选:显式要求备份工作目录所在文件系统至少保留的可用空间;为空时按数据目录大小 + 安全余量估算。 GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES= +# archive history 模式持久化已验真 full baseline 与追加批次;files 模式改用 work-dir 下的 files state。 +GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE=/var/lib/genarrative/database-backups/genarrative-prod-history-state.json +# 仅 archive history 首次从 uploadStatus=uploaded 的 full manifest 初始化 state 时设置;初始化后可留空。 +GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST= # 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。 GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID= GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= diff --git a/deploy/systemd/genarrative-database-backup-files-history.conf b/deploy/systemd/genarrative-database-backup-files-history.conf new file mode 100644 index 000000000..512898b80 --- /dev/null +++ b/deploy/systemd/genarrative-database-backup-files-history.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --storage-format files --mode history --work-dir /var/lib/genarrative/database-backups/files-history diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 826c4db62..df7cfa4bb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,19 @@ --- +## 2026-07-16 SpacetimeDB 备份采用逐文件基线、CAS 增量与安全历史清理 + +- 背景:SpacetimeDB standalone 2.6.0 不自动删除已被 snapshot 覆盖的历史 commitlog 与旧 snapshot;反复压缩整个 `/stdb` 会重复占用磁盘、停机和 OSS 带宽。上游 issue #5542 的 contributor 明确说明,不触碰最新 snapshot 与重启所需 commitlog suffix 时,可在运行中移动或删除这些历史文件。 +- 决策:统一脚本新增 `--storage-format files`,完整基线递归保留目录与文件相对路径,普通文件按 SHA-256 上传为不可变 CAS 对象,catalog 记录目录、路径、长度、SHA 与对象 key;相同内容不重复 PUT,后续 full 扫描只上传新增或变化内容,不再生成 tar.gz。旧 `archive` 路径保留兼容。full 必须从停库目录或已验证的冻结副本生成,不能把在线跨文件扫描称为一致时点备份。 +- history 继续按 replica 计算安全边界:只接受完整、未锁定且含同 offset `.snapshot_bsatn` 的 snapshot,保留跨越最新 snapshot 的边界 segment 及全部后缀。旧 segment 对和旧 snapshot 被递归映射为单文件 CAS 对象;对象、history catalog、full baseline catalog、候选 fingerprint 与当前边界全部验真后才删除源文件。同库执行用 work-dir PID lock 互斥。 +- OSS 固定恢复入口为 `//latest.json`。CAS 文件和 full/history catalog 保持不可变;latest pointer 只保存最新 full catalog 与已发布 history catalog 的 object key、长度和 SHA,不包含主机绝对路径或文件内容。每次 state 变化先验真全部引用 catalog,再覆盖上传并 HEAD 验真 latest pointer,成功后才落本地 state;history 还必须在 pointer 成功后才允许删除源文件。全新机器可仅凭 bucket、database、prefix 与 OSS 凭据自动下载 pointer 和 full catalog。 +- dev 带宽不足时,允许把已冻结的 dev 基线经 `10.2.0.10 -> 10.2.4.16` 内网 rsync 到 release 独立 staging,再用 release 出口上传 dev bucket;staging 不得指向 release `/stdb`,不得停止或修改 release 服务,传输凭据必须临时创建并在演练后移除。catalog 不记录 staging 绝对路径,files state 可回传 dev 继续 history。 +- 恢复边界:恢复时默认从 OSS `latest.json` 自动定位 full catalog,创建目录并按相对路径下载每个对象、逐文件校验长度与 SHA;本地 state 只用于备份续跑,不再是异机恢复前置条件。远程 dev 已完成真实 OSS、清理、重启和异机隔离恢复演练;release timer 与 publish 前备份继续保持原行为。 +- systemd 接线:主 service 保持 `archive-full`。Server-Provision 新增默认值为 `archive-full` 的 `DATABASE_BACKUP_PROFILE`;dev 或 release 显式选择 `files-history` 时,必须为各自主机指定独立 work-dir,并先用 current release 脚本执行 history dry-run,确认已有 full state 后才安装仓库托管 drop-in,并删除现场手写旧 drop-in。切回默认 profile 必须删除所有 history 覆盖。 +- 影响范围:`scripts/database-backup-to-oss.mjs`、备份门禁、生产 env 示例、systemd 模板、Server-Provision、SpacetimeDB 运维与恢复流程;release timer 可在独立 baseline 验证后显式选择 profile,publish 前备份是否切换仍需单独决策。 +- 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;dev 现场必须完成逐文件 full catalog、重复 full 零 PUT、history dry-run、上传后清理、STDB 重启和按 catalog 隔离恢复 roundtrip。 +- 关联:。 + ## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权 - 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5d2a3d099..0f7605295 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3117,3 +3117,11 @@ - 原因:`.admin-info-list div` 会命中列表内所有后代 `div`,把字段值内部的 `.admin-inline-identity` 和昵称容器也覆盖成双列 grid;陶泥号又允许任意位置换行,最终只剩单字符宽度。素材详情布局若始终固定为 `220px + 信息列`,移动端也没有足够空间。 - 处理:信息列表的行布局只使用直接子选择器 `.admin-info-list > div`;作者昵称与陶泥号在身份组件内分行,陶泥号保持单行并在真正不足时省略。`560px` 以下的素材详情改为单列,缩略图居中;素材查询与精选审核共用该规则。 - 验证:在桌面、560px、390px 和 320px 浏览器宽度打开素材详情,确认 `.admin-inline-identity` 的 computed `display` 为 `flex`、陶泥号横向显示、详情字段不溢出页面。 + +## SpacetimeDB 历史归档不能按文件名小于 snapshot 就全部删除 + +- 现象:看到最新 `N.snapshot_dir` 后,把所有起始 offset 小于 `N` 的 `.stdb.log` 删除,或者只把旧日志上传 OSS 就宣称已有完整增量灾备。 +- 原因:segment 文件名只表示该段最早事务;起始 offset 小于等于最新 snapshot 的最后一个 segment 可能跨越 snapshot 边界,重启仍需要它。历史归档也不会及时覆盖 control-db、program bytes、最新 snapshot 和 active segment。 +- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。 +- 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。 +- 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0699549eb..e97d3ae0a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -303,7 +303,7 @@ UI 相关修改要重点验证: ### SpacetimeDB 数据目录 OSS 备份 -数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`);生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份: +数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`)。默认 `--storage-format archive --mode full` 保持原有全量压缩包冷备行为;`--storage-format files` 不生成 tar.gz,而是把目录树映射成逐文件 CAS 对象与 catalog,full 重跑只上传新增或内容变化的文件,history 只处理已被最新 snapshot 完全覆盖的历史 commitlog 与旧 snapshot。`Genarrative-Server-Provision` 的 `DATABASE_BACKUP_PROFILE` 默认是 `archive-full`,继续安装每天 `03:20` 左右执行的全量冷备主 service;development 和 release 都可以显式选择 `files-history`,但指定 work-dir 必须已经有与本机 database/bucket 匹配且已发布的 full baseline state: ```bash npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service @@ -326,13 +326,67 @@ GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET= GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false +GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT=archive GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES= +GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE=/var/lib/genarrative/database-backups/genarrative-prod-history-state.json +# 仅 archive history 首次从一份 uploadStatus=uploaded 的全量 manifest 初始化 state 时设置或传 --baseline-manifest。 +GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST= GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID= GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= ``` `GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。冷备脚本会在停止 SpacetimeDB 前检查 `GENARRATIVE_DATABASE_BACKUP_WORK_DIR` 所在文件系统剩余空间;未设置 `GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES` 时,按数据目录大小加安全余量估算,空间不足会在停库前失败,避免写满根分区。即使打包或上传前步骤失败,只要脚本已经停过 SpacetimeDB,也会先恢复 SpacetimeDB 并执行 `--restart-service-after` 指定的 API / worker / controller,再带着原始备份错误退出。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。如果 timer 显示 `enabled` 但 `inactive/dead` 且 `NEXT` / `Trigger` 为空,先写入当前 stamp 避免 `Persistent=true` 在白天立刻补跑冷备份:`touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer && systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,随后确认下一次触发时间约为次日 `03:20`。 +`files-history` 使用仓库模板 `deploy/systemd/genarrative-database-backup-files-history.conf` 覆盖主 service 的 `ExecStart`,从 `/etc/genarrative/api-server.env` 读取 data-dir、database、bucket、prefix 与 OSS 凭据,不在 unit 写死环境目标,也不传 `--stop-service`。Server-Provision 在改动 drop-in 前,先用 current release 的同一脚本、同一 env 和 `DATABASE_BACKUP_FILES_HISTORY_WORK_DIR` 执行一次 history `--dry-run`;缺少已发布 full catalog 的 files state、current 脚本过旧或配置不匹配都会在安装 drop-in 和 `daemon-reload` 前失败。选择 `archive-full` 会主动删除仓库托管的 `10-files-history.conf` 与 dev 试点遗留的 `10-dev-files.conf`,防止 systemd 继续合并旧覆盖。dev 可继续指定已有 `/var/lib/genarrative/database-backups/dev-files`,release 建议先在 `/var/lib/genarrative/database-backups/release-files` 建立自己的 full baseline;两台机器不得复用或互传本地 state 目录冒充本机基线。启用时通过 Server-Provision Job 选择目标、`DATABASE_BACKUP_PROFILE=files-history` 和对应 work-dir,先保持 `DRY_RUN=true` 核对,再以同参数正式 provision。不要直接在 `/etc/systemd/system` 手写第二份 drop-in。 + +files full 会递归扫描 data-dir,保留空目录和每个普通文件的相对路径;文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA 和对象 key,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证 2083 个文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 + +history 的安全边界按每个 replica 独立计算。设最新完整且未锁定的 snapshot offset 为 `S`;数字更大但缺少同 offset `.snapshot_bsatn`、仍存在同名 `.lock` 的目录不能参与边界计算。脚本必须保留起始 offset 小于等于 `S` 的最后一个 commitlog segment,以及它之后的全部 segment;只处理更早的 `.stdb.log` / `.stdb.ofs`,snapshot 只处理最新目录之前的旧目录。files history 会递归展开候选目录,逐对象复用或上传,随后依次验真候选对象、history catalog 与 full baseline catalog,再重新扫描边界和 stat fingerprint,最后覆盖发布并验真 `latest.json`;任何一步失败都不推进 state 或删除源文件。脚本在 work-dir 使用 PID lock 拒绝同库并发上传,SSH 超时后必须先检查原进程,不能直接重跑。 + +```bash +# 从停库目录或已验证冻结副本建立逐文件完整基线;相同 work-dir 重跑只传变化内容。 +node -- scripts/database-backup-to-oss.mjs \ + --storage-format files \ + --mode full \ + --data-dir /path/to/frozen/stdb \ + --work-dir /var/lib/genarrative/database-backups/dev-files \ + --database genarrative-prod \ + --env-file /etc/genarrative/api-server.env + +# 把完整 work-dir/state 放回 dev 后,先只读查看可清理历史候选。 +node -- scripts/database-backup-to-oss.mjs \ + --storage-format files \ + --mode history \ + --data-dir /stdb \ + --work-dir /var/lib/genarrative/database-backups/dev-files \ + --database genarrative-prod \ + --env-file /etc/genarrative/api-server.env \ + --dry-run \ + --result-file /var/lib/genarrative/database-backups/history-dry-run.json + +# 核对 dry-run 后执行真实归档与清理;history 在线处理不可变历史文件,不传 --stop-service。 +node -- scripts/database-backup-to-oss.mjs \ + --storage-format files \ + --mode history \ + --data-dir /stdb \ + --work-dir /var/lib/genarrative/database-backups/dev-files \ + --database genarrative-prod \ + --env-file /etc/genarrative/api-server.env +``` + +dev 出口过慢时,可以把冻结基线经内网 rsync 到 release 独立 staging,再由 release 上传 dev bucket。staging 必须位于 `/var/lib/genarrative/dev-database-backup-staging/` 一类隔离目录,命令显式传 staging `--data-dir`、独立 `--work-dir`、dev `--bucket`,且不得传 `--stop-service`;禁止指向或修改 release `/stdb`。中转 key 只为本次传输临时授权,结束后从 dev 私钥和 release `authorized_keys` 同时移除。上传完成后把整个 files work-dir/state 回传 dev,history 才能延续同一 baseline catalog。 + +完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `/` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;当前 live release 仍保持 `archive-full`,需要切换时先为 release 建立并恢复验证独立 full baseline,再通过 Server-Provision 显式选择 `files-history`,无需修改代码或解除额外硬门禁。 + +```bash +node -- scripts/database-backup-to-oss.mjs \ + --env-file /etc/genarrative/api-server.env \ + --database genarrative-prod \ + --restore-files-latest \ + --restore-dir /var/lib/genarrative/database-backup-restore/stdb \ + --result-file /var/lib/genarrative/database-backup-restore/restore-result.json +``` + 冷备份后必须做一次只读验收,不要只看 `genarrative-database-backup.service` 是否成功退出: ```bash diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index e59a82fa9..2d2e550ba 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -33,6 +33,8 @@ pipeline { string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点目录或软链接') string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件') string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') + choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-history'], description: '数据库定时备份 profile;默认 archive-full,files-history 仅在指定 work-dir 已有完整 full baseline 后启用') + string(name: 'DATABASE_BACKUP_FILES_HISTORY_WORK_DIR', defaultValue: '/var/lib/genarrative/database-backups/files-history', description: 'files-history 的本地 state/catalog 目录;dev/release 必须使用各自已建立 full baseline 的独立目录') choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-http,release 正式入口选 production-https') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collector;api-server 模板默认开启 OTLP,如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false') @@ -103,6 +105,14 @@ pipeline { if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') { error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。') } + def databaseBackupProfile = params.DATABASE_BACKUP_PROFILE?.trim() + if (!(databaseBackupProfile in ['archive-full', 'files-history'])) { + error("DATABASE_BACKUP_PROFILE 只能是 archive-full 或 files-history,当前值: ${params.DATABASE_BACKUP_PROFILE}") + } + def databaseBackupFilesHistoryWorkDir = params.DATABASE_BACKUP_FILES_HISTORY_WORK_DIR?.trim() + if (!(databaseBackupFilesHistoryWorkDir ==~ /^\/var\/lib\/genarrative\/database-backups\/[A-Za-z0-9._\/-]+$/) || databaseBackupFilesHistoryWorkDir.contains('..')) { + error("DATABASE_BACKUP_FILES_HISTORY_WORK_DIR 必须是 /var/lib/genarrative/database-backups/ 下不含连续点号的绝对路径,当前值: ${params.DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}") + } if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') { error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。') } diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index bbc8b0fb2..0905febc2 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -1,11 +1,25 @@ #!/usr/bin/env node import {spawnSync} from 'node:child_process'; -import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import path from 'node:path'; -import {buildAuthorization, buildCanonicalQuery, uploadArchive} from './database-backup-to-oss.mjs'; +import { + buildAuthorization, + buildCanonicalQuery, + cleanupHistoryCandidates, + collectDirectFileEntries, + discoverHistoryPlan, + restoreDirectFilesBackup, + restoreDirectFilesLatest, + resumeUploadedHistoryBatch, + runDirectFilesBackup, + uploadArchive, + uploadHistoryArchiveWithCleanup, + uploadManifestFile, +} from './database-backup-to-oss.mjs'; const BACKUP_SCRIPT = path.resolve('scripts/database-backup-to-oss.mjs'); const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-database-backup-check-')); @@ -35,6 +49,296 @@ async function main() { await assertMissingPartEtagAbortsMultipartUpload(); await assertCompleteResponseAmbiguityUsesHeadVerification(); await assertHeadLengthMismatchAbortsMultipartUpload(); + await assertHeadShaMismatchAbortsMultipartUpload(); + await assertManifestUploadUsesShaAndHeadVerification(); + assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas(); + assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch(); + assertHistoryBackupLockRejectsLiveAndStaleOwners(); + assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames(); + assertHistoryStatDriftPreventsAnyCleanup(); + await assertHistoryUploadFailureDoesNotDeleteSources(); + await assertHistorySuccessfulUploadCleansAndIsIdempotent(); + await assertHistoryResumeReverifiesArchiveAndManifest(); + await assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(); + await assertDirectHistoryPublishesCatalogBeforeCleanup(); + await assertDirectHistoryWithoutCandidatesPublishesLatest(); + await assertDirectFilesRestoreDownloadsCatalogAndObjects(); +} + +function createDirectOssHarness() { + const objects = new Map(); + const uploadedKeys = []; + const verifiedKeys = []; + const uploadFn = async ({archivePath, objectKey, archiveSha256}) => { + const body = readFileSync(archivePath); + const sha256 = createHash('sha256').update(body).digest('hex'); + assertEqual(sha256, archiveSha256, `direct file ${objectKey} 的上传 SHA 必须来自实际内容。`); + objects.set(objectKey, {body, contentLength: body.length, sha256}); + uploadedKeys.push(objectKey); + return {objectKey, contentLength: body.length, archiveSha256: sha256, verifiedAt: '2026-07-16T01:00:00.000Z'}; + }; + const uploadManifestFn = async ({manifestPath, objectKey}) => { + const body = readFileSync(manifestPath); + const sha256 = createHash('sha256').update(body).digest('hex'); + objects.set(objectKey, {body, contentLength: body.length, sha256}); + uploadedKeys.push(objectKey); + return {objectKey, contentLength: body.length, archiveSha256: sha256, verifiedAt: '2026-07-16T01:00:01.000Z'}; + }; + const verifyFn = async ({objectKey, contentLength, archiveSha256}) => { + verifiedKeys.push(objectKey); + const object = objects.get(objectKey); + if (!object) { + const error = new Error(`missing ${objectKey}`); + error.status = 404; + throw error; + } + if (object.contentLength !== contentLength || object.sha256 !== archiveSha256) { + throw new Error(`mismatch ${objectKey}`); + } + return {verifiedAt: '2026-07-16T01:00:02.000Z'}; + }; + return {objects, uploadedKeys, verifiedKeys, uploadFn, uploadManifestFn, verifyFn}; +} + +async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload() { + const root = path.join(tmpRoot, 'direct-files-incremental'); + const dataDir = path.join(root, 'stdb'); + const workDir = path.join(root, 'work'); + mkdirSync(path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects'), {recursive: true}); + mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); + writeFileSync(path.join(dataDir, 'control-db'), 'control'); + writeFileSync( + path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects', 'object.bin'), + 'snapshot object', + ); + const harness = createDirectOssHarness(); + const options = { + mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', + uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + }; + const collected = await collectDirectFileEntries({dataDir, database: 'test-db', objectPrefix: 'database-backups'}); + assertTrue( + collected.files.some(({path: filePath}) => filePath === 'replicas/1/snapshots/00000000000000000010.snapshot_dir/objects/object.bin'), + 'files catalog 必须原样保留 snapshot 内文件的相对路径。', + ); + assertTrue(collected.directories.includes('empty-directory'), 'files catalog 必须保留空目录。'); + + const first = await runDirectFilesBackup(options); + assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。'); + assertTrue(!Object.hasOwn(first.catalog, 'dataDir'), '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。'); + const latestObjectKey = 'database-backups/test-db/latest.json'; + const latest = JSON.parse(harness.objects.get(latestObjectKey).body.toString('utf8')); + assertEqual(latest.latestFullCatalog.catalogId, first.catalogId, 'latest pointer 必须指向已验真的最新 full catalog。'); + assertTrue(!Object.hasOwn(latest.latestFullCatalog, 'files'), 'latest full ref 不得嵌入 files 数组。'); + assertTrue(latest.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'latest history ref 不得嵌入 files 数组。'); + const immutableUploadsAfterFirst = harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey).length; + const repeated = await runDirectFilesBackup(options); + assertEqual(repeated.uploadedCount, 0, '相同目录重复运行不得重复上传文件。'); + assertEqual( + harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey).length, + immutableUploadsAfterFirst, + '相同 catalog 重跑不得重复 PUT 文件或 catalog,但应覆盖验真 latest pointer。', + ); + + writeFileSync(path.join(dataDir, 'control-db'), 'control changed'); + writeFileSync(path.join(dataDir, 'new-program.bin'), 'new program'); + const incremental = await runDirectFilesBackup(options); + assertEqual(incremental.uploadedCount, 2, '增量 files full 只应上传新增和变化文件。'); + assertEqual(incremental.reusedCount, 1, '增量 files full 应复用未变化 snapshot 文件。'); +} + +async function assertDirectHistoryPublishesCatalogBeforeCleanup() { + const fixture = createHistoryFixture('direct-files-history-cleanup', {nestedData: false}); + createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const harness = createDirectOssHarness(); + const common = { + dataDir: fixture.dataDir, + workDir: fixture.workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + verifyFn: harness.verifyFn, + }; + await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn}); + const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + let failure = null; + try { + await runDirectFilesBackup({ + ...common, + mode: 'history', + uploadManifestFn: async () => { + throw new Error('synthetic direct catalog failure'); + }, + }); + } catch (error) { + failure = error; + } + assertIncludes(failure?.message ?? '', 'synthetic direct catalog failure', 'direct history catalog 发布失败必须向上返回。'); + for (const candidate of plan.candidates) { + assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `direct history catalog 发布失败不得删除: ${candidate.path}`); + } + + let pointerFailure = null; + try { + await runDirectFilesBackup({ + ...common, + mode: 'history', + uploadManifestFn: harness.uploadManifestFn, + verifyFn: async (options) => { + if (options.objectKey.endsWith('/latest.json')) { + throw new Error('synthetic latest pointer HEAD failure'); + } + return harness.verifyFn(options); + }, + }); + } catch (error) { + pointerFailure = error; + } + assertIncludes(pointerFailure?.message ?? '', 'synthetic latest pointer HEAD failure', 'latest pointer HEAD 验真失败必须向上返回。'); + for (const candidate of plan.candidates) { + assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `latest pointer 发布失败不得删除: ${candidate.path}`); + } + + const success = await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn}); + assertEqual(success.uploadedCount, 0, 'history 文件已在 full CAS baseline 时不应重复上传内容。'); + for (const file of success.catalog.files) { + assertTrue( + harness.verifiedKeys.includes(file.objectKey), + `history 清理前必须逐个验真 baseline 复用对象: ${file.path}`, + ); + } + assertEqual(success.cleanup?.deletedCount, plan.candidates.length, 'catalog 和 baseline 验真后才应清理全部安全候选。'); + + const state = JSON.parse(readFileSync(success.statePath, 'utf8')); + const historyCatalogObjectKey = state.historyCatalogs[0].objectKey; + harness.objects.delete(historyCatalogObjectKey); + let brokenHistoryFailure = null; + try { + await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn}); + } catch (error) { + brokenHistoryFailure = error; + } + assertIncludes( + brokenHistoryFailure?.message ?? '', + `missing ${historyCatalogObjectKey}`, + 'latest pointer 发布前必须重新验真所有 history catalog 引用。', + ); +} + +async function assertDirectHistoryWithoutCandidatesPublishesLatest() { + const fixture = createHistoryFixture('direct-files-history-empty', {nestedData: false}); + createReplicaHistory(fixture.replicasDir, '1', {snapshots: [10], segments: [0]}); + const harness = createDirectOssHarness(); + const common = { + dataDir: fixture.dataDir, + workDir: fixture.workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, + }; + await runDirectFilesBackup({...common, mode: 'full'}); + const latestObjectKey = 'database-backups/test-db/latest.json'; + harness.objects.delete(latestObjectKey); + const result = await runDirectFilesBackup({...common, mode: 'history'}); + assertEqual(result.candidateCount, 0, 'fixture 应没有可归档 history 候选。'); + assertTrue(harness.objects.has(latestObjectKey), 'history 无候选时仍必须从现有 state 发布 latest pointer。'); + assertTrue(harness.verifiedKeys.includes(latestObjectKey), 'history 无候选时 latest pointer 仍必须 HEAD 验真。'); +} + +async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { + const root = path.join(tmpRoot, 'direct-files-restore'); + const dataDir = path.join(root, 'stdb'); + const workDir = path.join(root, 'work'); + const restoreDir = path.join(root, 'restore'); + mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); + mkdirSync(path.join(dataDir, 'config'), {recursive: true}); + const keyPath = path.join(dataDir, 'config', 'id_ecdsa'); + writeFileSync(keyPath, 'private key fixture'); + chmodSync(keyPath, 0o640); + const harness = createDirectOssHarness(); + await runDirectFilesBackup({ + mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', + uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + }); + writeFileSync(keyPath, 'updated private key fixture'); + chmodSync(keyPath, 0o640); + const latestFull = await runDirectFilesBackup({ + mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', + uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + }); + const restored = await restoreDirectFilesBackup({ + statePath: latestFull.statePath, + restoreDir, + database: 'test-db', + bucket: 'backup-bucket', + uploadOptions: {}, + downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''), + downloadFileFn: async ({objectKey, destinationPath}) => { + const object = harness.objects.get(objectKey); + if (!object) { + throw new Error(`missing ${objectKey}`); + } + writeFileSync(destinationPath, object.body); + }, + }); + assertEqual(restored.downloadedCount, 1, 'files restore 必须从对象存储下载 catalog 中的普通文件。'); + assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'files restore 必须按最新 full catalog 的原相对路径恢复内容。'); + assertTrue(existsSync(path.join(restoreDir, 'empty-directory')), 'files restore 必须重建空目录。'); + assertEqual(statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, 0o640, 'files restore 必须恢复文件权限。'); + + rmSync(restoreDir, {recursive: true, force: true}); + const downloadBufferFn = async ({objectKey}) => { + const object = harness.objects.get(objectKey); + if (!object) { + throw new Error(`missing ${objectKey}`); + } + return Buffer.from(object.body); + }; + let objectDownloadCount = 0; + const downloadFileFn = async ({objectKey, destinationPath}) => { + objectDownloadCount += 1; + const object = harness.objects.get(objectKey); + if (!object) { + throw new Error(`missing ${objectKey}`); + } + writeFileSync(destinationPath, object.body); + }; + const dryRun = await restoreDirectFilesLatest({ + restoreDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + dryRun: true, + downloadBufferFn, + downloadFileFn, + verifyFn: harness.verifyFn, + }); + assertEqual(dryRun.catalogId, latestFull.catalogId, 'OSS-only dry-run 必须选择 latestFullCatalog。'); + assertEqual(dryRun.fileCount, 1, 'OSS-only dry-run 应返回 full catalog 文件数。'); + assertEqual(dryRun.totalSizeBytes, String(Buffer.byteLength('updated private key fixture')), 'OSS-only dry-run 应返回总字节数。'); + assertEqual(objectDownloadCount, 0, 'OSS-only dry-run 不得下载数据对象。'); + assertTrue(!existsSync(restoreDir), 'OSS-only dry-run 不得创建恢复目录。'); + + const latestRestored = await restoreDirectFilesLatest({ + restoreDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + downloadBufferFn, + downloadFileFn, + verifyFn: harness.verifyFn, + }); + assertEqual(latestRestored.catalogId, latestFull.catalogId, 'OSS-only restore 必须选择 latestFullCatalog。'); + assertEqual(latestRestored.downloadedCount, 1, 'OSS-only restore 应下载 latest full catalog 的数据对象。'); + assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'OSS-only restore 应还原最新 full 内容。'); } function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() { @@ -138,6 +442,7 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { Buffer.alloc(partSizeBytes, 'b'), Buffer.alloc(17, 'c'), ]); + const payloadSha256 = createHash('sha256').update(payload).digest('hex'); mkdirSync(root, {recursive: true}); writeFileSync(archivePath, payload); @@ -167,7 +472,10 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { return new Response('', {status: 200, headers: {etag: '"complete-etag"'}}); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: {'content-length': String(payload.length)}}); + return new Response(null, {status: 200, headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': payloadSha256, + }}); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -199,6 +507,7 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { const initiateRequest = requests[0]; assertTrue(initiateRequest.url.endsWith('?uploads'), 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。'); assertTrue(!initiateRequest.url.endsWith('?uploads='), 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。'); + assertEqual(initiateRequest.headers['x-oss-meta-archive-sha256'], payloadSha256, 'multipart 对象必须保存本地归档 SHA-256 元数据。'); const firstPartRequests = requests.filter(({method, url}) => method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1'); assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。'); assertBufferEqual(firstPartRequests[0].body, payload.subarray(0, partSizeBytes), '第一段原请求内容必须完整。'); @@ -222,6 +531,7 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { const archivePath = path.join(root, 'backup.tar.gz'); const partSizeBytes = 100 * 1024; const payload = Buffer.alloc(partSizeBytes + 1, 'x'); + const payloadSha256 = createHash('sha256').update(payload).digest('hex'); mkdirSync(root, {recursive: true}); writeFileSync(archivePath, payload); @@ -240,7 +550,10 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { return new Response('', {status: 200, headers: {etag: '"complete-etag"'}}); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: {'content-length': String(payload.length - 1)}}); + return new Response(null, {status: 200, headers: { + 'content-length': String(payload.length - 1), + 'x-oss-meta-archive-sha256': payloadSha256, + }}); } if (options.method === 'DELETE') { return new Response(null, {status: 204}); @@ -277,6 +590,107 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { assertTrue(abortRequest?.url.endsWith('?uploadId=mismatch-upload'), 'AbortMultipartUpload 必须携带同一 uploadId。'); } +async function assertHeadShaMismatchAbortsMultipartUpload() { + const root = path.join(tmpRoot, 'multipart-head-sha-mismatch'); + const archivePath = path.join(root, 'backup.tar.gz'); + const payload = Buffer.alloc(100 * 1024, 's'); + mkdirSync(root, {recursive: true}); + writeFileSync(archivePath, payload); + const requests = []; + const fetchImpl = async (url, options) => { + await readRequestBody(options.body); + requests.push({url, method: options.method}); + const parsedUrl = new URL(url); + if (options.method === 'POST' && parsedUrl.search === '?uploads') { + return new Response('sha-mismatch-upload', {status: 200}); + } + if (options.method === 'PUT') { + return new Response('', {status: 200, headers: {etag: '"part-etag"'}}); + } + if (options.method === 'POST') { + return new Response('', {status: 200}); + } + if (options.method === 'HEAD') { + return new Response(null, {status: 200, headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': '0'.repeat(64), + }}); + } + if (options.method === 'DELETE') { + return new Response(null, {status: 204}); + } + throw new Error(`unexpected request: ${options.method} ${url}`); + }; + let uploadError = null; + try { + await uploadArchive({ + archivePath, + bucket: 'genarrative-test', + endpoint: 'oss-cn-shanghai.aliyuncs.com', + objectKey: 'database-backups/test/sha-mismatch.tar.gz', + accessKeyId: 'test-access-key', + accessKeySecret: 'test-access-secret', + partSizeBytes: 100 * 1024, + maxAttempts: 1, + fetchImpl, + nowFn: () => new Date('2026-07-13T10:20:30.000Z'), + sleepImpl: async () => {}, + randomFn: () => 0, + }); + } catch (error) { + uploadError = error; + } + assertIncludes(uploadError?.message ?? '', 'SHA-256 不一致', 'HEAD SHA-256 不一致时上传必须失败。'); + assertTrue( + requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=sha-mismatch-upload')), + 'HEAD SHA-256 不一致后必须 best-effort AbortMultipartUpload。', + ); +} + +async function assertManifestUploadUsesShaAndHeadVerification() { + const root = path.join(tmpRoot, 'manifest-upload'); + const manifestPath = path.join(root, 'backup.manifest.json'); + const body = Buffer.from('{"uploadStatus":"uploaded"}\n'); + const bodySha256 = createHash('sha256').update(body).digest('hex'); + mkdirSync(root, {recursive: true}); + writeFileSync(manifestPath, body); + const requests = []; + const result = await uploadManifestFile({ + manifestPath, + bucket: 'genarrative-test', + endpoint: 'oss-cn-shanghai.aliyuncs.com', + objectKey: 'database-backups/test/backup.tar.gz.manifest.json', + accessKeyId: 'test-access-key', + accessKeySecret: 'test-access-secret', + maxAttempts: 1, + nowFn: () => new Date('2026-07-13T10:20:30.000Z'), + sleepImpl: async () => {}, + randomFn: () => 0, + fetchImpl: async (url, options) => { + const requestBody = await readRequestBody(options.body); + requests.push({url, method: options.method, headers: options.headers, body: requestBody}); + if (options.method === 'PUT') { + return new Response(null, {status: 200}); + } + if (options.method === 'HEAD') { + return new Response(null, {status: 200, headers: { + 'x-oss-meta-file-size': String(body.length), + 'x-oss-meta-archive-sha256': bodySha256, + }}); + } + throw new Error(`unexpected request: ${options.method} ${url}`); + }, + }); + assertEqual(result.archiveSha256, bodySha256, 'manifest 上传结果必须记录本地 SHA-256。'); + assertBufferEqual(requests.find(({method}) => method === 'PUT')?.body, body, 'manifest PUT 必须上传完整 JSON。'); + assertTrue(requests.some(({method}) => method === 'HEAD'), 'manifest PUT 后必须执行 HEAD 验真。'); + assertEqual( + requests.find(({method}) => method === 'PUT')?.headers['x-oss-meta-file-size'], + String(body.length), + 'manifest PUT 必须记录原始字节数,供动态压缩 HEAD 缺少 content-length 时验真。', + ); +} + async function assertMissingPartEtagAbortsMultipartUpload() { const root = path.join(tmpRoot, 'multipart-missing-etag'); const archivePath = path.join(root, 'backup.tar.gz'); @@ -331,6 +745,7 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { const root = path.join(tmpRoot, 'multipart-complete-ambiguity'); const archivePath = path.join(root, 'backup.tar.gz'); const payload = Buffer.alloc(100 * 1024, 'c'); + const payloadSha256 = createHash('sha256').update(payload).digest('hex'); mkdirSync(root, {recursive: true}); writeFileSync(archivePath, payload); @@ -354,7 +769,10 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { return new Response('NoSuchUpload', {status: 404}); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: {'content-length': String(payload.length)}}); + return new Response(null, {status: 200, headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': payloadSha256, + }}); } if (options.method === 'DELETE') { return new Response(null, {status: 204}); @@ -385,6 +803,459 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { assertTrue(!requests.some(({method}) => method === 'DELETE'), 'HEAD 已证实对象完整时不得 Abort 已完成上传。'); } +function assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas() { + const dev = createHistoryFixture('history-dev-layout', {nestedData: false}); + createReplicaHistory(dev.replicasDir, '1', { + snapshots: [0, 187, 279], + segments: [0, 188, 280], + }); + createReplicaHistory(dev.replicasDir, '2', { + snapshots: [50, 99], + segments: [0, 51, 100], + }); + const devPlan = discoverHistoryPlan({dataDir: dev.dataDir}); + assertEqual(devPlan.replicas.length, 2, 'history 应逐 replica 计算安全边界。'); + assertEqual(devPlan.candidates.length, 7, '多 replica history 候选数量必须符合 snapshot/segment 边界。'); + assertTrue( + devPlan.candidates.some(({path}) => path === 'replicas/1/clog/00000000000000000000.stdb.log'), + 'dev 布局应识别边界 segment 之前的 commitlog。', + ); + assertTrue( + !devPlan.candidates.some(({path}) => path.includes('00000000000000000188.stdb.log')), + '跨越 latest snapshot 的边界 segment 必须保留。', + ); + assertTrue( + !devPlan.candidates.some(({path}) => path.includes('00000000000000000279.snapshot_dir')), + '每个 replica 的 latest snapshot 必须保留。', + ); + + const production = createHistoryFixture('history-production-layout', {nestedData: true}); + createReplicaHistory(production.replicasDir, '7', { + snapshots: [10, 20], + segments: [0, 11, 21], + }); + const productionPlan = discoverHistoryPlan({dataDir: production.dataDir}); + assertEqual(productionPlan.replicasDir, 'data/replicas', 'history 必须兼容 /stdb/data/replicas 布局。'); + assertEqual(productionPlan.candidates.length, 3, 'production 布局应识别一个旧 snapshot 与一对旧 commitlog 文件。'); + + const importResult = runHistoryDryRun(dev); + assertStatus(importResult, 0, 'history dry-run 应能从已有 uploaded baseline manifest 导入 state。'); + assertTrue(existsSync(dev.statePath), 'history dry-run 应持久化导入后的 baseline state。'); + assertIncludes(importResult.stdout, 'history dry-run', 'history dry-run 应明确说明不会上传或删除。'); + for (const candidate of devPlan.candidates) { + assertTrue(existsSync(path.join(dev.dataDir, candidate.path)), `history dry-run 不得删除候选: ${candidate.path}`); + } +} + +function assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames() { + const noSnapshot = createHistoryFixture('history-no-snapshot', {nestedData: false}); + createReplicaHistory(noSnapshot.replicasDir, '1', {snapshots: [], segments: [0]}); + const plan = discoverHistoryPlan({dataDir: noSnapshot.dataDir}); + assertEqual(plan.candidates.length, 0, '没有 snapshot 的 replica 不得产生可删除候选。'); + assertEqual(plan.replicas[0]?.reason, 'no-snapshot', '没有 snapshot 时应记录明确跳过原因。'); + + const incompleteSnapshot = createHistoryFixture('history-incomplete-snapshot', {nestedData: false}); + const incompleteReplica = createReplicaHistory(incompleteSnapshot.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + mkdirSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000020.snapshot_dir')); + mkdirSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.snapshot_dir')); + writeFileSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.snapshot_dir', '00000000000000000030.snapshot_bsatn'), 'locked'); + writeFileSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.lock'), `${process.pid}\n`); + const incompletePlan = discoverHistoryPlan({dataDir: incompleteSnapshot.dataDir}); + assertEqual(incompletePlan.replicas[0]?.latestSnapshot, '10', '缺少 snapshot_bsatn 或仍有 lockfile 的目录不得成为 latest snapshot。'); + assertTrue( + !incompletePlan.candidates.some(({path}) => path.includes('00000000000000000010.snapshot_dir')), + '最后一个完整且未锁定的 snapshot 必须保留。', + ); + + const malformedLog = createHistoryFixture('history-malformed-log', {nestedData: false}); + const malformedLogReplica = createReplicaHistory(malformedLog.replicasDir, '1', {snapshots: [10], segments: [0, 11]}); + writeFileSync(path.join(malformedLogReplica.clogDir, 'broken.stdb.log'), 'broken'); + assertThrows( + () => discoverHistoryPlan({dataDir: malformedLog.dataDir}), + 'commitlog 文件名不符合预期', + '异常 commitlog 名称必须阻断整个清理计划。', + ); +} + +function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { + const missingBaseline = createHistoryFixture('history-missing-baseline', {nestedData: false}); + createReplicaHistory(missingBaseline.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + rmSync(missingBaseline.baselineManifestPath, {force: true}); + const missingResult = runHistoryCommand(missingBaseline, ['--dry-run'], {includeBaselineManifest: false}); + assertStatus(missingResult, 1, 'history 没有 baseline state 或 imported manifest 时必须失败。'); + assertIncludes(missingResult.stderr, '缺少已验真 baseline state', 'baseline 门禁失败应给出明确错误。'); + + const wrongKind = createHistoryFixture('history-wrong-baseline-kind', {nestedData: false}); + createReplicaHistory(wrongKind.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const wrongKindManifest = JSON.parse(readFileSync(wrongKind.baselineManifestPath, 'utf8')); + wrongKindManifest.backupKind = 'spacetimedb-history'; + writeFileSync(wrongKind.baselineManifestPath, `${JSON.stringify(wrongKindManifest)}\n`); + const wrongKindResult = runHistoryDryRun(wrongKind); + assertStatus(wrongKindResult, 1, 'history archive manifest 不得被导入为 full baseline。'); + assertIncludes(wrongKindResult.stderr, 'backupKind 必须是 spacetimedb-data-dir', 'baseline 类型不匹配应失败关闭。'); + + const deterministic = createHistoryFixture('history-deterministic-batch', {nestedData: false}); + createReplicaHistory(deterministic.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + importHistoryState(deterministic); + const firstResultFile = path.join(deterministic.workDir, 'defer-first.json'); + const secondResultFile = path.join(deterministic.workDir, 'defer-second.json'); + const first = runHistoryCommand(deterministic, ['--defer-upload', '--result-file', firstResultFile]); + const second = runHistoryCommand(deterministic, ['--defer-upload', '--result-file', secondResultFile]); + assertStatus(first, 0, '第一次 history defer 应成功生成归档。'); + assertStatus(second, 0, '相同候选重复 history defer 应幂等复用 batch identity。'); + const firstPayload = JSON.parse(readFileSync(firstResultFile, 'utf8')); + const secondPayload = JSON.parse(readFileSync(secondResultFile, 'utf8')); + assertEqual(firstPayload.batchId, secondPayload.batchId, '相同 baseline 与候选必须生成确定性 batchId。'); + assertEqual(firstPayload.objectKey, secondPayload.objectKey, '相同 batch 重跑不得制造新的 OSS object key。'); + const archiveListing = spawnSync('tar', ['-tzf', firstPayload.archivePath], {encoding: 'utf8'}); + assertStatus(archiveListing, 0, 'history 归档应可被 tar 正常读取。'); + assertIncludes(archiveListing.stdout, path.basename(firstPayload.manifestPath), 'history 归档内部必须携带安全候选 manifest。'); + + const dryRunPending = createHistoryFixture('history-dry-run-pending-cleanup', {nestedData: false}); + createReplicaHistory(dryRunPending.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const dryRunState = importHistoryState(dryRunPending); + const dryRunPlan = discoverHistoryPlan({dataDir: dryRunPending.dataDir}); + dryRunState.batches.push({ + batchId: 'pending-cleanup', + objectKey: 'database-backups/test-db/history/pending-cleanup.tar.gz', + contentLength: 10, + verifiedAt: '2026-07-16T00:20:00.000Z', + uploadedAt: '2026-07-16T00:20:00.000Z', + status: 'uploaded', + candidates: dryRunPlan.candidates, + }); + writeFileSync(dryRunPending.statePath, `${JSON.stringify(dryRunState)}\n`); + const pendingDryRunResult = runHistoryDryRun(dryRunPending); + assertStatus(pendingDryRunResult, 0, '存在待清理 uploaded batch 时 history dry-run 仍应只读成功。'); + for (const candidate of dryRunPlan.candidates) { + assertTrue(existsSync(path.join(dryRunPending.dataDir, candidate.path)), `history dry-run 不得恢复执行待清理 batch: ${candidate.path}`); + } +} + +function assertHistoryBackupLockRejectsLiveAndStaleOwners() { + const liveOwner = createHistoryFixture('history-live-lock', {nestedData: false}); + createReplicaHistory(liveOwner.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + importHistoryState(liveOwner); + const liveLockPath = path.join(liveOwner.workDir, 'test-db.backup.lock'); + writeFileSync(liveLockPath, `${process.pid}\n`); + const liveResult = runHistoryCommand(liveOwner, ['--defer-upload']); + assertStatus(liveResult, 1, '仍存活进程持有 backup lock 时必须拒绝并发备份。'); + assertIncludes(liveResult.stderr, '已有数据库备份进程持有锁', '并发备份失败应报告 lock owner pid。'); + + const staleOwner = createHistoryFixture('history-stale-lock', {nestedData: false}); + createReplicaHistory(staleOwner.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + importHistoryState(staleOwner); + const staleLockPath = path.join(staleOwner.workDir, 'test-db.backup.lock'); + writeFileSync(staleLockPath, '2147483647\n'); + const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']); + assertStatus(staleResult, 1, '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。'); + assertIncludes(staleResult.stderr, '拒绝自动抢锁', '失效 backup lock 应要求人工核对 multipart 与进程。'); + assertTrue(existsSync(staleLockPath), '失效 backup lock 未经人工核对不得自动删除。'); +} + +function assertHistoryStatDriftPreventsAnyCleanup() { + const fixture = createHistoryFixture('history-stat-drift', {nestedData: false}); + createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const driftCandidate = plan.candidates.find(({kind}) => kind === 'commitlog'); + const untouchedCandidate = plan.candidates.find(({kind}) => kind === 'snapshot'); + writeFileSync(path.join(fixture.dataDir, driftCandidate.path), 'changed-after-plan'); + assertThrows( + () => cleanupHistoryCandidates({dataDir: fixture.dataDir, candidates: plan.candidates}), + 'stat 漂移', + '任一候选 stat 漂移时必须在删除任何文件前失败。', + ); + assertTrue(existsSync(path.join(fixture.dataDir, untouchedCandidate.path)), 'stat 漂移失败时不得删除其他候选。'); +} + +async function assertHistoryUploadFailureDoesNotDeleteSources() { + const fixture = createHistoryFixture('history-upload-failure', {nestedData: false}); + createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const state = importHistoryState(fixture); + const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const archivePath = path.join(fixture.workDir, 'history.tar.gz'); + const manifestPath = `${archivePath}.manifest.json`; + writeFileSync(archivePath, 'history archive'); + const manifest = createHistoryManifest({fixture, state, plan, archivePath}); + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + + let uploadError = null; + try { + await uploadHistoryArchiveWithCleanup({ + archivePath, + manifestPath, + manifest, + statePath: fixture.statePath, + uploadOptions: {}, + uploadFn: async () => { + throw new Error('synthetic upload failure'); + }, + manifestUploadFn: async () => { + throw new Error('manifest upload must not run after archive failure'); + }, + }); + } catch (error) { + uploadError = error; + } + assertIncludes(uploadError?.message ?? '', 'synthetic upload failure', 'history 应保留上传失败原因。'); + for (const candidate of plan.candidates) { + assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `上传失败不得删除 history 源文件: ${candidate.path}`); + } + const stateAfterFailure = JSON.parse(readFileSync(fixture.statePath, 'utf8')); + assertEqual(stateAfterFailure.batches.length, 0, '上传失败不得把 batch 标记为 uploaded。'); + + const manifestFailure = createHistoryFixture('history-manifest-upload-failure', {nestedData: false}); + createReplicaHistory(manifestFailure.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const manifestFailureState = importHistoryState(manifestFailure); + const manifestFailurePlan = discoverHistoryPlan({dataDir: manifestFailure.dataDir}); + const manifestFailureArchive = path.join(manifestFailure.workDir, 'history.tar.gz'); + const manifestFailurePath = `${manifestFailureArchive}.manifest.json`; + writeFileSync(manifestFailureArchive, 'history archive'); + const manifestFailurePayload = createHistoryManifest({ + fixture: manifestFailure, + state: manifestFailureState, + plan: manifestFailurePlan, + archivePath: manifestFailureArchive, + }); + writeFileSync(manifestFailurePath, `${JSON.stringify(manifestFailurePayload)}\n`); + let manifestUploadError = null; + try { + await uploadHistoryArchiveWithCleanup({ + archivePath: manifestFailureArchive, + manifestPath: manifestFailurePath, + manifest: manifestFailurePayload, + statePath: manifestFailure.statePath, + uploadOptions: {}, + uploadFn: async () => ({ + bucket: 'backup-bucket', + objectKey: manifestFailurePayload.objectKey, + contentLength: 15, + archiveSha256: 'c'.repeat(64), + verifiedAt: '2026-07-16T00:05:00.000Z', + }), + manifestUploadFn: async () => { + throw new Error('synthetic manifest upload failure'); + }, + }); + } catch (error) { + manifestUploadError = error; + } + assertIncludes(manifestUploadError?.message ?? '', 'synthetic manifest upload failure', 'sidecar manifest 上传失败应阻断清理。'); + for (const candidate of manifestFailurePlan.candidates) { + assertTrue(existsSync(path.join(manifestFailure.dataDir, candidate.path)), `manifest 上传失败不得删除源文件: ${candidate.path}`); + } +} + +async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { + const fixture = createHistoryFixture('history-upload-success', {nestedData: false}); + createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const state = importHistoryState(fixture); + const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const archivePath = path.join(fixture.workDir, 'history.tar.gz'); + const manifestPath = `${archivePath}.manifest.json`; + writeFileSync(archivePath, 'history archive'); + const manifest = createHistoryManifest({fixture, state, plan, archivePath}); + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + let baselineVerifyCount = 0; + const result = await uploadHistoryArchiveWithCleanup({ + archivePath, + manifestPath, + manifest, + statePath: fixture.statePath, + uploadOptions: {}, + uploadFn: async () => ({ + bucket: 'backup-bucket', + objectKey: manifest.objectKey, + contentLength: 15, + archiveSha256: 'b'.repeat(64), + etag: 'test-etag', + uploadMode: 'multipart', + partCount: 1, + partSizeBytes: 102400, + verifiedAt: '2026-07-16T00:10:00.000Z', + }), + manifestUploadFn: async ({objectKey}) => ({ + objectKey, + contentLength: 512, + archiveSha256: 'd'.repeat(64), + verifiedAt: '2026-07-16T00:10:01.000Z', + }), + verifyFn: async () => { + baselineVerifyCount += 1; + return {verifiedAt: '2026-07-16T00:10:02.000Z'}; + }, + }); + assertEqual(baselineVerifyCount, 2, 'history 删除源文件前必须重新验真 full baseline 与 sidecar。'); + assertEqual(result.cleanup.deletedCount, plan.candidates.length, '验真上传成功后应删除全部安全候选。'); + for (const candidate of plan.candidates) { + assertTrue(!existsSync(path.join(fixture.dataDir, candidate.path)), `验真成功后应删除 history 源文件: ${candidate.path}`); + } + const repeatedCleanup = cleanupHistoryCandidates({dataDir: fixture.dataDir, candidates: plan.candidates}); + assertEqual(repeatedCleanup.alreadyMissingCount, plan.candidates.length, '重复清理同一 uploaded batch 应幂等。'); +} + +async function assertHistoryResumeReverifiesArchiveAndManifest() { + const fixture = createHistoryFixture('history-resume-verification', {nestedData: false}); + createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const state = importHistoryState(fixture); + const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + state.batches.push({ + batchId: 'resume-batch', + objectKey: 'database-backups/test-db/history/resume.tar.gz', + contentLength: 100, + archiveSha256: 'e'.repeat(64), + verifiedAt: '2026-07-16T00:30:00.000Z', + manifestObjectKey: 'database-backups/test-db/history/resume.tar.gz.manifest.json', + manifestContentLength: 200, + manifestArchiveSha256: 'f'.repeat(64), + manifestVerifiedAt: '2026-07-16T00:30:01.000Z', + uploadedAt: '2026-07-16T00:30:00.000Z', + status: 'uploaded', + candidates: plan.candidates, + }); + writeFileSync(fixture.statePath, `${JSON.stringify(state)}\n`); + const verifiedKeys = []; + await resumeUploadedHistoryBatch({ + statePath: fixture.statePath, + state, + dataDir: fixture.dataDir, + verificationOptions: {}, + verifyFn: async ({objectKey}) => { + verifiedKeys.push(objectKey); + return {verifiedAt: '2026-07-16T00:31:00.000Z'}; + }, + }); + assertEqual(verifiedKeys.length, 2, '续清理前必须重新验真 history archive 与 sidecar manifest。'); + for (const candidate of plan.candidates) { + assertTrue(!existsSync(path.join(fixture.dataDir, candidate.path)), `续清理验真后应删除候选: ${candidate.path}`); + } + + const failure = createHistoryFixture('history-resume-verification-failure', {nestedData: false}); + createReplicaHistory(failure.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const failureState = importHistoryState(failure); + const failurePlan = discoverHistoryPlan({dataDir: failure.dataDir}); + failureState.batches.push({...state.batches[0], candidates: failurePlan.candidates}); + writeFileSync(failure.statePath, `${JSON.stringify(failureState)}\n`); + let resumeError = null; + try { + await resumeUploadedHistoryBatch({ + statePath: failure.statePath, + state: failureState, + dataDir: failure.dataDir, + verificationOptions: {}, + verifyFn: async () => { + throw new Error('synthetic resume HEAD failure'); + }, + }); + } catch (error) { + resumeError = error; + } + assertIncludes(resumeError?.message ?? '', 'synthetic resume HEAD failure', '续清理 OSS 复核失败应保留错误。'); + for (const candidate of failurePlan.candidates) { + assertTrue(existsSync(path.join(failure.dataDir, candidate.path)), `续清理验真失败不得删除候选: ${candidate.path}`); + } +} + +function createHistoryFixture(name, {nestedData}) { + const root = path.join(tmpRoot, name); + const dataDir = path.join(root, 'stdb'); + const replicasDir = nestedData ? path.join(dataDir, 'data', 'replicas') : path.join(dataDir, 'replicas'); + const workDir = path.join(root, 'work'); + const statePath = path.join(workDir, 'history-state.json'); + const baselineManifestPath = path.join(workDir, 'baseline.manifest.json'); + mkdirSync(replicasDir, {recursive: true}); + mkdirSync(workDir, {recursive: true}); + writeFileSync(baselineManifestPath, `${JSON.stringify({ + backupKind: 'spacetimedb-data-dir', + uploadStatus: 'uploaded', + database: 'test-db', + dataDir, + bucket: 'backup-bucket', + objectKey: 'database-backups/test-db/baseline.tar.gz', + manifestObjectKey: 'database-backups/test-db/baseline.tar.gz.manifest.json', + contentLength: 1234, + archiveSha256: 'a'.repeat(64), + manifestContentLength: 512, + manifestArchiveSha256: '9'.repeat(64), + manifestVerifiedAt: '2026-07-16T00:00:00.500Z', + verifiedAt: '2026-07-16T00:00:00.000Z', + uploadedAt: '2026-07-16T00:00:01.000Z', + }, null, 2)}\n`); + return {root, dataDir, replicasDir, workDir, statePath, baselineManifestPath}; +} + +function createReplicaHistory(replicasDir, replicaId, {snapshots, segments}) { + const replicaDir = path.join(replicasDir, replicaId); + const snapshotsDir = path.join(replicaDir, 'snapshots'); + const clogDir = path.join(replicaDir, 'clog'); + mkdirSync(snapshotsDir, {recursive: true}); + mkdirSync(clogDir, {recursive: true}); + for (const transaction of snapshots) { + const name = `${String(transaction).padStart(20, '0')}.snapshot_dir`; + const snapshotDir = path.join(snapshotsDir, name); + mkdirSync(path.join(snapshotDir, 'objects'), {recursive: true}); + writeFileSync(path.join(snapshotDir, `${String(transaction).padStart(20, '0')}.snapshot_bsatn`), `snapshot-${transaction}`); + writeFileSync(path.join(snapshotDir, 'objects', 'object.bin'), `object-${transaction}`); + } + for (const transaction of segments) { + const prefix = String(transaction).padStart(20, '0'); + writeFileSync(path.join(clogDir, `${prefix}.stdb.log`), `log-${transaction}`); + writeFileSync(path.join(clogDir, `${prefix}.stdb.ofs`), `ofs-${transaction}`); + } + return {replicaDir, snapshotsDir, clogDir}; +} + +function runHistoryDryRun(fixture) { + const resultFile = path.join(fixture.workDir, 'dry-run-result.json'); + return runHistoryCommand(fixture, [ + '--result-file', resultFile, + '--dry-run', + ]); +} + +function runHistoryCommand(fixture, extraArgs = [], {includeBaselineManifest = true} = {}) { + const baselineManifestArgs = includeBaselineManifest + ? ['--baseline-manifest', fixture.baselineManifestPath] + : []; + return spawnSync(process.execPath, [ + BACKUP_SCRIPT, + '--mode', 'history', + '--data-dir', fixture.dataDir, + '--work-dir', fixture.workDir, + '--database', 'test-db', + '--bucket', 'backup-bucket', + '--endpoint', 'oss-cn-shanghai.aliyuncs.com', + '--access-key-id', 'test-access-key', + '--access-key-secret', 'test-access-secret', + '--baseline-state', fixture.statePath, + ...baselineManifestArgs, + ...extraArgs, + ], {encoding: 'utf8'}); +} + +function importHistoryState(fixture) { + const result = runHistoryDryRun(fixture); + assertStatus(result, 0, '测试 fixture 应能导入 baseline state。'); + return JSON.parse(readFileSync(fixture.statePath, 'utf8')); +} + +function createHistoryManifest({fixture, state, plan, archivePath}) { + return { + schemaVersion: 1, + backupKind: 'spacetimedb-history', + database: 'test-db', + dataDir: fixture.dataDir, + bucket: 'backup-bucket', + objectKey: `database-backups/test-db/history/${state.baseline.id}/test-batch.tar.gz`, + archivePath, + baselineId: state.baseline.id, + baselineStatePath: fixture.statePath, + batchId: 'test-batch', + candidates: plan.candidates, + uploadStatus: 'pending', + }; +} + async function readRequestBody(body) { if (body === undefined || body === null) { return Buffer.alloc(0); @@ -502,6 +1373,20 @@ function assertTrue(condition, reason) { } } +function assertThrows(callback, expectedMessage, reason) { + let thrown = null; + try { + callback(); + } catch (error) { + thrown = error; + } + if (!(thrown instanceof Error)) { + failures.push(`${reason} 预期抛出错误。`); + return; + } + assertIncludes(thrown.message, expectedMessage, reason); +} + function assertBufferEqual(actual, expected, reason) { if (!Buffer.isBuffer(actual) || !actual.equals(expected)) { failures.push(`${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? ''} bytes。`); diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index d46621969..22843943d 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -514,6 +514,76 @@ const checks = [ reason: '生产冷备份 service 必须用 node -- 分隔脚本参数,避免 Node 22 抢占业务 --env-file。', }, + { + file: 'deploy/systemd/genarrative-database-backup.service', + excludes: '--storage-format files', + reason: '生产数据库备份主 service 必须继续保持 archive full 默认行为。', + }, + { + file: 'deploy/systemd/genarrative-database-backup-files-history.conf', + includes: 'ExecStart=\nExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file', + reason: 'files-history drop-in 必须先清空主 service 的 ExecStart,并使用 current release 脚本。', + }, + { + file: 'deploy/systemd/genarrative-database-backup-files-history.conf', + includes: '--storage-format files --mode history --work-dir /var/lib/genarrative/database-backups/files-history', + reason: 'files-history drop-in 必须只执行逐文件历史归档,并复用已建立基线的独立 work-dir。', + }, + { + file: 'deploy/systemd/genarrative-database-backup-files-history.conf', + excludes: '--stop-service', + reason: 'files-history 在线归档不可停止 SpacetimeDB。', + }, + { + file: 'deploy/systemd/genarrative-database-backup-files-history.conf', + excludes: '--database', + reason: 'files-history drop-in 不得写死数据库名,必须从环境文件读取。', + }, + { + file: 'deploy/systemd/genarrative-database-backup-files-history.conf', + excludes: '--bucket', + reason: 'files-history drop-in 不得写死 OSS bucket,必须从环境文件读取。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'DATABASE_BACKUP_PROFILE="${DATABASE_BACKUP_PROFILE:-archive-full}"', + reason: 'Server-Provision 的数据库备份 profile 必须默认保持 archive-full。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'archive-full|files-history)', + reason: 'Server-Provision 必须拒绝未知数据库备份 profile。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: '--storage-format files --mode history --work-dir', + reason: 'Server-Provision 启用 files-history 前必须用真实 current release 脚本执行只读 baseline 预检。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"', + reason: 'Server-Provision 切回 archive-full 时必须移除托管及现场遗留的 history drop-in。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'install_file "${rendered_drop_in}" "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" 0644', + reason: 'Server-Provision 必须从仓库模板安装托管的 files-history drop-in。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + includes: "choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-history']", + reason: 'Server-Provision Job 必须显式暴露 archive-first 的数据库备份 profile。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + includes: "string(name: 'DATABASE_BACKUP_FILES_HISTORY_WORK_DIR', defaultValue: '/var/lib/genarrative/database-backups/files-history'", + reason: 'Server-Provision Job 必须允许 dev/release 为 files-history 选择各自的 baseline state 目录。', + }, + { + file: 'jenkins/Jenkinsfile.production-server-provision', + excludes: "params.DEPLOY_TARGET == 'release' && databaseBackupProfile == 'files-history'", + reason: 'release 必须能在显式选择 profile 且 baseline 预检通过后启用 files-history。', + }, { file: 'scripts/database-backup-to-oss.mjs', includes: 'assertSufficientWorkDirSpace({dataDir, workDir, args, env})', @@ -6837,6 +6907,7 @@ const checks = [ const nodeEnvFileCommandFiles = [ 'package.json', 'deploy/systemd/genarrative-database-backup.service', + 'deploy/systemd/genarrative-database-backup-files-history.conf', 'scripts/deploy/production-stdb-publish.sh', 'scripts/deploy/pingora-direct-enable.sh', 'scripts/deploy/pingora-direct-rollback.sh', diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index d38a28598..c5abd6a3d 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -1,8 +1,27 @@ #!/usr/bin/env node import {spawnSync} from 'node:child_process'; import {createHash, createHmac} from 'node:crypto'; -import {createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, statfsSync, writeFileSync} from 'node:fs'; -import {basename, dirname, isAbsolute, resolve} from 'node:path'; +import { + chmodSync, + closeSync, + createReadStream, + createWriteStream, + existsSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statfsSync, + statSync, + writeFileSync, +} from 'node:fs'; +import {basename, dirname, isAbsolute, join, relative, resolve, sep} from 'node:path'; +import {Readable} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; import {setTimeout as sleep} from 'node:timers/promises'; import {fileURLToPath} from 'node:url'; @@ -27,15 +46,26 @@ const DEFAULT_OSS_REQUEST_MAX_ATTEMPTS = 5; const DEFAULT_OSS_RETRY_BASE_DELAY_MS = 1_000; const DEFAULT_OSS_RETRY_MAX_DELAY_MS = 30_000; const RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]); +const HISTORY_STATE_SCHEMA_VERSION = 1; +const HISTORY_MANIFEST_SCHEMA_VERSION = 1; +const DIRECT_FILES_STATE_SCHEMA_VERSION = 1; +const DIRECT_FILES_CATALOG_SCHEMA_VERSION = 1; +const DIRECT_FILES_LATEST_SCHEMA_VERSION = 1; function usage() { console.log(`用法: - npm run database:backup:oss -- [--data-dir ] [--work-dir ] [--bucket ] [--object-prefix ] [--keep-local] + npm run database:backup:oss -- [--mode full|history] [--storage-format archive|files] [--data-dir ] [--work-dir ] [--bucket ] [--object-prefix ] [--keep-local] node -- scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--restart-service-after genarrative-api.service] [--defer-upload] node -- scripts/database-backup-to-oss.mjs --upload-archive + node -- scripts/database-backup-to-oss.mjs --publish-manifest + node -- scripts/database-backup-to-oss.mjs --restore-files-state --restore-dir + node -- scripts/database-backup-to-oss.mjs --restore-files-latest --restore-dir [--dry-run] 说明: - 将 SpacetimeDB 数据目录打包成 .tar.gz,并上传到阿里云 OSS 指定 bucket。 + 将 SpacetimeDB 数据目录以 .tar.gz 或逐文件 catalog 形式上传到阿里云 OSS 指定 bucket。 + 默认 full 模式保持原有全量冷备行为;history 模式只归档已被最新 snapshot 覆盖的历史 commitlog 与旧 snapshot。 + --storage-format files 不打包:按原相对路径建立 catalog,文件内容以 SHA-256 不可变对象上传;重复运行只上传新增或变化内容。 + archive history 必须有已验真的 full baseline state;files history 必须复用同一 work-dir 中已发布的 full catalog state。 --defer-upload 只生成本地冷备份和 manifest,不上传;后续用 --upload-archive 异步上传。 默认读取 .env / .env.local / .env.secrets.local;生产服务可传 --env-file /etc/genarrative/api-server.env。 shell 环境变量优先级最高,不会被 env 文件覆盖。 @@ -46,8 +76,11 @@ function usage() { GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET 备份 bucket;未设置时回退 ALIYUN_OSS_BUCKET GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX 对象前缀,默认 database-backups GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT OSS endpoint;未设置时回退 ALIYUN_OSS_ENDPOINT + GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT archive(默认)或 files GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL true 时保留本地 tar.gz GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES 备份前要求 work dir 所在文件系统至少有这些可用字节;未设置时按数据目录大小估算 + GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE history 使用的 full baseline 与追加批次状态文件 + GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST 首次初始化 history state 的 uploaded full manifest ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET `); } @@ -121,6 +154,14 @@ function parseArgs(argv) { objectKey: '', resultFile: '', minFreeBytes: '', + mode: 'full', + baselineState: '', + baselineManifest: '', + publishManifest: '', + storageFormat: '', + restoreFilesState: '', + restoreFilesLatest: false, + restoreDir: '', }; for (let index = 0; index < argv.length; index += 1) { @@ -198,6 +239,30 @@ function parseArgs(argv) { case '--min-free-bytes': options.minFreeBytes = readValue(); break; + case '--mode': + options.mode = readValue(); + break; + case '--baseline-state': + options.baselineState = readValue(); + break; + case '--baseline-manifest': + options.baselineManifest = readValue(); + break; + case '--publish-manifest': + options.publishManifest = readValue(); + break; + case '--storage-format': + options.storageFormat = readValue(); + break; + case '--restore-files-state': + options.restoreFilesState = readValue(); + break; + case '--restore-files-latest': + options.restoreFilesLatest = true; + break; + case '--restore-dir': + options.restoreDir = readValue(); + break; default: throw new Error(`未知参数: ${arg}`); } @@ -251,6 +316,356 @@ function buildBackupNames({database, dataDir, objectPrefix}) { return {fileName, objectKey}; } +function atomicWriteJson(filePath, payload) { + mkdirSync(dirname(filePath), {recursive: true}); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + chmodSync(tempPath, 0o600); + renameSync(tempPath, filePath); +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function acquireBackupLock({workDir, database}) { + mkdirSync(workDir, {recursive: true}); + const lockPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`); + try { + const fd = openSync(lockPath, 'wx', 0o600); + writeFileSync(fd, `${process.pid}\n`, 'utf8'); + closeSync(fd); + const release = () => { + try { + const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); + if (ownerPid === process.pid) { + rmSync(lockPath, {force: true}); + } + } catch { + // The lock may already have been removed by the normal exit path. + } + }; + process.once('exit', release); + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + release(); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + } + return lockPath; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + } + const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); + if (Number.isSafeInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) { + throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); + } + throw new Error(`发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`); +} + +function historyStatePath({args, env, workDir, database}) { + return resolvePath(firstNonEmpty( + args.baselineState, + env.GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE, + join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-history-state.json`), + )); +} + +function baselineIdFor(baseline) { + return sha256Hex([ + baseline.bucket, + baseline.objectKey, + baseline.verifiedAt, + baseline.contentLength, + baseline.archiveSha256, + ].join('\0')).slice(0, 24); +} + +function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { + if (manifest.uploadStatus !== 'uploaded') { + throw new Error(`baseline manifest 必须是 uploaded,实际: ${manifest.uploadStatus ?? ''}`); + } + if (manifest.backupKind !== 'spacetimedb-data-dir') { + throw new Error(`baseline manifest backupKind 必须是 spacetimedb-data-dir,实际: ${manifest.backupKind ?? ''}`); + } + const baseline = { + backupKind: 'spacetimedb-data-dir', + database: firstNonEmpty(manifest.database, database), + dataDir: resolvePath(firstNonEmpty(manifest.dataDir, dataDir)), + bucket: String(manifest.bucket ?? '').trim(), + objectKey: String(manifest.objectKey ?? '').trim(), + verifiedAt: String(manifest.verifiedAt ?? '').trim(), + uploadedAt: String(manifest.uploadedAt ?? '').trim(), + contentLength: Number(manifest.contentLength), + archiveSha256: String(manifest.archiveSha256 ?? '').trim().toLowerCase(), + manifestObjectKey: String(manifest.manifestObjectKey ?? '').trim(), + manifestContentLength: Number(manifest.manifestContentLength), + manifestArchiveSha256: String(manifest.manifestArchiveSha256 ?? '').trim().toLowerCase(), + manifestVerifiedAt: String(manifest.manifestVerifiedAt ?? '').trim(), + }; + if ( + !baseline.bucket + || !baseline.objectKey + || !baseline.verifiedAt + || !Number.isSafeInteger(baseline.contentLength) + || baseline.contentLength <= 0 + || !/^[a-f0-9]{64}$/u.test(baseline.archiveSha256) + || !baseline.manifestObjectKey + || !Number.isSafeInteger(baseline.manifestContentLength) + || baseline.manifestContentLength <= 0 + || !/^[a-f0-9]{64}$/u.test(baseline.manifestArchiveSha256) + || !baseline.manifestVerifiedAt + ) { + throw new Error('baseline manifest 缺少已验真 OSS 归档或 sidecar 信息。'); + } + baseline.id = baselineIdFor(baseline); + return baseline; +} + +function validateHistoryState(state, {database, dataDir}) { + if (state.schemaVersion !== HISTORY_STATE_SCHEMA_VERSION || !state.baseline) { + throw new Error('history state schemaVersion 或 baseline 无效。'); + } + const baseline = normalizeUploadedBaselineManifest( + {...state.baseline, uploadStatus: 'uploaded'}, + {database, dataDir}, + ); + if (baseline.database !== database) { + throw new Error(`history state database 不匹配: expected=${database}, actual=${baseline.database}`); + } + if (resolvePath(baseline.dataDir) !== resolvePath(dataDir)) { + throw new Error(`history state dataDir 不匹配: expected=${resolvePath(dataDir)}, actual=${resolvePath(baseline.dataDir)}`); + } + return { + ...state, + baseline, + batches: Array.isArray(state.batches) ? state.batches : [], + }; +} + +function writeBaselineState({statePath, baseline, previousState = null}) { + const state = { + schemaVersion: HISTORY_STATE_SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + baseline, + batches: previousState?.baseline?.id === baseline.id && Array.isArray(previousState.batches) + ? previousState.batches + : [], + }; + atomicWriteJson(statePath, state); + return state; +} + +function loadOrImportHistoryState({args, env, statePath, database, dataDir}) { + if (existsSync(statePath)) { + return validateHistoryState(readManifest(statePath), {database, dataDir}); + } + const importPath = firstNonEmpty(args.baselineManifest, env.GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST); + if (!importPath) { + throw new Error(`history 模式缺少已验真 baseline state: ${statePath};可用 --baseline-manifest 导入已有 uploaded baseline manifest。`); + } + const baseline = normalizeUploadedBaselineManifest(readManifest(resolvePath(importPath)), {database, dataDir}); + return writeBaselineState({statePath, baseline}); +} + +function assertSafeRelativePath(dataDir, absolutePath) { + const relativePath = relative(resolvePath(dataDir), resolvePath(absolutePath)); + if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`history 候选路径越界或等于数据目录: ${absolutePath}`); + } + return relativePath.split(sep).join('/'); +} + +function statFingerprint(absolutePath, rootPath = absolutePath) { + const entries = []; + let totalSize = 0n; + const visit = (currentPath) => { + const stat = lstatSync(currentPath, {bigint: true}); + if (stat.isSymbolicLink()) { + throw new Error(`history 候选不得包含符号链接: ${currentPath}`); + } + const entryPath = currentPath === rootPath ? '.' : relative(rootPath, currentPath).split(sep).join('/'); + const kind = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other'; + if (kind === 'other') { + throw new Error(`history 候选只允许普通文件或目录: ${currentPath}`); + } + entries.push([ + entryPath, + kind, + stat.dev.toString(), + stat.ino.toString(), + stat.mode.toString(), + stat.size.toString(), + stat.mtimeNs.toString(), + ].join('\0')); + if (stat.isFile()) { + totalSize += stat.size; + } else { + for (const name of readdirSync(currentPath).sort()) { + visit(join(currentPath, name)); + } + } + }; + visit(rootPath); + return { + fingerprint: sha256Hex(entries.join('\n')), + sizeBytes: totalSize.toString(), + entryCount: entries.length, + }; +} + +function findReplicasDir(dataDir) { + const candidates = [resolve(dataDir, 'replicas'), resolve(dataDir, 'data', 'replicas')] + .filter((candidate) => existsSync(candidate) && lstatSync(candidate).isDirectory()); + if (candidates.length !== 1) { + throw new Error(`无法唯一确定 replicas 目录: ${candidates.length === 0 ? '' : candidates.join(', ')}`); + } + return candidates[0]; +} + +function historyCandidate({dataDir, absolutePath, kind, replicaId, transaction}) { + const stat = statFingerprint(absolutePath); + return { + path: assertSafeRelativePath(dataDir, absolutePath), + kind, + replicaId, + transaction: transaction.toString(), + ...stat, + }; +} + +export function discoverHistoryPlan({dataDir}) { + const resolvedDataDir = resolvePath(dataDir); + const replicasDir = findReplicasDir(resolvedDataDir); + const replicaEntries = readdirSync(replicasDir, {withFileTypes: true}); + const replicas = []; + const candidates = []; + + for (const replicaEntry of replicaEntries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!replicaEntry.isDirectory()) { + continue; + } + if (!/^\d+$/u.test(replicaEntry.name)) { + throw new Error(`replica 目录名不符合预期: ${replicaEntry.name}`); + } + const replicaId = replicaEntry.name; + const replicaDir = join(replicasDir, replicaId); + const snapshotsDir = join(replicaDir, 'snapshots'); + const clogDir = join(replicaDir, 'clog'); + if (!existsSync(snapshotsDir) || !lstatSync(snapshotsDir).isDirectory()) { + replicas.push({replicaId, status: 'skipped', reason: 'no-snapshots-directory'}); + continue; + } + const snapshotEntries = readdirSync(snapshotsDir, {withFileTypes: true}); + const snapshots = snapshotEntries.flatMap((entry) => { + const match = /^(\d{20})\.snapshot_dir$/u.exec(entry.name); + if (!match) { + return []; + } + const transaction = BigInt(match[1]); + if (transaction > 0xffff_ffff_ffff_ffffn) { + throw new Error(`snapshot transaction 超出 u64: ${entry.name}`); + } + if (!entry.isDirectory()) { + throw new Error(`snapshot 候选必须是目录: ${join(snapshotsDir, entry.name)}`); + } + const snapshotDir = join(snapshotsDir, entry.name); + const lockPath = join(snapshotsDir, `${match[1]}.lock`); + const snapshotFile = join(snapshotDir, `${match[1]}.snapshot_bsatn`); + if (existsSync(lockPath) || !existsSync(snapshotFile) || !lstatSync(snapshotFile).isFile()) { + return []; + } + return [{name: entry.name, transaction}]; + }).sort((left, right) => left.transaction < right.transaction ? -1 : left.transaction > right.transaction ? 1 : 0); + if (snapshots.length === 0) { + replicas.push({replicaId, status: 'skipped', reason: 'no-snapshot'}); + continue; + } + if (!existsSync(clogDir) || !lstatSync(clogDir).isDirectory()) { + throw new Error(`replica ${replicaId} 缺少 clog 目录。`); + } + const segmentFiles = new Map(); + for (const entry of readdirSync(clogDir, {withFileTypes: true})) { + const match = /^(\d{20})\.stdb\.(log|ofs)$/u.exec(entry.name); + if (!match) { + throw new Error(`commitlog 文件名不符合预期: ${entry.name}`); + } + if (!entry.isFile()) { + throw new Error(`commitlog 候选必须是普通文件: ${join(clogDir, entry.name)}`); + } + const transaction = BigInt(match[1]); + if (transaction > 0xffff_ffff_ffff_ffffn) { + throw new Error(`commitlog transaction 超出 u64: ${entry.name}`); + } + const key = transaction.toString(); + const group = segmentFiles.get(key) ?? {transaction}; + group[match[2]] = entry.name; + segmentFiles.set(key, group); + } + for (const group of segmentFiles.values()) { + if (group.ofs && !group.log) { + throw new Error(`commitlog offset 缺少对应 log: replica=${replicaId}, transaction=${group.transaction}`); + } + } + const segments = [...segmentFiles.values()] + .filter((group) => group.log) + .sort((left, right) => left.transaction < right.transaction ? -1 : left.transaction > right.transaction ? 1 : 0); + const latestSnapshot = snapshots.at(-1).transaction; + const boundarySegment = segments.filter((segment) => segment.transaction <= latestSnapshot).at(-1); + if (!boundarySegment) { + throw new Error(`replica ${replicaId} 无法找到覆盖 latest snapshot ${latestSnapshot} 的 commitlog 边界。`); + } + for (const snapshot of snapshots.slice(0, -1)) { + candidates.push(historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(snapshotsDir, snapshot.name), + kind: 'snapshot', + replicaId, + transaction: snapshot.transaction, + })); + } + for (const segment of segments.filter((item) => item.transaction < boundarySegment.transaction)) { + candidates.push(historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(clogDir, segment.log), + kind: 'commitlog', + replicaId, + transaction: segment.transaction, + })); + if (segment.ofs) { + candidates.push(historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(clogDir, segment.ofs), + kind: 'commitlog-offset', + replicaId, + transaction: segment.transaction, + })); + } + } + replicas.push({ + replicaId, + status: 'ready', + latestSnapshot: latestSnapshot.toString(), + boundarySegment: boundarySegment.transaction.toString(), + }); + } + candidates.sort((left, right) => left.path.localeCompare(right.path)); + return { + dataDir: resolvedDataDir, + replicasDir: assertSafeRelativePath(resolvedDataDir, replicasDir), + replicas, + candidates, + totalSizeBytes: candidates.reduce((sum, item) => sum + BigInt(item.sizeBytes), 0n).toString(), + }; +} + function runCommand(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd ?? REPO_ROOT, @@ -371,6 +786,18 @@ function assertSufficientWorkDirSpace({dataDir, workDir, args, env}) { } } +function assertSufficientHistoryWorkDirSpace({historySizeBytes, workDir, args, env}) { + mkdirSync(workDir, {recursive: true}); + const availableBytes = getAvailableBytes(workDir); + const requiredFreeBytes = calculateRequiredFreeBytes({dataSizeBytes: BigInt(historySizeBytes), args, env}); + console.log( + `[database-backup] history 空间预检: candidates=${formatBytes(historySizeBytes)}, available=${formatBytes(availableBytes)}, required=${formatBytes(requiredFreeBytes)}`, + ); + if (availableBytes < requiredFreeBytes) { + throw new Error(`history 工作目录剩余空间不足: available=${formatBytes(availableBytes)};required=${formatBytes(requiredFreeBytes)}`); + } +} + function collectRestartServicesAfterBackup({args, env}) { const serviceNames = [ ...String(env.GENARRATIVE_DATABASE_BACKUP_RESTART_SERVICE_AFTER ?? '') @@ -448,9 +875,119 @@ function createArchive({dataDir, workDir, fileName}) { const entryName = basename(dataDir); console.log(`[database-backup] 打包: ${dataDir} -> ${archivePath}`); runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], {stdio: 'inherit'}); + verifyArchive(archivePath); return archivePath; } +function verifyArchive(archivePath) { + console.log(`[database-backup] 校验归档: ${archivePath}`); + runCommand('tar', ['-tzf', archivePath], {stdio: 'ignore'}); +} + +function historyBatchId({baselineId, plan}) { + const identity = plan.candidates.map((candidate) => [ + candidate.path, + candidate.kind, + candidate.transaction, + candidate.fingerprint, + ].join('\0')).join('\n'); + return sha256Hex(`${baselineId}\0${identity}`).slice(0, 32); +} + +function buildHistoryNames({database, objectPrefix, baselineId, batchId}) { + const databasePart = sanitizeObjectPart(database, 'spacetimedb'); + const prefix = String(objectPrefix || 'database-backups') + .trim() + .replace(/^\/+|\/+$/gu, '') + .split('/') + .filter(Boolean) + .map((part) => sanitizeObjectPart(part, 'backup')) + .join('/'); + const fileName = `${databasePart}-history-${batchId}.tar.gz`; + return { + fileName, + objectKey: [prefix, databasePart, 'history', baselineId, fileName].filter(Boolean).join('/'), + }; +} + +function createHistoryArchive({dataDir, workDir, fileName, manifestPath, candidates}) { + mkdirSync(workDir, {recursive: true}); + const archivePath = resolve(workDir, fileName); + const candidatePaths = candidates.map((candidate) => candidate.path); + console.log(`[database-backup] 打包 history: ${candidatePaths.length} 个候选 -> ${archivePath}`); + runCommand('tar', [ + '-czf', + archivePath, + '-C', + dataDir, + ...candidatePaths, + '-C', + dirname(manifestPath), + basename(manifestPath), + ], {stdio: 'inherit'}); + verifyArchive(archivePath); + return archivePath; +} + +function recordHistoryBatch({statePath, state, manifest, uploadResult, manifestUpload, status, cleanedAt = ''}) { + const batch = { + batchId: manifest.batchId, + objectKey: uploadResult.objectKey, + contentLength: uploadResult.contentLength, + archiveSha256: uploadResult.archiveSha256, + verifiedAt: uploadResult.verifiedAt, + manifestObjectKey: manifestUpload.objectKey, + manifestContentLength: manifestUpload.contentLength, + manifestArchiveSha256: manifestUpload.archiveSha256, + manifestVerifiedAt: manifestUpload.verifiedAt, + uploadedAt: manifest.uploadedAt, + status, + cleanedAt, + candidates: manifest.candidates, + }; + const batches = state.batches.filter((item) => item.batchId !== batch.batchId); + batches.push(batch); + const nextState = {...state, updatedAt: new Date().toISOString(), batches}; + atomicWriteJson(statePath, nextState); + return nextState; +} + +function candidateKey(candidate) { + return `${candidate.kind}\0${candidate.path}`; +} + +export function cleanupHistoryCandidates({dataDir, candidates}) { + const currentPlan = discoverHistoryPlan({dataDir}); + const eligible = new Map(currentPlan.candidates.map((candidate) => [candidateKey(candidate), candidate])); + const existing = []; + for (const candidate of candidates) { + const absolutePath = resolve(dataDir, candidate.path); + assertSafeRelativePath(dataDir, absolutePath); + if (!existsSync(absolutePath)) { + continue; + } + const current = eligible.get(candidateKey(candidate)); + if (!current) { + throw new Error(`history 候选已不在当前安全边界内,拒绝删除: ${candidate.path}`); + } + const currentStat = statFingerprint(absolutePath); + if (currentStat.fingerprint !== candidate.fingerprint || currentStat.sizeBytes !== candidate.sizeBytes) { + throw new Error(`history 候选 stat 漂移,拒绝删除: ${candidate.path}`); + } + existing.push({candidate, absolutePath}); + } + existing.sort((left, right) => { + const priority = {'commitlog-offset': 0, commitlog: 1, snapshot: 2}; + return (priority[left.candidate.kind] ?? 3) - (priority[right.candidate.kind] ?? 3) + || left.candidate.path.localeCompare(right.candidate.path); + }); + for (const {candidate, absolutePath} of existing) { + rmSync(absolutePath, {recursive: candidate.kind === 'snapshot', force: false}); + console.log(`[database-backup] 已清理 history 源文件: ${candidate.path}`); + } + return {deletedCount: existing.length, alreadyMissingCount: candidates.length - existing.length}; +} + function writeManifest({manifestPath, payload}) { writeFileSync(manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } @@ -470,6 +1007,724 @@ function sha256Hex(content) { return createHash('sha256').update(content).digest('hex'); } +async function sha256FileHex(filePath) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk); + } + return hash.digest('hex'); +} + +function directFilesStatePath({workDir, database}) { + return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`); +} + +function normalizeObjectPrefix(objectPrefix, database) { + const prefix = String(objectPrefix || 'database-backups') + .trim() + .replace(/^\/+|\/+$/gu, '') + .split('/') + .filter(Boolean) + .map((part) => sanitizeObjectPart(part, 'backup')) + .join('/'); + return [prefix, sanitizeObjectPart(database, 'spacetimedb')].filter(Boolean).join('/'); +} + +function directFileIdentity(filePath) { + const stat = lstatSync(filePath, {bigint: true}); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`files 模式只允许普通文件: ${filePath}`); + } + return { + dev: stat.dev.toString(), + ino: stat.ino.toString(), + size: stat.size.toString(), + mtimeNs: stat.mtimeNs.toString(), + mode: Number(stat.mode & 0o7777n), + }; +} + +function sameDirectFileIdentity(left, right) { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mtimeNs === right.mtimeNs + && left.mode === right.mode; +} + +export async function collectDirectFileEntries({dataDir, candidates = null, objectPrefix, database}) { + const resolvedDataDir = resolvePath(dataDir); + if (!existsSync(resolvedDataDir) || !lstatSync(resolvedDataDir).isDirectory()) { + throw new Error(`files 数据目录不存在或不是目录: ${resolvedDataDir}`); + } + const files = new Map(); + const directories = new Set(['.']); + const roots = candidates === null + ? [{absolutePath: resolvedDataDir, relativePath: '.'}] + : candidates.map((candidate) => ({ + absolutePath: resolve(resolvedDataDir, candidate.path), + relativePath: assertSafeRelativePath(resolvedDataDir, resolve(resolvedDataDir, candidate.path)), + })); + + const visit = async (absolutePath, relativePath) => { + const stat = lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + throw new Error(`files 模式拒绝符号链接: ${absolutePath}`); + } + if (stat.isDirectory()) { + directories.add(relativePath); + for (const name of readdirSync(absolutePath).sort()) { + const childRelative = relativePath === '.' ? name : `${relativePath}/${name}`; + await visit(join(absolutePath, name), childRelative); + } + return; + } + if (!stat.isFile()) { + throw new Error(`files 模式只允许普通文件或目录: ${absolutePath}`); + } + const before = directFileIdentity(absolutePath); + const sha256 = await sha256FileHex(absolutePath); + const after = directFileIdentity(absolutePath); + if (!sameDirectFileIdentity(before, after)) { + throw new Error(`files 扫描期间源文件发生变化: ${relativePath}`); + } + const basePrefix = normalizeObjectPrefix(objectPrefix, database); + files.set(relativePath, { + path: relativePath, + sizeBytes: Number(after.size), + sha256, + mode: after.mode, + objectKey: `${basePrefix}/files/sha256/${sha256.slice(0, 2)}/${sha256}`, + sourceStat: after, + }); + }; + + for (const root of roots.sort((left, right) => left.relativePath.localeCompare(right.relativePath))) { + if (!existsSync(root.absolutePath)) { + throw new Error(`files 候选在扫描前消失: ${root.relativePath}`); + } + await visit(root.absolutePath, root.relativePath); + } + return { + directories: [...directories].sort(), + files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)), + }; +} + +function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files}) { + return sha256Hex(JSON.stringify({ + mode, + baselineCatalogId: baselineCatalogId || '', + rootName, + directories, + files: files.map(({path, sizeBytes, sha256, mode, objectKey}) => ({path, sizeBytes, sha256, mode, objectKey})), + })); +} + +function readDirectFilesState(statePath, {database, bucket}) { + if (!existsSync(statePath)) { + return null; + } + const state = readManifest(statePath); + if ( + state.schemaVersion !== DIRECT_FILES_STATE_SCHEMA_VERSION + || state.backupKind !== 'spacetimedb-direct-files-state' + || state.database !== database + || state.bucket !== bucket + ) { + throw new Error(`files state 与本次数据源或 bucket 不匹配: ${statePath}`); + } + return state; +} + +async function ensureDirectObject({ + file, + dataDir, + uploadOptions, + previousFile, + verifyCatalogReuse = false, + uploadFn, + verifyFn, +}) { + const absolutePath = resolve(dataDir, file.path); + assertSafeRelativePath(dataDir, absolutePath); + if (previousFile?.sha256 === file.sha256 + && previousFile?.sizeBytes === file.sizeBytes + && previousFile?.objectKey === file.objectKey) { + if (verifyCatalogReuse) { + await verifyFn({ + ...uploadOptions, + objectKey: file.objectKey, + contentLength: file.sizeBytes, + archiveSha256: file.sha256, + }); + } + return { + status: verifyCatalogReuse ? 'catalog-reused-verified' : 'catalog-reused', + objectKey: file.objectKey, + }; + } + try { + await verifyFn({ + ...uploadOptions, + objectKey: file.objectKey, + contentLength: file.sizeBytes, + archiveSha256: file.sha256, + }); + return {status: 'oss-reused', objectKey: file.objectKey}; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + const beforeUpload = directFileIdentity(absolutePath); + if (!sameDirectFileIdentity(beforeUpload, file.sourceStat)) { + throw new Error(`files 上传前源文件 stat 漂移: ${file.path}`); + } + await uploadFn({ + archivePath: absolutePath, + ...uploadOptions, + objectKey: file.objectKey, + archiveSha256: file.sha256, + backupKind: 'spacetimedb-direct-file', + contentType: 'application/octet-stream', + allowEmpty: true, + }); + const afterUpload = directFileIdentity(absolutePath); + if (!sameDirectFileIdentity(afterUpload, file.sourceStat)) { + throw new Error(`files 上传期间源文件 stat 漂移: ${file.path}`); + } + return {status: 'uploaded', objectKey: file.objectKey}; +} + +async function ensureDirectManifest({manifestPath, objectKey, uploadOptions, uploadManifestFn, verifyFn}) { + const body = readFileSync(manifestPath); + const archiveSha256 = sha256Hex(body); + try { + const verification = await verifyFn({ + ...uploadOptions, + objectKey, + contentLength: body.length, + archiveSha256, + }); + return {objectKey, contentLength: body.length, archiveSha256, verifiedAt: verification.verifiedAt, reused: true}; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + return uploadManifestFn({manifestPath, ...uploadOptions, objectKey}); +} + +function directCatalogRef(catalog) { + return { + mode: catalog.mode, + catalogId: catalog.catalogId, + objectKey: catalog.objectKey, + contentLength: catalog.contentLength, + sha256: catalog.sha256, + verifiedAt: catalog.verifiedAt, + }; +} + +function assertDirectCatalogRef(catalog, expectedMode, label) { + if ( + !catalog + || catalog.mode !== expectedMode + || !/^[a-f0-9]{64}$/u.test(catalog.catalogId) + || typeof catalog.objectKey !== 'string' + || !catalog.objectKey + || !Number.isSafeInteger(catalog.contentLength) + || catalog.contentLength <= 0 + || !/^[a-f0-9]{64}$/u.test(catalog.sha256) + || typeof catalog.verifiedAt !== 'string' + || !catalog.verifiedAt + ) { + throw new Error(`files ${label} catalog ref 无效。`); + } + return directCatalogRef(catalog); +} + +function buildDirectFilesLatest({database, bucket, state}) { + const latestFullCatalog = assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'); + const historyCatalogs = (state?.historyCatalogs ?? []).map((catalog) => ( + assertDirectCatalogRef(catalog, 'history', 'history') + )); + return { + schemaVersion: DIRECT_FILES_LATEST_SCHEMA_VERSION, + backupKind: 'spacetimedb-direct-files-latest', + database, + bucket, + updatedAt: new Date().toISOString(), + latestFullCatalog, + historyCatalogs, + }; +} + +function validateDirectFilesLatest(latest, {database, bucket}) { + if ( + latest?.schemaVersion !== DIRECT_FILES_LATEST_SCHEMA_VERSION + || latest.backupKind !== 'spacetimedb-direct-files-latest' + || latest.database !== database + || latest.bucket !== bucket + || !Array.isArray(latest.historyCatalogs) + ) { + throw new Error('files latest pointer 契约无效。'); + } + return { + ...latest, + latestFullCatalog: assertDirectCatalogRef(latest.latestFullCatalog, 'full', 'latest full'), + historyCatalogs: latest.historyCatalogs.map((catalog) => assertDirectCatalogRef(catalog, 'history', 'history')), + }; +} + +async function publishDirectFilesLatest({ + workDir, + database, + bucket, + objectPrefix, + state, + uploadOptions, + uploadManifestFn, + verifyFn, +}) { + const latest = buildDirectFilesLatest({database, bucket, state}); + for (const catalogRef of [latest.latestFullCatalog, ...latest.historyCatalogs]) { + await verifyFn({ + ...uploadOptions, + objectKey: catalogRef.objectKey, + contentLength: catalogRef.contentLength, + archiveSha256: catalogRef.sha256, + }); + } + const latestPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-latest.json`); + const latestObjectKey = `${normalizeObjectPrefix(objectPrefix, database)}/latest.json`; + writeManifest({manifestPath: latestPath, payload: latest}); + const uploaded = await uploadManifestFn({manifestPath: latestPath, ...uploadOptions, objectKey: latestObjectKey}); + const verification = await verifyFn({ + ...uploadOptions, + objectKey: latestObjectKey, + contentLength: uploaded.contentLength, + archiveSha256: uploaded.archiveSha256, + }); + return { + latest, + latestPath, + latestObjectKey, + contentLength: uploaded.contentLength, + sha256: uploaded.archiveSha256, + verifiedAt: verification.verifiedAt, + }; +} + +export async function runDirectFilesBackup({ + mode, + dataDir, + workDir, + database, + bucket, + objectPrefix, + dryRun = false, + resultFile = '', + uploadOptions, + uploadFn = uploadArchive, + uploadManifestFn = uploadManifestFile, + verifyFn = verifyOssObject, +}) { + mkdirSync(workDir, {recursive: true}); + const statePath = directFilesStatePath({workDir, database}); + const state = readDirectFilesState(statePath, {database, bucket}); + if (mode === 'history' && (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full')) { + throw new Error(`files history 模式缺少已发布 full baseline catalog: ${statePath}`); + } + const plan = mode === 'history' ? discoverHistoryPlan({dataDir}) : null; + const collected = await collectDirectFileEntries({ + dataDir, + candidates: plan?.candidates ?? null, + objectPrefix, + database, + }); + const baselineCatalogId = mode === 'history' ? (state?.baselineCatalog?.catalogId ?? '') : ''; + const rootName = basename(dataDir); + const catalogId = directCatalogIdentity({mode, baselineCatalogId, rootName, ...collected}); + const basePrefix = normalizeObjectPrefix(objectPrefix, database); + const catalogObjectKey = `${basePrefix}/catalogs/${mode}/${catalogId}.json`; + const catalogPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-${mode}-${catalogId}.catalog.json`); + const catalog = { + schemaVersion: DIRECT_FILES_CATALOG_SCHEMA_VERSION, + backupKind: mode === 'full' ? 'spacetimedb-data-dir-files' : 'spacetimedb-history-files', + database, + bucket, + mode, + catalogId, + catalogObjectKey, + baselineCatalogId, + rootName, + directories: collected.directories, + files: collected.files.map(({sourceStat: _sourceStat, ...file}) => file), + }; + writeManifest({manifestPath: catalogPath, payload: catalog}); + const summary = { + statePath, + catalogPath, + catalogObjectKey, + catalogId, + fileCount: collected.files.length, + totalSizeBytes: collected.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(), + candidateCount: plan?.candidates.length ?? 0, + }; + if (resultFile) { + atomicWriteJson(resolvePath(resultFile), {...summary, dryRun}); + } + console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`); + if (dryRun) { + console.log('[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。'); + return {...summary, catalog, uploadedCount: 0, reusedCount: 0}; + } + if (mode === 'history' && plan.candidates.length === 0) { + await verifyFn({ + ...uploadOptions, + objectKey: state.latestCatalog.objectKey, + contentLength: state.latestCatalog.contentLength, + archiveSha256: state.latestCatalog.sha256, + }); + const latestPointer = await publishDirectFilesLatest({ + workDir, + database, + bucket, + objectPrefix, + state, + uploadOptions, + uploadManifestFn, + verifyFn, + }); + console.log('[database-backup] files history 没有可归档候选。'); + const emptyResult = {...summary, catalog, latestPointer, uploadedCount: 0, reusedCount: 0, cleanup: null}; + if (resultFile) { + atomicWriteJson(resolvePath(resultFile), emptyResult); + } + return emptyResult; + } + + if (state?.latestCatalog?.catalogId === catalogId && state.latestCatalog.mode === mode) { + await verifyFn({...uploadOptions, objectKey: state.latestCatalog.objectKey, contentLength: state.latestCatalog.contentLength, archiveSha256: state.latestCatalog.sha256}); + if (mode === 'full') { + const latestPointer = await publishDirectFilesLatest({ + workDir, + database, + bucket, + objectPrefix, + state, + uploadOptions, + uploadManifestFn, + verifyFn, + }); + console.log('[database-backup] files catalog 未变化,无文件需要上传。'); + return {...summary, catalog, latestPointer, uploadedCount: 0, reusedCount: collected.files.length, unchanged: true}; + } + } + + if (state?.latestCatalog) { + await verifyFn({ + ...uploadOptions, + objectKey: state.latestCatalog.objectKey, + contentLength: state.latestCatalog.contentLength, + archiveSha256: state.latestCatalog.sha256, + }); + } + + const previousFiles = new Map((state?.latestCatalog?.files ?? []).map((file) => [file.path, file])); + let uploadedCount = 0; + let reusedCount = 0; + for (const [index, file] of collected.files.entries()) { + const result = await ensureDirectObject({ + file, + dataDir, + uploadOptions, + previousFile: previousFiles.get(file.path), + verifyCatalogReuse: mode === 'history', + uploadFn, + verifyFn, + }); + if (result.status === 'uploaded') { + uploadedCount += 1; + } else { + reusedCount += 1; + } + console.log(`[database-backup] files 进度: ${index + 1}/${collected.files.length} (${result.status}) ${file.path}`); + } + const catalogUpload = await ensureDirectManifest({ + manifestPath: catalogPath, + objectKey: catalogObjectKey, + uploadOptions, + uploadManifestFn, + verifyFn, + }); + await verifyFn({...uploadOptions, objectKey: catalogObjectKey, contentLength: catalogUpload.contentLength, archiveSha256: catalogUpload.archiveSha256}); + + if (mode === 'history') { + await verifyFn({ + ...uploadOptions, + objectKey: state.baselineCatalog.objectKey, + contentLength: state.baselineCatalog.contentLength, + archiveSha256: state.baselineCatalog.sha256, + }); + } + const catalogRef = { + mode, + catalogId, + objectKey: catalogObjectKey, + contentLength: catalogUpload.contentLength, + sha256: catalogUpload.archiveSha256, + verifiedAt: catalogUpload.verifiedAt, + files: catalog.files, + }; + const nextState = { + schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION, + backupKind: 'spacetimedb-direct-files-state', + database, + dataDir, + bucket, + updatedAt: new Date().toISOString(), + baselineCatalog: state?.baselineCatalog ?? catalogRef, + latestCatalog: mode === 'full' ? catalogRef : state.latestCatalog, + historyCatalogs: mode === 'history' + ? [...(state.historyCatalogs ?? []).filter((item) => item.catalogId !== catalogId), catalogRef] + : (state?.historyCatalogs ?? []), + }; + const latestPointer = await publishDirectFilesLatest({ + workDir, + database, + bucket, + objectPrefix, + state: nextState, + uploadOptions, + uploadManifestFn, + verifyFn, + }); + atomicWriteJson(statePath, nextState); + let cleanup = null; + if (mode === 'history') { + cleanup = cleanupHistoryCandidates({dataDir, candidates: plan.candidates}); + } + const finalResult = {...summary, catalog, latestPointer, uploadedCount, reusedCount, cleanup}; + if (resultFile) { + atomicWriteJson(resolvePath(resultFile), finalResult); + } + return finalResult; +} + +async function downloadOssBuffer({objectKey, uploadOptions}) { + const response = await signedOssRequest({ + ...ossRequestDefaults(uploadOptions), + method: 'GET', + objectKey, + operation: '下载对象', + }); + return Buffer.from(await response.arrayBuffer()); +} + +async function downloadOssFile({objectKey, destinationPath, uploadOptions}) { + const response = await signedOssRequest({ + ...ossRequestDefaults(uploadOptions), + method: 'GET', + objectKey, + operation: '下载对象', + }); + const tempPath = `${destinationPath}.partial-${process.pid}`; + rmSync(tempPath, {force: true}); + try { + if (response.body) { + await pipeline(Readable.fromWeb(response.body), createWriteStream(tempPath, {mode: 0o600})); + } else { + writeFileSync(tempPath, Buffer.alloc(0), {mode: 0o600}); + } + renameSync(tempPath, destinationPath); + } catch (error) { + rmSync(tempPath, {force: true}); + throw error; + } +} + +async function loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}) { + const catalogBody = await downloadBufferFn({objectKey: catalogRef.objectKey, uploadOptions}); + if (catalogBody.length !== catalogRef.contentLength || sha256Hex(catalogBody) !== catalogRef.sha256) { + throw new Error(`files restore catalog 长度或 SHA-256 不一致: ${catalogRef.objectKey}`); + } + const catalog = JSON.parse(catalogBody.toString('utf8')); + if ( + catalog.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION + || catalog.backupKind !== 'spacetimedb-data-dir-files' + || catalog.database !== database + || catalog.bucket !== bucket + || catalog.catalogId !== catalogRef.catalogId + || !Array.isArray(catalog.directories) + || !Array.isArray(catalog.files) + ) { + throw new Error(`files restore catalog 契约无效: ${catalogRef.objectKey}`); + } + return catalog; +} + +function assertDirectCatalogFile(file, index) { + if ( + !file + || typeof file.path !== 'string' + || !Number.isSafeInteger(file.sizeBytes) + || file.sizeBytes < 0 + || !/^[a-f0-9]{64}$/u.test(file.sha256) + || typeof file.objectKey !== 'string' + || !file.objectKey + || !Number.isSafeInteger(file.mode) + ) { + throw new Error(`files restore catalog 文件项无效: index=${index}`); + } +} + +async function restoreDirectFilesCatalog({ + catalog, + restoreDir, + uploadOptions, + resultFile = '', + dryRun = false, + downloadFileFn = downloadOssFile, +}) { + const resolvedRestoreDir = resolvePath(restoreDir); + catalog.files.forEach(assertDirectCatalogFile); + const totalSizeBytes = catalog.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(); + if (dryRun) { + const result = { + restoreDir: resolvedRestoreDir, + catalogId: catalog.catalogId, + fileCount: catalog.files.length, + totalSizeBytes, + downloadedCount: 0, + reusedCount: 0, + dryRun: true, + }; + if (resultFile) { + atomicWriteJson(resolvePath(resultFile), result); + } + return result; + } + mkdirSync(resolvedRestoreDir, {recursive: true, mode: 0o700}); + for (const directoryPath of catalog.directories) { + if (directoryPath === '.') { + continue; + } + const absolutePath = resolve(resolvedRestoreDir, directoryPath); + assertSafeRelativePath(resolvedRestoreDir, absolutePath); + mkdirSync(absolutePath, {recursive: true}); + } + + let downloadedCount = 0; + let reusedCount = 0; + for (const [index, file] of catalog.files.entries()) { + const destinationPath = resolve(resolvedRestoreDir, file.path); + assertSafeRelativePath(resolvedRestoreDir, destinationPath); + mkdirSync(dirname(destinationPath), {recursive: true}); + let reusable = false; + if (existsSync(destinationPath) && lstatSync(destinationPath).isFile()) { + const stat = statSync(destinationPath); + reusable = stat.size === file.sizeBytes && await sha256FileHex(destinationPath) === file.sha256; + } + if (reusable) { + reusedCount += 1; + } else { + rmSync(destinationPath, {force: true}); + await downloadFileFn({objectKey: file.objectKey, destinationPath, uploadOptions}); + const stat = statSync(destinationPath); + const sha256 = await sha256FileHex(destinationPath); + if (stat.size !== file.sizeBytes || sha256 !== file.sha256) { + rmSync(destinationPath, {force: true}); + throw new Error(`files restore 对象长度或 SHA-256 不一致: ${file.path}`); + } + downloadedCount += 1; + } + chmodSync(destinationPath, file.mode & 0o7777); + console.log(`[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`); + } + const result = { + restoreDir: resolvedRestoreDir, + catalogId: catalog.catalogId, + fileCount: catalog.files.length, + totalSizeBytes, + downloadedCount, + reusedCount, + }; + if (resultFile) { + atomicWriteJson(resolvePath(resultFile), result); + } + return result; +} + +export async function restoreDirectFilesBackup({ + statePath, + restoreDir, + database, + bucket, + uploadOptions, + resultFile = '', + dryRun = false, + downloadBufferFn = downloadOssBuffer, + downloadFileFn = downloadOssFile, +}) { + const state = readDirectFilesState(resolvePath(statePath), {database, bucket}); + if (!state?.latestCatalog || state.latestCatalog.mode !== 'full') { + throw new Error(`files restore 缺少 full baseline catalog: ${statePath}`); + } + const catalogRef = assertDirectCatalogRef(state.latestCatalog, 'full', 'latest full'); + const catalog = await loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}); + return restoreDirectFilesCatalog({ + catalog, + restoreDir, + uploadOptions, + resultFile, + dryRun, + downloadFileFn, + }); +} + +export async function restoreDirectFilesLatest({ + restoreDir, + database, + bucket, + objectPrefix, + uploadOptions, + resultFile = '', + dryRun = false, + downloadBufferFn = downloadOssBuffer, + downloadFileFn = downloadOssFile, + verifyFn = verifyOssObject, +}) { + const latestObjectKey = `${normalizeObjectPrefix(objectPrefix, database)}/latest.json`; + const latestBody = await downloadBufferFn({objectKey: latestObjectKey, uploadOptions}); + const latestSha256 = sha256Hex(latestBody); + await verifyFn({ + ...uploadOptions, + objectKey: latestObjectKey, + contentLength: latestBody.length, + archiveSha256: latestSha256, + }); + const latest = validateDirectFilesLatest(JSON.parse(latestBody.toString('utf8')), {database, bucket}); + const catalogRef = latest.latestFullCatalog; + await verifyFn({ + ...uploadOptions, + objectKey: catalogRef.objectKey, + contentLength: catalogRef.contentLength, + archiveSha256: catalogRef.sha256, + }); + const catalog = await loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}); + return restoreDirectFilesCatalog({ + catalog, + restoreDir, + uploadOptions, + resultFile, + dryRun, + downloadFileFn, + }); +} + function regionFromEndpoint(endpoint) { const match = /^oss-([a-z0-9-]+)\./u.exec(endpoint); if (!match) { @@ -563,6 +1818,34 @@ function retryDelayMs({attempt, baseDelayMs, maxDelayMs, randomFn}) { return Math.floor(randomFn() * ceiling); } +function ossRequestDefaults({ + bucket, + endpoint, + accessKeyId, + accessKeySecret, + fetchImpl = globalThis.fetch, + nowFn = () => new Date(), + sleepImpl = sleep, + randomFn = Math.random, + maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS, + retryBaseDelayMs = DEFAULT_OSS_RETRY_BASE_DELAY_MS, + retryMaxDelayMs = DEFAULT_OSS_RETRY_MAX_DELAY_MS, +}) { + return { + bucket, + endpoint, + accessKeyId, + accessKeySecret, + fetchImpl, + nowFn, + sleepImpl, + randomFn, + maxAttempts, + retryBaseDelayMs, + retryMaxDelayMs, + }; +} + async function signedOssRequest({ method, bucket, @@ -694,21 +1977,70 @@ function resolveMultipartPartSize(fileSize, configuredPartSize) { return partSize; } -async function verifyUploadedObject({requestOptions, expectedContentLength}) { +async function verifyUploadedObject({requestOptions, expectedContentLength, expectedArchiveSha256}) { const response = await signedOssRequest({ ...requestOptions, method: 'HEAD', operation: 'HEAD 验证', }); const contentLengthHeader = response.headers.get('content-length'); - if (!contentLengthHeader || !/^\d+$/u.test(contentLengthHeader)) { - throw new Error(`OSS HEAD 验证缺少有效 content-length: ${contentLengthHeader ?? ''}`); + const metadataLengthHeader = response.headers.get('x-oss-meta-file-size'); + const effectiveLengthHeader = /^\d+$/u.test(contentLengthHeader ?? '') + ? contentLengthHeader + : metadataLengthHeader; + if (!effectiveLengthHeader || !/^\d+$/u.test(effectiveLengthHeader)) { + throw new Error( + `OSS HEAD 验证缺少有效 content-length/file-size: content-length=${contentLengthHeader ?? ''}, file-size=${metadataLengthHeader ?? ''}`, + ); } - const remoteContentLength = Number(contentLengthHeader); + const remoteContentLength = Number(effectiveLengthHeader); if (remoteContentLength !== expectedContentLength) { throw new Error(`OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`); } - return {verifiedAt: new Date().toISOString(), remoteContentLength}; + const remoteArchiveSha256 = String( + response.headers.get('x-oss-meta-file-sha256') + ?? response.headers.get('x-oss-meta-archive-sha256') + ?? '', + ).trim().toLowerCase(); + if (remoteArchiveSha256 !== expectedArchiveSha256) { + throw new Error(`OSS HEAD 验证 SHA-256 不一致: local=${expectedArchiveSha256}, remote=${remoteArchiveSha256 || ''}`); + } + return {verifiedAt: new Date().toISOString(), remoteContentLength, remoteArchiveSha256}; +} + +export async function verifyOssObject({ + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + contentLength, + archiveSha256, + fetchImpl = globalThis.fetch, + nowFn = () => new Date(), + sleepImpl = sleep, + randomFn = Math.random, + maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS, +}) { + const requestOptions = { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + fetchImpl, + nowFn, + sleepImpl, + randomFn, + maxAttempts, + retryBaseDelayMs: DEFAULT_OSS_RETRY_BASE_DELAY_MS, + retryMaxDelayMs: DEFAULT_OSS_RETRY_MAX_DELAY_MS, + }; + return verifyUploadedObject({ + requestOptions, + expectedContentLength: Number(contentLength), + expectedArchiveSha256: String(archiveSha256 ?? '').trim().toLowerCase(), + }); } async function abortMultipartUpload({requestOptions, uploadId}) { @@ -741,13 +2073,19 @@ export async function uploadArchive({ nowFn = () => new Date(), sleepImpl = sleep, randomFn = Math.random, + backupKind = 'spacetimedb-data-dir', + archiveSha256 = '', + contentType = 'application/gzip', + allowEmpty = false, }) { const fileStat = statSync(archivePath); - if (!fileStat.isFile() || fileStat.size <= 0) { - throw new Error(`待上传备份必须是非空文件: ${archivePath}`); + if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) { + throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`); + } + const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath); + if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) { + throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`); } - const partSize = resolveMultipartPartSize(fileStat.size, partSizeBytes); - const partCount = Math.ceil(fileStat.size / partSize); const requestOptions = { bucket, endpoint, @@ -762,6 +2100,26 @@ export async function uploadArchive({ retryBaseDelayMs, retryMaxDelayMs, }; + if (fileStat.size === 0) { + await signedOssRequest({ + ...requestOptions, + method: 'PUT', + headers: { + 'content-type': contentType, + 'x-oss-meta-archive-sha256': verifiedArchiveSha256, + 'x-oss-meta-file-sha256': verifiedArchiveSha256, + 'x-oss-meta-file-size': '0', + 'x-oss-meta-backup-kind': backupKind, + }, + contentLength: 0, + bodyFactory: () => Buffer.alloc(0), + operation: '上传空文件', + }); + const verification = await verifyUploadedObject({requestOptions, expectedContentLength: 0, expectedArchiveSha256: verifiedArchiveSha256}); + return {bucket, objectKey, contentLength: 0, archiveSha256: verifiedArchiveSha256, etag: '', uploadMode: 'single', partCount: 1, partSizeBytes: 0, verifiedAt: verification.verifiedAt}; + } + const partSize = resolveMultipartPartSize(fileStat.size, partSizeBytes); + const partCount = Math.ceil(fileStat.size / partSize); let uploadId = ''; let uploadCompleted = false; @@ -772,8 +2130,11 @@ export async function uploadArchive({ method: 'POST', queries: {uploads: null}, headers: { - 'content-type': 'application/gzip', - 'x-oss-meta-backup-kind': 'spacetimedb-data-dir', + 'content-type': contentType, + 'x-oss-meta-archive-sha256': verifiedArchiveSha256, + 'x-oss-meta-file-sha256': verifiedArchiveSha256, + 'x-oss-meta-file-size': String(fileStat.size), + 'x-oss-meta-backup-kind': backupKind, }, operation: 'InitiateMultipartUpload', }); @@ -822,19 +2183,24 @@ export async function uploadArchive({ } } catch (completeError) { try { - await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size}); + await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size, expectedArchiveSha256: verifiedArchiveSha256}); completeResponse = null; } catch { throw completeError; } } - const verification = await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size}); + const verification = await verifyUploadedObject({ + requestOptions, + expectedContentLength: fileStat.size, + expectedArchiveSha256: verifiedArchiveSha256, + }); uploadCompleted = true; return { bucket, objectKey, contentLength: fileStat.size, + archiveSha256: verifiedArchiveSha256, etag: completeResponse?.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '', uploadMode: 'multipart', partCount, @@ -849,6 +2215,149 @@ export async function uploadArchive({ } } +export async function uploadManifestFile({ + manifestPath, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + fetchImpl = globalThis.fetch, + nowFn = () => new Date(), + sleepImpl = sleep, + randomFn = Math.random, + maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS, +}) { + const body = readFileSync(manifestPath); + if (body.length === 0) { + throw new Error(`待上传 manifest 不能为空: ${manifestPath}`); + } + const archiveSha256 = sha256Hex(body); + const requestOptions = { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + fetchImpl, + nowFn, + sleepImpl, + randomFn, + maxAttempts, + retryBaseDelayMs: DEFAULT_OSS_RETRY_BASE_DELAY_MS, + retryMaxDelayMs: DEFAULT_OSS_RETRY_MAX_DELAY_MS, + }; + await signedOssRequest({ + ...requestOptions, + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'x-oss-meta-archive-sha256': archiveSha256, + 'x-oss-meta-file-size': String(body.length), + 'x-oss-meta-backup-kind': 'spacetimedb-backup-manifest', + }, + contentLength: body.length, + bodyFactory: () => body, + operation: '上传 manifest', + }); + const verification = await verifyUploadedObject({ + requestOptions, + expectedContentLength: body.length, + expectedArchiveSha256: archiveSha256, + }); + return {objectKey, contentLength: body.length, archiveSha256, verifiedAt: verification.verifiedAt}; +} + +function uploadedManifestPayload({manifest, database, result}) { + return { + ...manifest, + database, + bucket: result.bucket, + objectKey: result.objectKey, + manifestObjectKey: `${result.objectKey}.manifest.json`, + contentLength: result.contentLength, + archiveSha256: result.archiveSha256, + etag: result.etag, + uploadMode: result.uploadMode, + partCount: result.partCount, + partSizeBytes: result.partSizeBytes, + verifiedAt: result.verifiedAt, + uploadedAt: new Date().toISOString(), + uploadStatus: 'uploaded', + }; +} + +export async function uploadHistoryArchiveWithCleanup({ + archivePath, + manifestPath, + manifest, + statePath, + uploadOptions, + uploadFn = uploadArchive, + manifestUploadFn = uploadManifestFile, + verifyFn = verifyOssObject, +}) { + const result = await uploadFn({ + archivePath, + ...uploadOptions, + backupKind: 'spacetimedb-history', + }); + const uploadedManifest = uploadedManifestPayload({manifest, database: manifest.database, result}); + writeManifest({manifestPath, payload: uploadedManifest}); + const manifestUpload = await manifestUploadFn({ + manifestPath, + ...uploadOptions, + objectKey: uploadedManifest.manifestObjectKey, + }); + uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; + uploadedManifest.manifestContentLength = manifestUpload.contentLength; + uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; + writeManifest({manifestPath, payload: uploadedManifest}); + let state = validateHistoryState(readManifest(statePath), { + database: uploadedManifest.database, + dataDir: uploadedManifest.dataDir, + }); + if (state.baseline.id !== uploadedManifest.baselineId) { + throw new Error(`history manifest baselineId 与 state 不匹配: manifest=${uploadedManifest.baselineId}, state=${state.baseline.id}`); + } + await verifyFn({ + ...uploadOptions, + bucket: state.baseline.bucket, + objectKey: state.baseline.objectKey, + contentLength: state.baseline.contentLength, + archiveSha256: state.baseline.archiveSha256, + }); + await verifyFn({ + ...uploadOptions, + bucket: state.baseline.bucket, + objectKey: state.baseline.manifestObjectKey, + contentLength: state.baseline.manifestContentLength, + archiveSha256: state.baseline.manifestArchiveSha256, + }); + state = recordHistoryBatch({ + statePath, + state, + manifest: uploadedManifest, + uploadResult: result, + manifestUpload, + status: 'uploaded', + }); + const cleanup = cleanupHistoryCandidates({ + dataDir: uploadedManifest.dataDir, + candidates: uploadedManifest.candidates, + }); + state = recordHistoryBatch({ + statePath, + state, + manifest: uploadedManifest, + uploadResult: result, + manifestUpload, + status: 'cleaned', + cleanedAt: new Date().toISOString(), + }); + return {result, uploadedManifest, cleanup, state}; +} + async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}) { const archivePath = resolvePath(args.uploadArchive); if (!existsSync(archivePath)) { @@ -860,6 +2369,13 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, const dataDir = firstNonEmpty(manifest.dataDir, env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, DEFAULT_PRODUCTION_DATA_DIR); const database = firstNonEmpty(args.database, manifest.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); const objectKey = firstNonEmpty(args.objectKey, manifest.objectKey, buildBackupNames({database, dataDir, objectPrefix}).objectKey); + if (manifest.backupKind !== 'spacetimedb-history') { + manifest.backupKind = 'spacetimedb-data-dir'; + manifest.baselineStatePath = firstNonEmpty( + manifest.baselineStatePath, + historyStatePath({args, env, workDir: dirname(archivePath), database}), + ); + } console.log(`[database-backup] 上传已有备份: ${archivePath}`); console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`); @@ -869,30 +2385,51 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, return; } - const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}); + const statePath = resolvePath(firstNonEmpty( + manifest.baselineStatePath, + historyStatePath({args, env, workDir: dirname(archivePath), database}), + )); + let result; + let uploadedAt; + if (manifest.backupKind === 'spacetimedb-history') { + const historyResult = await uploadHistoryArchiveWithCleanup({ + archivePath, + manifestPath, + manifest, + statePath, + uploadOptions: {bucket, endpoint, objectKey, accessKeyId, accessKeySecret}, + }); + result = historyResult.result; + uploadedAt = historyResult.uploadedManifest.uploadedAt; + console.log(`[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`); + } else { + result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}); + const uploadedManifest = uploadedManifestPayload({manifest, database, result}); + uploadedAt = uploadedManifest.uploadedAt; + writeManifest({manifestPath, payload: uploadedManifest}); + const manifestUpload = await uploadManifestFile({ + manifestPath, + bucket, + endpoint, + objectKey: uploadedManifest.manifestObjectKey, + accessKeyId, + accessKeySecret, + }); + uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; + uploadedManifest.manifestContentLength = manifestUpload.contentLength; + uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; + writeManifest({manifestPath, payload: uploadedManifest}); + const previousState = existsSync(statePath) + ? validateHistoryState(readManifest(statePath), {database, dataDir}) + : null; + const baseline = normalizeUploadedBaselineManifest(uploadedManifest, {database, dataDir}); + writeBaselineState({statePath, baseline, previousState}); + console.log(`[database-backup] 已写入 baseline state: ${statePath}`); + } console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); - const uploadedAt = new Date().toISOString(); - writeManifest({ - manifestPath, - payload: { - ...manifest, - database, - bucket: result.bucket, - objectKey: result.objectKey, - contentLength: result.contentLength, - etag: result.etag, - uploadMode: result.uploadMode, - partCount: result.partCount, - partSizeBytes: result.partSizeBytes, - verifiedAt: result.verifiedAt, - uploadedAt, - uploadStatus: 'uploaded', - }, - }); - if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, ...result, uploadedAt}, null, 2)}\n`, 'utf8'); + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, statePath, ...result, uploadedAt}, null, 2)}\n`, 'utf8'); } const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; @@ -906,6 +2443,204 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, } } +async function publishExistingManifest({args, bucket, endpoint, accessKeyId, accessKeySecret}) { + const manifestPath = resolvePath(args.publishManifest); + const manifest = readManifest(manifestPath); + if (manifest.uploadStatus !== 'uploaded' || !manifest.objectKey) { + throw new Error('只允许发布 uploadStatus=uploaded 且包含 objectKey 的备份 manifest。'); + } + manifest.manifestObjectKey = manifest.manifestObjectKey || `${manifest.objectKey}.manifest.json`; + writeManifest({manifestPath, payload: manifest}); + const result = await uploadManifestFile({ + manifestPath, + bucket, + endpoint, + objectKey: manifest.manifestObjectKey, + accessKeyId, + accessKeySecret, + }); + manifest.manifestVerifiedAt = result.verifiedAt; + manifest.manifestContentLength = result.contentLength; + manifest.manifestArchiveSha256 = result.archiveSha256; + writeManifest({manifestPath, payload: manifest}); + console.log(`[database-backup] manifest 上传并验真完成: ${JSON.stringify(result)}`); +} + +export async function resumeUploadedHistoryBatch({statePath, state, dataDir, verificationOptions, verifyFn = verifyOssObject}) { + const pendingBatch = state.batches.find((batch) => batch.status === 'uploaded'); + if (!pendingBatch) { + return state; + } + console.log(`[database-backup] 重试已上传 history 批次的本地清理: ${pendingBatch.batchId}`); + await verifyFn({ + ...verificationOptions, + objectKey: pendingBatch.objectKey, + contentLength: pendingBatch.contentLength, + archiveSha256: pendingBatch.archiveSha256, + }); + await verifyFn({ + ...verificationOptions, + objectKey: pendingBatch.manifestObjectKey, + contentLength: pendingBatch.manifestContentLength, + archiveSha256: pendingBatch.manifestArchiveSha256, + }); + const cleanup = cleanupHistoryCandidates({dataDir, candidates: pendingBatch.candidates}); + const manifest = { + batchId: pendingBatch.batchId, + uploadedAt: pendingBatch.uploadedAt, + candidates: pendingBatch.candidates, + }; + const uploadResult = { + objectKey: pendingBatch.objectKey, + contentLength: pendingBatch.contentLength, + archiveSha256: pendingBatch.archiveSha256, + verifiedAt: pendingBatch.verifiedAt, + }; + const manifestUpload = { + objectKey: pendingBatch.manifestObjectKey, + contentLength: pendingBatch.manifestContentLength, + archiveSha256: pendingBatch.manifestArchiveSha256, + verifiedAt: pendingBatch.manifestVerifiedAt, + }; + const nextState = recordHistoryBatch({ + statePath, + state, + manifest, + uploadResult, + manifestUpload, + status: 'cleaned', + cleanedAt: new Date().toISOString(), + }); + console.log(`[database-backup] 已完成 history 清理重试: ${JSON.stringify(cleanup)}`); + return nextState; +} + +async function runHistoryBackup({ + args, + env, + dataDir, + workDir, + database, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + objectPrefix, + keepLocal, +}) { + const statePath = historyStatePath({args, env, workDir, database}); + let state = loadOrImportHistoryState({args, env, statePath, database, dataDir}); + if (!args.dryRun && !args.deferUpload) { + console.log(`[database-backup] 重新验真 full baseline: oss://${state.baseline.bucket}/${state.baseline.objectKey}`); + await verifyOssObject({ + bucket: state.baseline.bucket, + endpoint, + objectKey: state.baseline.objectKey, + accessKeyId, + accessKeySecret, + contentLength: state.baseline.contentLength, + archiveSha256: state.baseline.archiveSha256, + }); + await verifyOssObject({ + bucket: state.baseline.bucket, + endpoint, + objectKey: state.baseline.manifestObjectKey, + accessKeyId, + accessKeySecret, + contentLength: state.baseline.manifestContentLength, + archiveSha256: state.baseline.manifestArchiveSha256, + }); + state = await resumeUploadedHistoryBatch({ + statePath, + state, + dataDir, + verificationOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + }); + } + const plan = discoverHistoryPlan({dataDir}); + console.log(`[database-backup] history replicas: ${JSON.stringify(plan.replicas)}`); + console.log(`[database-backup] history 候选: count=${plan.candidates.length}, size=${formatBytes(plan.totalSizeBytes)}`); + if (args.resultFile) { + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({statePath, baseline: state.baseline, ...plan}, null, 2)}\n`, 'utf8'); + } + if (args.dryRun) { + console.log('[database-backup] history dry-run,仅输出安全候选,不打包、上传或删除。'); + return; + } + if (plan.candidates.length === 0) { + console.log('[database-backup] 没有可归档的 history 候选。'); + return; + } + + assertSufficientHistoryWorkDirSpace({historySizeBytes: plan.totalSizeBytes, workDir, args, env}); + const batchId = historyBatchId({baselineId: state.baseline.id, plan}); + const {fileName, objectKey} = buildHistoryNames({ + database, + objectPrefix, + baselineId: state.baseline.id, + batchId, + }); + const archivePath = resolve(workDir, fileName); + const manifestPath = `${archivePath}.manifest.json`; + const manifest = { + schemaVersion: HISTORY_MANIFEST_SCHEMA_VERSION, + backupKind: 'spacetimedb-history', + createdAt: new Date().toISOString(), + database, + dataDir, + bucket, + objectKey, + archivePath, + baselineId: state.baseline.id, + baselineStatePath: statePath, + batchId, + replicas: plan.replicas, + candidates: plan.candidates, + totalSizeBytes: plan.totalSizeBytes, + uploadStatus: args.deferUpload ? 'deferred' : 'pending', + }; + writeManifest({manifestPath, payload: manifest}); + createHistoryArchive({ + dataDir, + workDir, + fileName, + manifestPath, + candidates: plan.candidates, + }); + + if (args.deferUpload) { + console.log(`[database-backup] 已生成 history 归档,延后上传且未清理源文件: ${archivePath}`); + if (args.resultFile) { + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, statePath, bucket, objectKey, batchId}, null, 2)}\n`, 'utf8'); + } + return; + } + + const historyResult = await uploadHistoryArchiveWithCleanup({ + archivePath, + manifestPath, + manifest, + statePath, + uploadOptions: {bucket, endpoint, objectKey, accessKeyId, accessKeySecret}, + }); + console.log(`[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`); + if (args.resultFile) { + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({ + archivePath, + manifestPath, + statePath, + batchId, + ...historyResult.result, + uploadedAt: historyResult.uploadedManifest.uploadedAt, + }, null, 2)}\n`, 'utf8'); + } + if (!keepLocal) { + rmSync(archivePath, {force: true}); + rmSync(manifestPath, {force: true}); + console.log('[database-backup] 已删除本地 history 临时归档和清单。'); + } +} + async function main() { const args = parseArgs(process.argv.slice(2)); const env = loadEffectiveEnv(args.envFiles); @@ -927,6 +2662,14 @@ async function main() { const objectPrefix = firstNonEmpty(args.objectPrefix, env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, 'database-backups'); const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; + const storageFormat = firstNonEmpty(args.storageFormat, env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, 'archive'); + + if (!['full', 'history'].includes(args.mode)) { + throw new Error(`--mode 只能是 full 或 history,实际: ${args.mode}`); + } + if (!['archive', 'files'].includes(storageFormat)) { + throw new Error(`--storage-format 只能是 archive 或 files,实际: ${storageFormat}`); + } for (const [label, value] of Object.entries({bucket, endpoint, accessKeyId, accessKeySecret})) { if (!value) { @@ -934,11 +2677,124 @@ async function main() { } } + if (args.restoreFilesState && args.restoreFilesLatest) { + throw new Error('--restore-files-state 与 --restore-files-latest 不能同时使用。'); + } + if (args.restoreFilesState) { + if (!args.restoreDir) { + throw new Error('--restore-files-state 必须同时传 --restore-dir。'); + } + await restoreDirectFilesBackup({ + statePath: args.restoreFilesState, + restoreDir: args.restoreDir, + database, + bucket, + uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + resultFile: args.resultFile, + dryRun: args.dryRun, + }); + return; + } + if (args.restoreFilesLatest) { + if (!args.restoreDir) { + throw new Error('--restore-files-latest 必须同时传 --restore-dir。'); + } + await restoreDirectFilesLatest({ + restoreDir: args.restoreDir, + database, + bucket, + objectPrefix, + uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + resultFile: args.resultFile, + dryRun: args.dryRun, + }); + return; + } + if (args.restoreDir) { + throw new Error('--restore-dir 只能与 --restore-files-state 或 --restore-files-latest 一起使用。'); + } + + if (!args.dryRun) { + const lockPath = acquireBackupLock({workDir, database}); + console.log(`[database-backup] 已获取进程锁: ${lockPath}`); + } + + if (args.publishManifest) { + await publishExistingManifest({args, bucket, endpoint, accessKeyId, accessKeySecret}); + return; + } + if (args.uploadArchive) { await uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}); return; } + if (storageFormat === 'files') { + if (args.deferUpload) { + throw new Error('files 模式无需本地归档且不支持 --defer-upload;失败后使用同一 work-dir 重跑即可续传。'); + } + const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); + const restartServicesAfter = collectRestartServicesAfterBackup({args, env}); + let serviceStopped = false; + let backupError = null; + let restoreError = null; + try { + if (args.mode === 'full' && !args.dryRun) { + serviceStopped = stopServiceIfNeeded(stopService); + } + await runDirectFilesBackup({ + mode: args.mode, + dataDir, + workDir, + database, + bucket, + objectPrefix, + dryRun: args.dryRun, + resultFile: args.resultFile, + uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + }); + } catch (error) { + backupError = error; + } finally { + try { + if (serviceStopped) { + restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter}); + } else if (!backupError && args.mode === 'full' && !args.dryRun) { + restartServicesAfterBackup(restartServicesAfter); + } + } catch (error) { + restoreError = error; + } + } + if (backupError && restoreError) { + throw new AggregateError([backupError, restoreError], `files 备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`); + } + if (backupError) { + throw backupError; + } + if (restoreError) { + throw restoreError; + } + return; + } + + if (args.mode === 'history') { + await runHistoryBackup({ + args, + env, + dataDir, + workDir, + database, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + objectPrefix, + keepLocal, + }); + return; + } + const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix}); console.log(`[database-backup] 数据目录: ${dataDir}`); console.log(`[database-backup] 本地临时目录: ${workDir}`); @@ -983,50 +2839,54 @@ async function main() { } const manifestPath = `${archivePath}.manifest.json`; + const baselineStatePath = historyStatePath({args, env, workDir, database}); + const fullManifest = { + backupKind: 'spacetimedb-data-dir', + createdAt: new Date().toISOString(), + database, + dataDir, + bucket, + objectKey, + archivePath, + baselineStatePath, + uploadStatus: args.deferUpload ? 'deferred' : 'pending', + }; writeManifest({ manifestPath, - payload: { - createdAt: new Date().toISOString(), - database, - dataDir, - bucket, - objectKey, - archivePath, - uploadStatus: args.deferUpload ? 'deferred' : 'pending', - }, + payload: fullManifest, }); if (args.deferUpload) { console.log(`[database-backup] 已生成本地冷备份,延后上传: ${archivePath}`); console.log(`[database-backup] 已写入备份清单: ${manifestPath}`); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, bucket, objectKey}, null, 2)}\n`, 'utf8'); + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, baselineStatePath, bucket, objectKey}, null, 2)}\n`, 'utf8'); } return; } const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}); console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); - - writeManifest({ + const uploadedManifest = uploadedManifestPayload({manifest: fullManifest, database, result}); + writeManifest({manifestPath, payload: uploadedManifest}); + const manifestUpload = await uploadManifestFile({ manifestPath, - payload: { - createdAt: new Date().toISOString(), - database, - dataDir, - bucket: result.bucket, - objectKey: result.objectKey, - archivePath, - contentLength: result.contentLength, - etag: result.etag, - uploadMode: result.uploadMode, - partCount: result.partCount, - partSizeBytes: result.partSizeBytes, - verifiedAt: result.verifiedAt, - uploadedAt: new Date().toISOString(), - uploadStatus: 'uploaded', - }, + bucket, + endpoint, + objectKey: uploadedManifest.manifestObjectKey, + accessKeyId, + accessKeySecret, }); + uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; + uploadedManifest.manifestContentLength = manifestUpload.contentLength; + uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; + writeManifest({manifestPath, payload: uploadedManifest}); + const previousState = existsSync(baselineStatePath) + ? validateHistoryState(readManifest(baselineStatePath), {database, dataDir}) + : null; + const baseline = normalizeUploadedBaselineManifest(uploadedManifest, {database, dataDir}); + writeBaselineState({statePath: baselineStatePath, baseline, previousState}); + console.log(`[database-backup] 已写入 baseline state: ${baselineStatePath}`); if (!keepLocal) { rmSync(archivePath, {force: true}); diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 3a49739be..89d1b6cdc 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -10,6 +10,11 @@ GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}" GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}" GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}" GENARRATIVE_OPENSSL_SOURCE_SHA256="${GENARRATIVE_OPENSSL_SOURCE_SHA256:-14c826f07c7e433706fb5c69fa9e25dab95684844b4c962a2cf1bf183eb4690e}" +DATABASE_BACKUP_PROFILE="${DATABASE_BACKUP_PROFILE:-archive-full}" +DATABASE_BACKUP_FILES_HISTORY_WORK_DIR="${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR:-/var/lib/genarrative/database-backups/files-history}" +DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR="/etc/systemd/system/genarrative-database-backup.service.d" +DATABASE_BACKUP_FILES_HISTORY_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-files-history.conf" +DATABASE_BACKUP_LEGACY_DEV_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-dev-files.conf" require_non_root_relative_path() { local label="$1" @@ -63,6 +68,21 @@ validate_server_names() { done } +validate_database_backup_profile() { + case "${DATABASE_BACKUP_PROFILE}" in + archive-full|files-history) + ;; + *) + echo "[server-provision] DATABASE_BACKUP_PROFILE 只能是 archive-full 或 files-history,当前值: ${DATABASE_BACKUP_PROFILE}" >&2 + exit 1 + ;; + esac + if [[ ! "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" =~ ^/var/lib/genarrative/database-backups/[A-Za-z0-9._/-]+$ || "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" == *..* ]]; then + echo "[server-provision] DATABASE_BACKUP_FILES_HISTORY_WORK_DIR 必须是 /var/lib/genarrative/database-backups/ 下不含连续点号的绝对路径,当前值: ${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" >&2 + exit 1 + fi +} + run_cmd() { echo "+ $*" if [[ "${DRY_RUN}" != "true" ]]; then @@ -917,6 +937,52 @@ render_database_backup_service() { deploy/systemd/genarrative-database-backup.service } +render_database_backup_files_history_drop_in() { + local current_escaped env_escaped work_dir_escaped + current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" + env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" + work_dir_escaped="$(escape_sed_replacement "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}")" + sed \ + -e "s|/opt/genarrative/current|${current_escaped}|g" \ + -e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \ + -e "s|/var/lib/genarrative/database-backups/files-history|${work_dir_escaped}|g" \ + deploy/systemd/genarrative-database-backup-files-history.conf +} + +configure_database_backup_profile() { + local rendered_drop_in + + if [[ "${DATABASE_BACKUP_PROFILE}" == "archive-full" ]]; then + echo "[server-provision] 数据库备份 profile=archive-full,保留主 service 的全量冷备行为。" + run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}" + return + fi + + echo "[server-provision] 数据库备份 profile=files-history,先只读验证 full baseline state。" + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ /usr/bin/node -- ${CURRENT_LINK}/scripts/database-backup-to-oss.mjs --env-file ${API_ENV_FILE} --storage-format files --mode history --work-dir ${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR} --dry-run" + else + if [[ ! -f "${CURRENT_LINK}/scripts/database-backup-to-oss.mjs" ]]; then + echo "[server-provision] current release 缺少数据库备份脚本: ${CURRENT_LINK}/scripts/database-backup-to-oss.mjs" >&2 + exit 1 + fi + /usr/bin/node -- "${CURRENT_LINK}/scripts/database-backup-to-oss.mjs" \ + --env-file "${API_ENV_FILE}" \ + --storage-format files \ + --mode history \ + --work-dir "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" \ + --dry-run + fi + + run_cmd install -d -o genarrative -g genarrative -m 0750 "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" + run_cmd install -d -o root -g root -m 0755 "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}" + run_cmd rm -f "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}" + rendered_drop_in="$(mktemp)" + render_database_backup_files_history_drop_in >"${rendered_drop_in}" + install_file "${rendered_drop_in}" "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" 0644 + rm -f "${rendered_drop_in}" +} + render_health_patrol_service() { local current_escaped current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" @@ -930,6 +996,7 @@ require_path deploy/systemd/genarrative-api.service require_path deploy/systemd/genarrative-external-generation-worker@.service require_path deploy/systemd/genarrative-external-generation-controller.service require_path deploy/systemd/genarrative-database-backup.service +require_path deploy/systemd/genarrative-database-backup-files-history.conf require_path deploy/systemd/genarrative-database-backup.timer require_path deploy/systemd/genarrative-health-patrol.service require_path deploy/systemd/genarrative-health-patrol.timer @@ -951,9 +1018,10 @@ require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh validate_server_names +validate_database_backup_profile require_non_root_relative_path "PROVISION_TOOLS_DIR" "${PROVISION_TOOLS_DIR}" -echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" +echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, database_backup_profile=${DATABASE_BACKUP_PROFILE}, source_commit=$(cat .jenkins-source-commit)" run_cmd id require_root_for_real_provision @@ -1033,6 +1101,7 @@ else echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" fi ensure_api_runtime_env_defaults +configure_database_backup_profile if [[ ! -f "${WORKER_ENV_FILE}" ]]; then echo "+ create ${WORKER_ENV_FILE} from example" From 56d70bf720579f93535527e30423a3a1849e44db Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 16 Jul 2026 18:16:38 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=80=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=87=E4=BB=BD=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录data-dir内部相对符号链接并拒绝绝对或越界目标 恢复full catalog时安全重建符号链接 补齐备份测试与生产运维文档 --- .../shared-memory/decision-log.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/check-database-backup-to-oss.mjs | 14 +++++- scripts/database-backup-to-oss.mjs | 49 +++++++++++++++++-- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index df7cfa4bb..66cecd788 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-07-16 SpacetimeDB 备份采用逐文件基线、CAS 增量与安全历史清理 - 背景:SpacetimeDB standalone 2.6.0 不自动删除已被 snapshot 覆盖的历史 commitlog 与旧 snapshot;反复压缩整个 `/stdb` 会重复占用磁盘、停机和 OSS 带宽。上游 issue #5542 的 contributor 明确说明,不触碰最新 snapshot 与重启所需 commitlog suffix 时,可在运行中移动或删除这些历史文件。 -- 决策:统一脚本新增 `--storage-format files`,完整基线递归保留目录与文件相对路径,普通文件按 SHA-256 上传为不可变 CAS 对象,catalog 记录目录、路径、长度、SHA 与对象 key;相同内容不重复 PUT,后续 full 扫描只上传新增或变化内容,不再生成 tar.gz。旧 `archive` 路径保留兼容。full 必须从停库目录或已验证的冻结副本生成,不能把在线跨文件扫描称为一致时点备份。 +- 决策:统一脚本新增 `--storage-format files`,完整基线递归保留目录、文件和 data-dir 内部相对符号链接,普通文件按 SHA-256 上传为不可变 CAS 对象,catalog 记录目录、路径、长度、SHA、对象 key 与相对链接目标;绝对或越界链接拒绝备份。相同内容不重复 PUT,后续 full 扫描只上传新增或变化内容,不再生成 tar.gz。旧 `archive` 路径保留兼容。full 必须从停库目录或已验证的冻结副本生成,不能把在线跨文件扫描称为一致时点备份。 - history 继续按 replica 计算安全边界:只接受完整、未锁定且含同 offset `.snapshot_bsatn` 的 snapshot,保留跨越最新 snapshot 的边界 segment 及全部后缀。旧 segment 对和旧 snapshot 被递归映射为单文件 CAS 对象;对象、history catalog、full baseline catalog、候选 fingerprint 与当前边界全部验真后才删除源文件。同库执行用 work-dir PID lock 互斥。 - OSS 固定恢复入口为 `//latest.json`。CAS 文件和 full/history catalog 保持不可变;latest pointer 只保存最新 full catalog 与已发布 history catalog 的 object key、长度和 SHA,不包含主机绝对路径或文件内容。每次 state 变化先验真全部引用 catalog,再覆盖上传并 HEAD 验真 latest pointer,成功后才落本地 state;history 还必须在 pointer 成功后才允许删除源文件。全新机器可仅凭 bucket、database、prefix 与 OSS 凭据自动下载 pointer 和 full catalog。 - dev 带宽不足时,允许把已冻结的 dev 基线经 `10.2.0.10 -> 10.2.4.16` 内网 rsync 到 release 独立 staging,再用 release 出口上传 dev bucket;staging 不得指向 release `/stdb`,不得停止或修改 release 服务,传输凭据必须临时创建并在演练后移除。catalog 不记录 staging 绝对路径,files state 可回传 dev 继续 history。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e97d3ae0a..dd2973ce7 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -339,7 +339,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= `files-history` 使用仓库模板 `deploy/systemd/genarrative-database-backup-files-history.conf` 覆盖主 service 的 `ExecStart`,从 `/etc/genarrative/api-server.env` 读取 data-dir、database、bucket、prefix 与 OSS 凭据,不在 unit 写死环境目标,也不传 `--stop-service`。Server-Provision 在改动 drop-in 前,先用 current release 的同一脚本、同一 env 和 `DATABASE_BACKUP_FILES_HISTORY_WORK_DIR` 执行一次 history `--dry-run`;缺少已发布 full catalog 的 files state、current 脚本过旧或配置不匹配都会在安装 drop-in 和 `daemon-reload` 前失败。选择 `archive-full` 会主动删除仓库托管的 `10-files-history.conf` 与 dev 试点遗留的 `10-dev-files.conf`,防止 systemd 继续合并旧覆盖。dev 可继续指定已有 `/var/lib/genarrative/database-backups/dev-files`,release 建议先在 `/var/lib/genarrative/database-backups/release-files` 建立自己的 full baseline;两台机器不得复用或互传本地 state 目录冒充本机基线。启用时通过 Server-Provision Job 选择目标、`DATABASE_BACKUP_PROFILE=files-history` 和对应 work-dir,先保持 `DRY_RUN=true` 核对,再以同参数正式 provision。不要直接在 `/etc/systemd/system` 手写第二份 drop-in。 -files full 会递归扫描 data-dir,保留空目录和每个普通文件的相对路径;文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA 和对象 key,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证 2083 个文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 +files full 会递归扫描 data-dir,保留空目录、每个普通文件的相对路径,以及目标仍位于 data-dir 内部的相对符号链接;绝对链接或解析后越界的链接直接拒绝。文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA、对象 key 和相对链接目标,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证大量文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 history 的安全边界按每个 replica 独立计算。设最新完整且未锁定的 snapshot offset 为 `S`;数字更大但缺少同 offset `.snapshot_bsatn`、仍存在同名 `.lock` 的目录不能参与边界计算。脚本必须保留起始 offset 小于等于 `S` 的最后一个 commitlog segment,以及它之后的全部 segment;只处理更早的 `.stdb.log` / `.stdb.ofs`,snapshot 只处理最新目录之前的旧目录。files history 会递归展开候选目录,逐对象复用或上传,随后依次验真候选对象、history catalog 与 full baseline catalog,再重新扫描边界和 stat fingerprint,最后覆盖发布并验真 `latest.json`;任何一步失败都不推进 state 或删除源文件。脚本在 work-dir 使用 PID lock 拒绝同库并发上传,SSH 超时后必须先检查原进程,不能直接重跑。 diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index 0905febc2..e031b0a35 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -2,7 +2,7 @@ import {spawnSync} from 'node:child_process'; import {createHash} from 'node:crypto'; -import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import path from 'node:path'; @@ -106,6 +106,8 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload( const workDir = path.join(root, 'work'); mkdirSync(path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects'), {recursive: true}); mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); + mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true}); + symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current')); writeFileSync(path.join(dataDir, 'control-db'), 'control'); writeFileSync( path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects', 'object.bin'), @@ -122,6 +124,10 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload( 'files catalog 必须原样保留 snapshot 内文件的相对路径。', ); assertTrue(collected.directories.includes('empty-directory'), 'files catalog 必须保留空目录。'); + assertTrue( + collected.symlinks.some(({path: symlinkPath, target}) => symlinkPath === 'bin/current' && target === '2.6.0'), + 'files catalog 必须保留指向 data-dir 内部的相对符号链接。', + ); const first = await runDirectFilesBackup(options); assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。'); @@ -258,6 +264,8 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { const restoreDir = path.join(root, 'restore'); mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); mkdirSync(path.join(dataDir, 'config'), {recursive: true}); + mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true}); + symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current')); const keyPath = path.join(dataDir, 'config', 'id_ecdsa'); writeFileSync(keyPath, 'private key fixture'); chmodSync(keyPath, 0o640); @@ -291,6 +299,8 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'files restore 必须按最新 full catalog 的原相对路径恢复内容。'); assertTrue(existsSync(path.join(restoreDir, 'empty-directory')), 'files restore 必须重建空目录。'); assertEqual(statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, 0o640, 'files restore 必须恢复文件权限。'); + assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。'); + assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。'); rmSync(restoreDir, {recursive: true, force: true}); const downloadBufferFn = async ({objectKey}) => { @@ -322,6 +332,7 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { }); assertEqual(dryRun.catalogId, latestFull.catalogId, 'OSS-only dry-run 必须选择 latestFullCatalog。'); assertEqual(dryRun.fileCount, 1, 'OSS-only dry-run 应返回 full catalog 文件数。'); + assertEqual(dryRun.symlinkCount, 1, 'OSS-only dry-run 应返回 full catalog 符号链接数。'); assertEqual(dryRun.totalSizeBytes, String(Buffer.byteLength('updated private key fixture')), 'OSS-only dry-run 应返回总字节数。'); assertEqual(objectDownloadCount, 0, 'OSS-only dry-run 不得下载数据对象。'); assertTrue(!existsSync(restoreDir), 'OSS-only dry-run 不得创建恢复目录。'); @@ -339,6 +350,7 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { assertEqual(latestRestored.catalogId, latestFull.catalogId, 'OSS-only restore 必须选择 latestFullCatalog。'); assertEqual(latestRestored.downloadedCount, 1, 'OSS-only restore 应下载 latest full catalog 的数据对象。'); assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'OSS-only restore 应还原最新 full 内容。'); + assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'OSS-only restore 应还原符号链接。'); } function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() { diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index c5abd6a3d..2b1585eac 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -12,11 +12,13 @@ import { openSync, readdirSync, readFileSync, + readlinkSync, realpathSync, renameSync, rmSync, statfsSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs'; import {basename, dirname, isAbsolute, join, relative, resolve, sep} from 'node:path'; @@ -1058,6 +1060,7 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje throw new Error(`files 数据目录不存在或不是目录: ${resolvedDataDir}`); } const files = new Map(); + const symlinks = new Map(); const directories = new Set(['.']); const roots = candidates === null ? [{absolutePath: resolvedDataDir, relativePath: '.'}] @@ -1069,7 +1072,13 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje const visit = async (absolutePath, relativePath) => { const stat = lstatSync(absolutePath); if (stat.isSymbolicLink()) { - throw new Error(`files 模式拒绝符号链接: ${absolutePath}`); + const target = readlinkSync(absolutePath, 'utf8'); + if (!target || isAbsolute(target)) { + throw new Error(`files 模式只允许 data-dir 内部的相对符号链接: ${absolutePath} -> ${target}`); + } + assertSafeRelativePath(resolvedDataDir, resolve(dirname(absolutePath), target)); + symlinks.set(relativePath, {path: relativePath, target}); + return; } if (stat.isDirectory()) { directories.add(relativePath); @@ -1108,16 +1117,18 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje return { directories: [...directories].sort(), files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)), + symlinks: [...symlinks.values()].sort((left, right) => left.path.localeCompare(right.path)), }; } -function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files}) { +function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files, symlinks}) { return sha256Hex(JSON.stringify({ mode, baselineCatalogId: baselineCatalogId || '', rootName, directories, files: files.map(({path, sizeBytes, sha256, mode, objectKey}) => ({path, sizeBytes, sha256, mode, objectKey})), + symlinks, })); } @@ -1362,6 +1373,7 @@ export async function runDirectFilesBackup({ rootName, directories: collected.directories, files: collected.files.map(({sourceStat: _sourceStat, ...file}) => file), + symlinks: collected.symlinks, }; writeManifest({manifestPath: catalogPath, payload: catalog}); const summary = { @@ -1370,13 +1382,14 @@ export async function runDirectFilesBackup({ catalogObjectKey, catalogId, fileCount: collected.files.length, + symlinkCount: collected.symlinks.length, totalSizeBytes: collected.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(), candidateCount: plan?.candidates.length ?? 0, }; if (resultFile) { atomicWriteJson(resolvePath(resultFile), {...summary, dryRun}); } - console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`); + console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, symlinks=${summary.symlinkCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`); if (dryRun) { console.log('[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。'); return {...summary, catalog, uploadedCount: 0, reusedCount: 0}; @@ -1478,6 +1491,7 @@ export async function runDirectFilesBackup({ sha256: catalogUpload.archiveSha256, verifiedAt: catalogUpload.verifiedAt, files: catalog.files, + symlinks: catalog.symlinks, }; const nextState = { schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION, @@ -1563,7 +1577,7 @@ async function loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptio ) { throw new Error(`files restore catalog 契约无效: ${catalogRef.objectKey}`); } - return catalog; + return {...catalog, symlinks: catalog.symlinks ?? []}; } function assertDirectCatalogFile(file, index) { @@ -1581,6 +1595,22 @@ function assertDirectCatalogFile(file, index) { } } +function assertDirectCatalogSymlink(symlink, index, restoreDir) { + if ( + !symlink + || typeof symlink.path !== 'string' + || !symlink.path + || typeof symlink.target !== 'string' + || !symlink.target + || isAbsolute(symlink.target) + ) { + throw new Error(`files restore catalog 符号链接项无效: index=${index}`); + } + const destinationPath = resolve(restoreDir, symlink.path); + assertSafeRelativePath(restoreDir, destinationPath); + assertSafeRelativePath(restoreDir, resolve(dirname(destinationPath), symlink.target)); +} + async function restoreDirectFilesCatalog({ catalog, restoreDir, @@ -1591,12 +1621,14 @@ async function restoreDirectFilesCatalog({ }) { const resolvedRestoreDir = resolvePath(restoreDir); catalog.files.forEach(assertDirectCatalogFile); + catalog.symlinks.forEach((symlink, index) => assertDirectCatalogSymlink(symlink, index, resolvedRestoreDir)); const totalSizeBytes = catalog.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(); if (dryRun) { const result = { restoreDir: resolvedRestoreDir, catalogId: catalog.catalogId, fileCount: catalog.files.length, + symlinkCount: catalog.symlinks.length, totalSizeBytes, downloadedCount: 0, reusedCount: 0, @@ -1644,10 +1676,19 @@ async function restoreDirectFilesCatalog({ chmodSync(destinationPath, file.mode & 0o7777); console.log(`[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`); } + for (const symlink of catalog.symlinks) { + const destinationPath = resolve(resolvedRestoreDir, symlink.path); + assertSafeRelativePath(resolvedRestoreDir, destinationPath); + mkdirSync(dirname(destinationPath), {recursive: true}); + rmSync(destinationPath, {recursive: true, force: true}); + symlinkSync(symlink.target, destinationPath); + console.log(`[database-backup] files restore: symlink ${symlink.path} -> ${symlink.target}`); + } const result = { restoreDir: resolvedRestoreDir, catalogId: catalog.catalogId, fileCount: catalog.files.length, + symlinkCount: catalog.symlinks.length, totalSizeBytes, downloadedCount, reusedCount, From 40684aafcd0653dfcf98fa07a3a8e43ebb1ca91d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 16 Jul 2026 18:18:26 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=87=E4=BB=BD=E5=B9=B6=E5=8F=91=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加files对象PUT和HEAD受控并发 限制并发范围并降低大目录进度日志量 补齐并发上限测试、环境示例与运维文档 --- deploy/env/api-server.env.example | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 3 +- scripts/check-database-backup-to-oss.mjs | 39 +++++++++++++ scripts/database-backup-to-oss.mjs | 57 +++++++++++++------ 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 0b0b38798..11c056e69 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -159,6 +159,8 @@ GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false # 可选:files 为逐文件 CAS + catalog,不生成 tar.gz;archive 保留旧全量压缩包兼容行为。 GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT=archive +# files 模式并行 PUT/HEAD 数量,必须为 1-64;默认 16。 +GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=16 # 可选:显式要求备份工作目录所在文件系统至少保留的可用空间;为空时按数据目录大小 + 安全余量估算。 GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES= # archive history 模式持久化已验真 full baseline 与追加批次;files 模式改用 work-dir 下的 files state。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index dd2973ce7..30164b5fa 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -327,6 +327,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT=archive +GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=16 GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES= GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE=/var/lib/genarrative/database-backups/genarrative-prod-history-state.json # 仅 archive history 首次从一份 uploadStatus=uploaded 的全量 manifest 初始化 state 时设置或传 --baseline-manifest。 @@ -339,7 +340,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= `files-history` 使用仓库模板 `deploy/systemd/genarrative-database-backup-files-history.conf` 覆盖主 service 的 `ExecStart`,从 `/etc/genarrative/api-server.env` 读取 data-dir、database、bucket、prefix 与 OSS 凭据,不在 unit 写死环境目标,也不传 `--stop-service`。Server-Provision 在改动 drop-in 前,先用 current release 的同一脚本、同一 env 和 `DATABASE_BACKUP_FILES_HISTORY_WORK_DIR` 执行一次 history `--dry-run`;缺少已发布 full catalog 的 files state、current 脚本过旧或配置不匹配都会在安装 drop-in 和 `daemon-reload` 前失败。选择 `archive-full` 会主动删除仓库托管的 `10-files-history.conf` 与 dev 试点遗留的 `10-dev-files.conf`,防止 systemd 继续合并旧覆盖。dev 可继续指定已有 `/var/lib/genarrative/database-backups/dev-files`,release 建议先在 `/var/lib/genarrative/database-backups/release-files` 建立自己的 full baseline;两台机器不得复用或互传本地 state 目录冒充本机基线。启用时通过 Server-Provision Job 选择目标、`DATABASE_BACKUP_PROFILE=files-history` 和对应 work-dir,先保持 `DRY_RUN=true` 核对,再以同参数正式 provision。不要直接在 `/etc/systemd/system` 手写第二份 drop-in。 -files full 会递归扫描 data-dir,保留空目录、每个普通文件的相对路径,以及目标仍位于 data-dir 内部的相对符号链接;绝对链接或解析后越界的链接直接拒绝。文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA、对象 key 和相对链接目标,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证大量文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 +files full 会递归扫描 data-dir,保留空目录、每个普通文件的相对路径,以及目标仍位于 data-dir 内部的相对符号链接;绝对链接或解析后越界的链接直接拒绝。文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA、对象 key 和相对链接目标,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。对象 PUT/HEAD 默认以 16 路并行执行,可用 `GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=1..64` 调整;并发只缩短传输与验真时间,不改变“全部对象、catalog 与 latest pointer 成功后才推进 state/清理”的顺序。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证大量文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 history 的安全边界按每个 replica 独立计算。设最新完整且未锁定的 snapshot offset 为 `S`;数字更大但缺少同 offset `.snapshot_bsatn`、仍存在同名 `.lock` 的目录不能参与边界计算。脚本必须保留起始 offset 小于等于 `S` 的最后一个 commitlog segment,以及它之后的全部 segment;只处理更早的 `.stdb.log` / `.stdb.ofs`,snapshot 只处理最新目录之前的旧目录。files history 会递归展开候选目录,逐对象复用或上传,随后依次验真候选对象、history catalog 与 full baseline catalog,再重新扫描边界和 stat fingerprint,最后覆盖发布并验真 `latest.json`;任何一步失败都不推进 state 或删除源文件。脚本在 work-dir 使用 PID lock 拒绝同库并发上传,SSH 超时后必须先检查原进程,不能直接重跑。 diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index e031b0a35..7688c470e 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -60,6 +60,7 @@ async function main() { await assertHistorySuccessfulUploadCleansAndIsIdempotent(); await assertHistoryResumeReverifiesArchiveAndManifest(); await assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(); + await assertDirectFilesConcurrencyIsBounded(); await assertDirectHistoryPublishesCatalogBeforeCleanup(); await assertDirectHistoryWithoutCandidatesPublishesLatest(); await assertDirectFilesRestoreDownloadsCatalogAndObjects(); @@ -153,6 +154,44 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload( assertEqual(incremental.reusedCount, 1, '增量 files full 应复用未变化 snapshot 文件。'); } +async function assertDirectFilesConcurrencyIsBounded() { + const root = path.join(tmpRoot, 'direct-files-concurrency'); + const dataDir = path.join(root, 'stdb'); + const workDir = path.join(root, 'work'); + mkdirSync(dataDir, {recursive: true}); + for (let index = 0; index < 12; index += 1) { + writeFileSync(path.join(dataDir, `file-${index}.bin`), `content-${index}`); + } + const harness = createDirectOssHarness(); + let activeUploads = 0; + let maxActiveUploads = 0; + const uploadFn = async (options) => { + activeUploads += 1; + maxActiveUploads = Math.max(maxActiveUploads, activeUploads); + await new Promise((resolve) => setTimeout(resolve, 5)); + try { + return await harness.uploadFn(options); + } finally { + activeUploads -= 1; + } + }; + await runDirectFilesBackup({ + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, + concurrency: 3, + }); + assertTrue(maxActiveUploads > 1, 'files 备份应按配置并发处理多个对象。'); + assertTrue(maxActiveUploads <= 3, 'files 备份对象并发数不得超过配置上限。'); +} + async function assertDirectHistoryPublishesCatalogBeforeCleanup() { const fixture = createHistoryFixture('direct-files-history-cleanup', {nestedData: false}); createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 2b1585eac..dd273cb3d 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -47,6 +47,8 @@ const OSS_MAX_MULTIPART_PARTS = 10_000; const DEFAULT_OSS_REQUEST_MAX_ATTEMPTS = 5; const DEFAULT_OSS_RETRY_BASE_DELAY_MS = 1_000; const DEFAULT_OSS_RETRY_MAX_DELAY_MS = 30_000; +const DEFAULT_DIRECT_FILES_CONCURRENCY = 16; +const MAX_DIRECT_FILES_CONCURRENCY = 64; const RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]); const HISTORY_STATE_SCHEMA_VERSION = 1; const HISTORY_MANIFEST_SCHEMA_VERSION = 1; @@ -277,6 +279,14 @@ function firstNonEmpty(...values) { return values.map((value) => String(value ?? '').trim()).find(Boolean) ?? ''; } +function parseDirectFilesConcurrency(rawValue) { + const value = Number(String(rawValue ?? DEFAULT_DIRECT_FILES_CONCURRENCY).trim()); + if (!Number.isSafeInteger(value) || value < 1 || value > MAX_DIRECT_FILES_CONCURRENCY) { + throw new Error(`GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY 必须是 1-${MAX_DIRECT_FILES_CONCURRENCY} 的整数,实际: ${rawValue}`); + } + return value; +} + function resolvePath(value) { return isAbsolute(value) ? value : resolve(REPO_ROOT, value); } @@ -1341,6 +1351,7 @@ export async function runDirectFilesBackup({ uploadFn = uploadArchive, uploadManifestFn = uploadManifestFile, verifyFn = verifyOssObject, + concurrency = 1, }) { mkdirSync(workDir, {recursive: true}); const statePath = directFilesStatePath({workDir, database}); @@ -1449,23 +1460,35 @@ export async function runDirectFilesBackup({ const previousFiles = new Map((state?.latestCatalog?.files ?? []).map((file) => [file.path, file])); let uploadedCount = 0; let reusedCount = 0; - for (const [index, file] of collected.files.entries()) { - const result = await ensureDirectObject({ - file, - dataDir, - uploadOptions, - previousFile: previousFiles.get(file.path), - verifyCatalogReuse: mode === 'history', - uploadFn, - verifyFn, - }); - if (result.status === 'uploaded') { - uploadedCount += 1; - } else { - reusedCount += 1; + let nextIndex = 0; + let completedCount = 0; + const workerCount = Math.min(concurrency, collected.files.length); + const workers = Array.from({length: workerCount}, async () => { + while (nextIndex < collected.files.length) { + const index = nextIndex; + nextIndex += 1; + const file = collected.files[index]; + const result = await ensureDirectObject({ + file, + dataDir, + uploadOptions, + previousFile: previousFiles.get(file.path), + verifyCatalogReuse: mode === 'history', + uploadFn, + verifyFn, + }); + if (result.status === 'uploaded') { + uploadedCount += 1; + } else { + reusedCount += 1; + } + completedCount += 1; + if (collected.files.length <= 100 || completedCount % 1000 === 0 || completedCount === collected.files.length) { + console.log(`[database-backup] files 进度: ${completedCount}/${collected.files.length} (${result.status}) ${file.path}`); + } } - console.log(`[database-backup] files 进度: ${index + 1}/${collected.files.length} (${result.status}) ${file.path}`); - } + }); + await Promise.all(workers); const catalogUpload = await ensureDirectManifest({ manifestPath: catalogPath, objectKey: catalogObjectKey, @@ -2704,6 +2727,7 @@ async function main() { const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; const storageFormat = firstNonEmpty(args.storageFormat, env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, 'archive'); + const directFilesConcurrency = parseDirectFilesConcurrency(env.GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY); if (!['full', 'history'].includes(args.mode)) { throw new Error(`--mode 只能是 full 或 history,实际: ${args.mode}`); @@ -2793,6 +2817,7 @@ async function main() { dryRun: args.dryRun, resultFile: args.resultFile, uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + concurrency: directFilesConcurrency, }); } catch (error) { backupError = error; From 94014392ed5dc155be4b155d76350c5591417300 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 16 Jul 2026 18:28:04 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=B0=8F=E5=AF=B9=E8=B1=A1=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小于等于16MiB的CAS对象改用单次PUT并执行HEAD验真 大对象继续保留multipart上传与失败清理 补齐单次PUT请求路径测试和运维说明 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/check-database-backup-to-oss.mjs | 35 +++++++ scripts/database-backup-to-oss.mjs | 97 ++++++++++++++++++- 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 30164b5fa..cb4f4912e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -340,7 +340,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= `files-history` 使用仓库模板 `deploy/systemd/genarrative-database-backup-files-history.conf` 覆盖主 service 的 `ExecStart`,从 `/etc/genarrative/api-server.env` 读取 data-dir、database、bucket、prefix 与 OSS 凭据,不在 unit 写死环境目标,也不传 `--stop-service`。Server-Provision 在改动 drop-in 前,先用 current release 的同一脚本、同一 env 和 `DATABASE_BACKUP_FILES_HISTORY_WORK_DIR` 执行一次 history `--dry-run`;缺少已发布 full catalog 的 files state、current 脚本过旧或配置不匹配都会在安装 drop-in 和 `daemon-reload` 前失败。选择 `archive-full` 会主动删除仓库托管的 `10-files-history.conf` 与 dev 试点遗留的 `10-dev-files.conf`,防止 systemd 继续合并旧覆盖。dev 可继续指定已有 `/var/lib/genarrative/database-backups/dev-files`,release 建议先在 `/var/lib/genarrative/database-backups/release-files` 建立自己的 full baseline;两台机器不得复用或互传本地 state 目录冒充本机基线。启用时通过 Server-Provision Job 选择目标、`DATABASE_BACKUP_PROFILE=files-history` 和对应 work-dir,先保持 `DRY_RUN=true` 核对,再以同参数正式 provision。不要直接在 `/etc/systemd/system` 手写第二份 drop-in。 -files full 会递归扫描 data-dir,保留空目录、每个普通文件的相对路径,以及目标仍位于 data-dir 内部的相对符号链接;绝对链接或解析后越界的链接直接拒绝。文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA、对象 key 和相对链接目标,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。对象 PUT/HEAD 默认以 16 路并行执行,可用 `GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=1..64` 调整;并发只缩短传输与验真时间,不改变“全部对象、catalog 与 latest pointer 成功后才推进 state/清理”的顺序。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证大量文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 +files full 会递归扫描 data-dir,保留空目录、每个普通文件的相对路径,以及目标仍位于 data-dir 内部的相对符号链接;绝对链接或解析后越界的链接直接拒绝。文件按 SHA-256 上传到不可变对象 key,catalog 记录目录、路径、长度、SHA、对象 key 和相对链接目标,不写 staging 主机的绝对路径。相同 catalog 重跑不重复 PUT;新增或变化文件先 HEAD CAS 对象,存在且长度/SHA 元数据一致就复用,否则上传。16 MiB 及以下对象使用单次 PUT 后 HEAD 验真,大对象继续使用 multipart;对象操作默认以 16 路并行执行,可用 `GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=1..64` 调整。并发和单次 PUT 只缩短传输与验真时间,不改变“全部对象、catalog 与 latest pointer 成功后才推进 state/清理”的顺序。full 基线必须来自停库后的 data-dir 或已通过恢复验证的冻结副本;源文件上传前后 stat 虽会复核,但在线扫描不能保证大量文件属于同一跨文件一致时点。catalog 验真后,脚本把最新 full/history 引用发布到固定 `//latest.json`,全新机器不需要本地 state 即可自动发现恢复入口。 history 的安全边界按每个 replica 独立计算。设最新完整且未锁定的 snapshot offset 为 `S`;数字更大但缺少同 offset `.snapshot_bsatn`、仍存在同名 `.lock` 的目录不能参与边界计算。脚本必须保留起始 offset 小于等于 `S` 的最后一个 commitlog segment,以及它之后的全部 segment;只处理更早的 `.stdb.log` / `.stdb.ofs`,snapshot 只处理最新目录之前的旧目录。files history 会递归展开候选目录,逐对象复用或上传,随后依次验真候选对象、history catalog 与 full baseline catalog,再重新扫描边界和 stat fingerprint,最后覆盖发布并验真 `latest.json`;任何一步失败都不推进 state 或删除源文件。脚本在 work-dir 使用 PID lock 拒绝同库并发上传,SSH 超时后必须先检查原进程,不能直接重跑。 diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index 7688c470e..31f685693 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -17,6 +17,7 @@ import { resumeUploadedHistoryBatch, runDirectFilesBackup, uploadArchive, + uploadDirectFile, uploadHistoryArchiveWithCleanup, uploadManifestFile, } from './database-backup-to-oss.mjs'; @@ -46,6 +47,7 @@ async function main() { assertInsufficientSpaceStopsBeforeServiceChanges(); assertArchiveFailureStillRestoresDependentServices(); await assertMultipartUploadRetriesAndVerifiesRemoteLength(); + await assertDirectSmallFileUsesSinglePut(); await assertMissingPartEtagAbortsMultipartUpload(); await assertCompleteResponseAmbiguityUsesHeadVerification(); await assertHeadLengthMismatchAbortsMultipartUpload(); @@ -101,6 +103,39 @@ function createDirectOssHarness() { return {objects, uploadedKeys, verifiedKeys, uploadFn, uploadManifestFn, verifyFn}; } +async function assertDirectSmallFileUsesSinglePut() { + const filePath = path.join(tmpRoot, 'direct-small-file.bin'); + const body = Buffer.from('small direct object'); + const sha256 = createHash('sha256').update(body).digest('hex'); + writeFileSync(filePath, body); + const methods = []; + const fetchImpl = async (_url, options) => { + methods.push(options.method); + if (options.method === 'PUT') { + return new Response('', {status: 200, headers: {etag: '"single-etag"'}}); + } + if (options.method === 'HEAD') { + return new Response(null, {status: 200, headers: { + 'content-length': String(body.length), + 'x-oss-meta-file-sha256': sha256, + }}); + } + throw new Error(`unexpected method ${options.method}`); + }; + const result = await uploadDirectFile({ + archivePath: filePath, + bucket: 'backup-bucket', + endpoint: 'oss-cn-shanghai.aliyuncs.com', + objectKey: 'database-backups/test-db/files/small', + accessKeyId: 'test-id', + accessKeySecret: 'test-secret', + archiveSha256: sha256, + fetchImpl, + }); + assertEqual(result.uploadMode, 'single', '小型逐文件对象必须使用单次 PUT。'); + assertEqual(methods.join(','), 'PUT,HEAD', '小型逐文件对象只能执行 PUT 后 HEAD 验真,不得进入 multipart。'); +} + async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload() { const root = path.join(tmpRoot, 'direct-files-incremental'); const dataDir = path.join(root, 'stdb'); diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index dd273cb3d..4c7a92aa8 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -49,6 +49,7 @@ const DEFAULT_OSS_RETRY_BASE_DELAY_MS = 1_000; const DEFAULT_OSS_RETRY_MAX_DELAY_MS = 30_000; const DEFAULT_DIRECT_FILES_CONCURRENCY = 16; const MAX_DIRECT_FILES_CONCURRENCY = 64; +const DIRECT_FILES_SINGLE_PUT_MAX_BYTES = 16 * 1024 * 1024; const RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]); const HISTORY_STATE_SCHEMA_VERSION = 1; const HISTORY_MANIFEST_SCHEMA_VERSION = 1; @@ -1348,7 +1349,7 @@ export async function runDirectFilesBackup({ dryRun = false, resultFile = '', uploadOptions, - uploadFn = uploadArchive, + uploadFn = uploadDirectFile, uploadManifestFn = uploadManifestFile, verifyFn = verifyOssObject, concurrency = 1, @@ -2279,6 +2280,100 @@ export async function uploadArchive({ } } +export async function uploadDirectFile({ + archivePath, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + fetchImpl = globalThis.fetch, + nowFn = () => new Date(), + sleepImpl = sleep, + randomFn = Math.random, + maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS, + retryBaseDelayMs = DEFAULT_OSS_RETRY_BASE_DELAY_MS, + retryMaxDelayMs = DEFAULT_OSS_RETRY_MAX_DELAY_MS, + backupKind = 'spacetimedb-direct-file', + archiveSha256 = '', + contentType = 'application/octet-stream', + allowEmpty = true, +}) { + const fileStat = statSync(archivePath); + if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) { + throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`); + } + if (fileStat.size > DIRECT_FILES_SINGLE_PUT_MAX_BYTES) { + return uploadArchive({ + archivePath, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + fetchImpl, + nowFn, + sleepImpl, + randomFn, + maxAttempts, + retryBaseDelayMs, + retryMaxDelayMs, + backupKind, + archiveSha256, + contentType, + allowEmpty, + }); + } + const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath); + if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) { + throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`); + } + const requestOptions = { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + fetchImpl, + nowFn, + sleepImpl, + randomFn, + maxAttempts, + retryBaseDelayMs, + retryMaxDelayMs, + }; + await signedOssRequest({ + ...requestOptions, + method: 'PUT', + headers: { + 'content-type': contentType, + 'x-oss-meta-archive-sha256': verifiedArchiveSha256, + 'x-oss-meta-file-sha256': verifiedArchiveSha256, + 'x-oss-meta-file-size': String(fileStat.size), + 'x-oss-meta-backup-kind': backupKind, + }, + contentLength: fileStat.size, + bodyFactory: () => fileStat.size === 0 ? Buffer.alloc(0) : createReadStream(archivePath), + operation: '上传逐文件对象', + }); + const verification = await verifyUploadedObject({ + requestOptions, + expectedContentLength: fileStat.size, + expectedArchiveSha256: verifiedArchiveSha256, + }); + return { + bucket, + objectKey, + contentLength: fileStat.size, + archiveSha256: verifiedArchiveSha256, + etag: '', + uploadMode: 'single', + partCount: 1, + partSizeBytes: fileStat.size, + verifiedAt: verification.verifiedAt, + }; +} + export async function uploadManifestFile({ manifestPath, bucket, From aecabdacfd24d29bb0fba6b42c7b24a233f993a1 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 16 Jul 2026 18:33:06 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E6=96=B0=E6=8A=A0=E5=9B=BE=E7=AE=97?= =?UTF-8?q?=E6=B3=95=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 段舒康 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/85 Co-authored-by: Linghong Co-committed-by: Linghong --- .../genarrative-external-editor-api/SKILL.md | 8 + .../references/api-selection.md | 8 + .env.local | 2 + .gitignore | 1 + .../genarrative-external-v1.openapi.json | 57 +- .../shared-memory/decision-log.md | 69 +- docs/project-memory/shared-memory/pitfalls.md | 16 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 20 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 36 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 11 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 3 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 11 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 6 +- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 8 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 12 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 9 +- .../src/character_animation_assets.rs | 445 +++--- server-rs/crates/api-server/src/config.rs | 74 +- .../crates/api-server/src/editor_agent.rs | 1 + .../crates/api-server/src/editor_project.rs | 1226 +++++++++++++++-- .../api-server/src/external_editor_api.rs | 16 + .../api-server/src/external_generation.rs | 66 + .../src/external_generation_worker.rs | 410 +++++- server-rs/crates/api-server/src/state.rs | 26 + .../module-assets/src/asset_object_core.rs | 23 +- server-rs/crates/shared-kernel/src/lib.rs | 25 + .../src/external_generation.rs | 121 ++ server-rs/crates/spacetime-client/src/lib.rs | 37 +- .../crates/spacetime-client/src/mapper.rs | 10 +- .../src/mapper/external_generation.rs | 98 ++ .../spacetime-client/src/module_bindings.rs | 8 + ...tion_job_phase_update_failure_kind_type.rs | 18 + ..._generation_job_phase_update_input_type.rs | 18 + ..._job_phase_update_procedure_result_type.rs | 21 + .../external_generation_job_snapshot_type.rs | 1 + ...al_generation_job_summary_snapshot_type.rs | 1 + .../external_generation_job_summary_type.rs | 3 + .../external_generation_job_type.rs | 3 + ...neration_job_phase_and_return_procedure.rs | 62 + .../src/external_generation.rs | 264 +++- .../crates/spacetime-module/src/migration.rs | 9 + ...CanvasEditorGenerationIntegration.test.tsx | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 4 +- .../ImageCanvasTaskSidebarView.test.tsx | 6 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 56 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 52 +- .../useImageCanvasGenerationWorkflow.test.tsx | 165 +-- .../useImageCanvasGenerationWorkflow.ts | 67 +- .../image-editor/editorProjectClient.test.ts | 31 +- .../image-editor/editorProjectClient.ts | 20 +- vite.config.ts | 2 + 51 files changed, 2905 insertions(+), 763 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_failure_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index abf8f6f3a..ac684ddf7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -318,6 +318,14 @@ For image edit/redraw that should replace an existing canvas layer, pass `projec For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation. +## Successful Responses with Warnings + +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists. + +- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation. +- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. +- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning. + ## Guardrails - Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 747d7ea28..77f7269e9 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -78,6 +78,14 @@ Ask a follow-up only when two routes could both be correct and produce different All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`. +## HTTP 2xx Warning Handling + +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist. + +- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch. +- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning. +- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively. + ## Reference Image Upload If the user provides a local file as a reference image, run upload before the generation request: diff --git a/.env.local b/.env.local index 66c7cb8e0..49f8cc1e8 100644 --- a/.env.local +++ b/.env.local @@ -29,6 +29,7 @@ GENARRATIVE_LLM_PROVIDER="ark" GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" +GENARRATIVE_EDITOR_BGFILTER_BASE_URL="https://u1082648-b442-cd409e05.westx.seetacloud.com:8443" APIMART_BASE_URL="https://api.apimart.ai/v1" APIMART_API_KEY="" APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 @@ -36,6 +37,7 @@ DASHSCOPE_SCENE_IMAGE_MODEL="wan2.2-t2i-flash" DASHSCOPE_REFERENCE_IMAGE_MODEL="qwen-image-2.0" DASHSCOPE_COVER_IMAGE_MODEL="wan2.2-t2i-flash" ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000 + # 启用服务端大模型调试日志(记录所有输入输出) LLM_DEBUG_LOG="true" diff --git a/.gitignore b/.gitignore index ee516d36a..b9ff3d97f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ temp*build*/ .worktrees/ .rag/ .env.secrets.local +nohup.out spacetime.local.json deploy/container/api-server.env deploy/container/worker-smoke/ diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index acc09d755..694bf03f4 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -969,7 +969,7 @@ "Editor Images" ], "operationId": "generateExternalEditorIconSpritesheet", - "summary": "按规范图生成并拆分图标素材", + "summary": "按规范图生成图标 spritesheet 并尝试拆分", "security": [ { "ExternalApiKey": [] @@ -987,7 +987,7 @@ }, "responses": { "200": { - "description": "图标 spritesheet、切片结果与落库资源", + "description": "图标 spritesheet、实际切片结果、可选非阻断告警与落库资源", "content": { "application/json": { "schema": { @@ -1017,7 +1017,7 @@ "Editor Images" ], "operationId": "extractExternalEditorUiDesignAssets", - "summary": "从 UI 设计图拆分素材", + "summary": "从 UI 设计图生成素材 spritesheet 并尝试拆分", "security": [ { "ExternalApiKey": [] @@ -1035,7 +1035,7 @@ }, "responses": { "200": { - "description": "UI 设计图素材 spritesheet、切片结果与落库资源", + "description": "UI 设计图素材 spritesheet、实际切片结果、可选非阻断告警与落库资源", "content": { "application/json": { "schema": { @@ -2849,6 +2849,17 @@ } ], "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" + }, + "warning": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorGenerationWarning" + }, + { + "type": "null" + } + ], + "description": "生成成功但后处理降级时返回的非阻断告警。" } } }, @@ -3090,6 +3101,25 @@ }, "additionalProperties": false }, + "EditorGenerationWarning": { + "type": "object", + "required": [ + "code", + "reason" + ], + "properties": { + "code": { + "type": "string", + "const": "postprocess-failed-source-preserved", + "description": "透明背景处理最终失败并保留 provider 原图时的稳定原因码。" + }, + "reason": { + "type": "string", + "description": "可直接展示给调用方的非阻断告警原因。" + } + }, + "additionalProperties": false + }, "EditorIconSpritesheetGenerationResponse": { "type": "object", "required": [ @@ -3130,7 +3160,7 @@ "type": "null" } ], - "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。" + "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。与通用 warning 互斥。" }, "prompt": { "type": "string" @@ -3184,7 +3214,24 @@ } ], "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" + }, + "warning": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorGenerationWarning" + }, + { + "type": "null" + } + ], + "description": "透明背景处理最终失败、provider 原图作为主结果时返回的非阻断告警。与 sliceWarning 互斥。" } + }, + "not": { + "required": [ + "warning", + "sliceWarning" + ] } }, "EditorCharacterAnimationGenerationRequest": { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 66cecd788..8ef781825 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,53 @@ --- +## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展 + +- 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。 +- 决策:保留 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 作为统一基准值。只有角色动作逐帧 BgFilter 在共享 Client 的 RequestBuilder 上把每一次 HTTP attempt 覆盖为“基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧为 `244000 / 260000 / 276000ms`;角色形象单图、图标、UI 和手动去背景不增加帧预算。该 timeout 覆盖请求发起到响应体读取完成;首次失败后的重试重新获得同样的 request deadline,整批并发策略、失败排空语义和 worker long-job 总预算不变。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构、开发运维和图片画布专题文档。 +- 验证方式:运行角色动作超时公式、BgFilter request override 与逐帧流水线定向测试,执行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试 + +- 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。 +- 决策:手动去背景的 complex 请求复用现有 `EDITOR_BGFILTER_RETRY_COUNT=1`,首次请求失败后立即重试一次,两次都失败仍返回最终错误;本次不把手动 complex 接入标准纯色背景链路的阿里云 / 本地兜底,也不改变 flat 路径的熔断状态。 +- 影响范围:图片画布手动去背景 worker、BgFilter complex 请求日志和 api-server 定向测试。 +- 验证方式:运行 `cargo test -p api-server editor_manual_background_removal_retries_once --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/project-memory/shared-memory/decision-log.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-07-14 手动去背景迁移到 BgFilter complex 模式 + +- 背景:图片画布手动“去除背景”此前单独代理 BiRefNet 服务;BgFilter 已增加 `background_mode=complex`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。 +- 决策:`POST /api/editor/images/background-removals` 保持前端与 BFF 契约不变,worker 改用现有 BgFilter 地址、token、超时和共享 HTTP client。multipart 提交图片文件、`background_mode=complex`、`seg_model=birefnet` 与 `cross_check=off`,不提交 `screen_color`。标准纯色背景的角色形象、图标 spritesheet、UI 素材提取和角色动作逐帧抠图继续使用 `background_mode=flat`。删除独立 BiRefNet base URL / timeout 配置;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 仅作为 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 的兼容回退别名。 +- 影响范围:图片画布手动去背景 worker、BgFilter HTTP 协议、api-server 配置、资源元数据、前端 provider 展示和相关文档。 +- 验证方式:运行 api-server BGFilter / 手动去背景定向测试、前端 editorProjectClient / 画布 workflow 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-07-13 外部生成任务持久化真实执行阶段 + +- 背景:图片画布任务列表此前把所有 `running` 任务固定映射为“正在生成”,角色生图、图标/UI spritesheet、角色动作和手动去背景进入抠图后仍无法展示“正在处理”;前端按耗时推断阶段会产生新的非正式业务真相。 +- 决策:不新增 DB 表,在既有 `external_generation_job` 与 `external_generation_job_summary` 末尾追加带默认值的可选 `phase`。worker claim 时写 `generating`;角色生图、图标 spritesheet、UI 素材提取在调用 BgFilter 前,角色动作在视频生成返回并开始抽帧/逐帧抠图前,手动去背景在执行开始时,通过 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 用结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;api-server 对 `LeaseFencingRejected` 立即终止,对 `OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,仅对 `Build` / `ConnectDropped` / `Timeout` 在同一 job attempt 内重试 `1` 次。编辑器 job 固定 `max_attempts=1`,第二次传输失败后进入 `failed`,不回 `pending`、不重新调用 provider,也不按错误文案猜测拒绝类型。BFF 将 `running + processing` 映射为“正在处理”,其它 `running`(含旧数据 `phase=None`)映射为“正在生成”;前端只展示后端投影。 +- 影响范围:`external_generation_job`、`external_generation_job_summary`、SpacetimeDB procedure / typed client / bindings、图片画布生成 worker、任务列表 BFF 与相关文档。 +- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、外部生成 module/client/api-server 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-07-13 角色动作逐帧开启 BgFilter cross-check + +- 背景:角色动作逐帧抠图此前为减少额外推理开销固定传 `cross_check=off`,但动作帧同样需要保留发丝、镂空和运动边缘质量。 +- 决策:角色动作逐帧 BgFilter 请求固定显式传 `cross_check=on`,与角色形象保持一致;图标 spritesheet 和 UI 设计图素材提取继续固定传 `off`。该策略仍属于后端内部供应商参数,不进入前端或外部 OpenAPI。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。 +- 验证方式:运行 `cargo test -p api-server editor_bgfilter_cross_check --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-07-13 角色动作 BgFilter 全帧流水线与单次重试 + +- 背景:角色动作抽帧后原先固定 `buffered(3)`,并在整批绿幕源帧串行落 OSS 后才开始抠图;每帧还单独创建 HTTP Client。公网 BgFilter 的网络等待会让服务端推理队列出现空档,且首个最终错误会通过 `try_collect` 提前取消 api-server 中其余已发 Future。 +- 决策:BgFilter HTTP Client 在 `AppState` 中统一创建并复用 keep-alive 连接池;每次 BgFilter 调用失败后立即重试 `1` 次,两次都失败才进入既有“阿里云通用抠图 → 本地键色”降级链,每次已发失败调用都保留审计。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的 `buffer_unordered` 连续发射并携带原始帧序,完成后排序;不在 api-server 新增供应商进程锁或全局 Semaphore。任一帧最终失败时先排空全部已启动 Future,再让整个动作任务失败退款,不发布缺帧动画。 +- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。 +- 验证方式:运行 `cargo test -p api-server editor_bgfilter_retries_once_before_fallback --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-07-16 SpacetimeDB 备份采用逐文件基线、CAS 增量与安全历史清理 - 背景:SpacetimeDB standalone 2.6.0 不自动删除已被 snapshot 覆盖的历史 commitlog 与旧 snapshot;反复压缩整个 `/stdb` 会重复占用磁盘、停机和 OSS 带宽。上游 issue #5542 的 contributor 明确说明,不触碰最新 snapshot 与重启所需 commitlog suffix 时,可在运行中移动或删除这些历史文件。 @@ -48,8 +95,9 @@ ## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物 - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 -- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 -- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时保留纯色背景原图与透明后处理结果;透明背景处理最终失败时只保留已经持久化的 provider 原图,并按下一条降级规则收口。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 +- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 2026-07-16 告警契约补充:inline / external v1 继续返回结构化原始诊断;queue 有意把通用 `warning` 或 `sliceWarning` 归一为展示就绪字符串,通用 `warning.reason` 原样保留,`sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀,摘要与 BFF 原样投影,Web 直接展示。历史值保留写入时快照,不按新格式回填或推断;该内部字符串契约通过 API/worker 与 Web 同一维护窗口、同版本发布收口,不增加混部兼容层。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。 - 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 @@ -70,6 +118,14 @@ - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、钱包定向 Rust 测试、个人中心定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +## 2026-07-11 BgFilter 交叉模型否决用于角色形象与角色动作 + +- 背景:新版 BgFilter 的 `cross_check` 默认开启,会额外运行 HR-matting 第二意见模型;角色形象与角色动作序列帧需要保留发丝、镂空和运动边缘质量;图标 spritesheet 和 UI 素材提取不需要承担这部分额外推理开销。 +- 决策:api-server 调用 BgFilter 时必须显式发送 multipart 字段 `cross_check`,不依赖服务端默认值。角色形象生成和角色动作逐帧去背固定传 `on`;图标 spritesheet 生成和 UI 设计图素材提取固定传 `off`。角色动作逐帧去背与三条静态生图路线复用同一条 `BgFilter → 阿里云通用抠图 → 本地键色` 降级链和同一 BgFilter 熔断器。该字段是后端内部供应商策略,不进入前端请求或外部 OpenAPI。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、图片画布 BgFilter 调用文档。 +- 验证方式:运行 `cargo test -p api-server editor_bgfilter_cross_check --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-11 SpacetimeDB 工具链统一升级到 2.6.0 - 背景:生产数据副本验证已使用 2.6.0 standalone,而仓库 Rust crate、本地 CLI、生成 bindings、容器与 server provision 仍锁定 2.5.0 或更早版本,继续混用会增加 BSATN / procedure 返回值与发布产物错配风险。 @@ -105,7 +161,7 @@ ## 2026-07-09 角色动作视频生成背景色统一为多色自动决策 + 阿里云抠帧 - 背景:角色动作视频抽帧过去固定 legacy `#00FF00` 绿幕 + 本地 `editor_green_screen`,与生图链路的多色自动决策不一致;实测出现背景色与前景 / 皮肤撞色(蓝撞蓝、桃 / 黄撞肤色)以及图生视频背景变白的问题。 -- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、`reasoning_effort=low`,`max_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。BgFilter 与阿里云抠图失败均写入 `external_api_call_failure` 失败审计。调色板新增中明度低饱和「灰竹绿 `#A0BBA0`」补齐冷区绿色段。 +- 决策:角色动作视频背景色与生图统一。`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、`reasoning_effort=low`,`max_tokens=1024`)读源角色图自动决策,并经硬过滤器(Lab 危险质量 + 皮肤专属三判据:ΔE 距离 / 色调投影 / RGB 分离)剔除与前景及皮肤撞色的候选,手动 hex 仍尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景确定性等于抠图键色。抽帧后逐帧优先走 BgFilter(固定 `seg_model=birefnet`、`cross_check=on`),失败依次降级阿里云通用抠图和本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。BgFilter 与阿里云抠图失败均写入 `external_api_call_failure` 失败审计。调色板新增中明度低饱和「灰竹绿 `#A0BBA0`」补齐冷区绿色段。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、`editor_screen_background_decision.rs`、`editor_screen_background_filter.rs`(新增硬过滤模块)、`editor_green_screen.rs`(调色板)、`external_api_audit.rs`、`llm_model_routing.rs`、图片画布 MVP 与后端数据契约文档。 - 验证方式:`cargo test -p api-server editor_screen_background character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、真机对源角色图跑视觉决策与候选危险度表、抽帧后采样序列帧背景色确认落在冷区安全集。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 @@ -357,7 +413,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 @@ -1345,7 +1401,7 @@ - 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 使用 `queue|inline` 显式策略;生产和容器扩缩容验证保持 `queue`。本地开发若需要同步等待结果,应通过 `.env.local` 或本机环境显式配置为 `inline`,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容;脚本不得硬编码该策略。拼图写回 guard 字段改为可选,queue 路径仍必须完整校验 `job_id + worker_id + lease_token`;inline 路径只允许三项同时为空,半空 guard 仍拒绝。 - 2026-06-11 追加:生产新增固定 `external-generation-controller` 进程角色和 `genarrative-external-generation-controller.service`。controller 只读取 `get_external_generation_queue_stats_and_return` 队列统计并管理 `genarrative-external-generation-worker@N.service`,不监听 HTTP、不执行外部生成任务;默认保留 `@1`,按 `claimable_pending + running_active + expired_running` 计算目标实例数,上限由 `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS` 控制,缩容需要连续空闲轮数且每轮只停最高编号一个实例。 - 2026-07-08 追加:生产 worker/controller 作为轻量 SpacetimeDB 客户端运行,专属 env 示例默认 `GENARRATIVE_SPACETIME_POOL_SIZE=1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。worker/controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 -- 2026-07-08 追加:worker lease 默认从 `3600s` 收短到 `600s`,普通 job 执行预算默认 `900s`,视频 / 角色动作等长 job 默认 `1800s`;预算到期后当前 worker 结束本次尝试、写失败 / 重试状态并释放 worker 槽位,若 SpacetimeDB 当时不可写则最多等到较短 lease 过期后重新领取,避免无进展续租无限延长。资产计费层对已扣费但 future 被取消的外部生成操作做异步补偿退款,避免超时取消绕过退款 outbox。 +- 2026-07-08 追加:worker lease 默认从 `3600s` 收短到 `600s`,普通 job 执行预算默认 `900s`,视频 / 角色动作等长 job 默认 `1800s`;预算到期后当前 worker 停止续租并释放 worker 槽位,但不取消已启动的业务 future,也不主动写失败 / 重试状态。在途写回由 lease fencing 仲裁:有效租约内照常完成,租约过期后任务可被重新认领,attempt 耗尽时由认领事务原子标记失败并结算退款,避免客户端取消与服务端写回发生竞态。 - 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。 - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并在 queue 模式下用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路;本地 inline 排查只确认不创建 `external_generation_job`。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -3153,8 +3209,9 @@ ## 2026-06-19 编辑器角色形象回填改用通用抠图 - 背景:画板编辑器里的 `生成角色形象` 属于编辑器图片生成链路,用户要求把“人物抠图”改成通用抠图方法,不再与 RPG / 资产工坊角色主图专用后处理绑定。 -- 决策:仅 `/api/editor/images/generations` 中 `kind = "character"` 的编辑器角色形象回填改用 `platform-image::generated_asset_sheets` 通用绿幕 / 近白去背能力,并开启内部镂空检测;输出仍归一为透明 PNG。`character_visual_assets::try_apply_background_alpha_to_png` 继续服务 RPG 角色主图与 Big Fish 等“角色主图口径”调用者,本轮不改变这些链路。 +- 决策:仅 `/api/editor/images/generations` 中 `kind = "character"` 的编辑器角色形象回填改用 `platform-image::generated_asset_sheets` 通用绿幕 / 近白去背能力,并开启内部镂空检测;透明背景处理正常成功时输出归一为透明 PNG。`character_visual_assets::try_apply_background_alpha_to_png` 继续服务 RPG 角色主图与 Big Fish 等“角色主图口径”调用者,本轮不改变这些链路。 - 2026-06-22 补充:所有明确设置绿幕用于后续抠图的 prompt 都必须固定写明 `#00FF00 / RGB(0,255,0)`,不能只写“纯绿色绿幕”或“接近 #00FF00”;编辑器角色图通用抠图额外开启暗绿 / 灰绿绿幕背景识别,只作为生成模型偏离标准亮绿时的兜底。该宽松识别只参与从画布边缘连通扩散出的背景清理,不参与全图断开绿色区域删除,避免误伤角色衣物或纹理。 +- 2026-07-16 补充:透明背景处理最终失败、但 provider 原图已持久化时,角色任务以 `completed + warning` 收口,provider 原图作为唯一主图放入画布,不创建透明处理图。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_general_cutout`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet_muted_green_alpha_requires_explicit_option`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0f7605295..5fe3dd109 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## phase 上报的业务拒绝与传输失败不能共用字符串错误 + +- 现象:provider 已经返回并保存原图,worker 上报 `processing` 时一次断连或超时就直接把任务判为失败;或者为了规避误杀而重试所有错误,导致 stale lease 的旧 worker 继续执行后处理。 +- 原因:phase procedure 的 lease / fencing 业务拒绝与 SDK 建连、断连、超时错误被压成同一种字符串错误,调用方无法可靠决定是否重试;按中文或 SDK 文案匹配会在错误文本变化后失效。 +- 处理:procedure 返回结构化 `LeaseFencingRejected` / `OtherRejected`,typed client 再把模块拒绝与 RPC 错误分开。`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试;只有 `Build` / `ConnectDropped` / `Timeout` 在同一 job attempt 内重试一次。编辑器 job 固定 `max_attempts=1`,第二次传输失败后进入 `failed`,不回 `pending`、不重新调用 provider。不得让 phase 上报错误落入“后处理失败保留原图”的降级分支。 +- 验证:分别覆盖 lease / fencing 拒绝、其它拒绝、建连、断连、超时和第二次失败,确认最多调用两次;同时断言角色、图标和 UI 的原图降级只包住透明背景处理,不包住 phase 上报。 +- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 + ## 禁止 Data URL 持久化时不要漏掉异步任务 JSON - 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。 @@ -1610,6 +1618,14 @@ - 验证:`npm run dev -- --watch` 下修改 `apps/admin-web/src/**` 应由 Vite HMR 处理,不应出现连续 `[dev] 重启 admin-web`;`scripts/dev.test.ts` 覆盖 web/admin-web 不注册外层 watch。 - 关联:`scripts/dev.mjs`、`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 +## 根目录 `nohup.out` 持续写入会触发主站 Vite 刷新循环 + +- 现象:在仓库根目录用 `nohup npm run dev ... &` 启动完整 dev 栈后,即使没有修改前端源码,主站页面也会反复整页刷新;`nohup.out` 同时持续增长。 +- 原因:未显式重定向 stdout / stderr 时,`nohup.out` 会收集 SpacetimeDB、api-server 和两套 Vite 的整套 dev 栈输出。主站 Vite 的 root 是仓库根目录,若 watcher 未忽略这个持续写入的文件,每次追加日志都会被当成文件变化;后台 Vite root 是 `apps/admin-web`,仓库根日志不在其监听根内。 +- 处理:主站 `vite.config.ts` 的 `server.watch.ignored` 保持忽略 `**/nohup.out`,Git 同时忽略 `nohup.out`。修改配置后重启主站 Vite。若显式重定向到其它仓库内日志文件,该文件不会自动受保护,应写到 Vite root 之外或补充精确忽略规则。 +- 验证:在仓库根目录追加 `nohup.out` 时主站不再刷新,真实源码修改仍正常触发 HMR;`git check-ignore nohup.out` 能命中忽略规则,`git status` 不出现该日志。 +- 关联:`vite.config.ts`、`.gitignore`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 本地 SpacetimeDB publish 401 可清本地库重发 - 现象:本地 `spacetime publish` 显示 `401` 无权限,或重新发布仍像是在更新旧库。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 01c745e50..cd8f8bf89 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,11 +21,13 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须固定把默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;BgFilter 请求失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:前端固定提交 `screenColor=auto`,后端视觉决策出具体 hex 并把源角色图合成到该背景色后再图生视频;抽帧后逐帧优先阿里云通用抠图,失败降级本地 `editor_green_screen`(按选定背景色,而非固定 `#00FF00`)。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 -- 多产物生成以后端项目快照为唯一画布真相:同一任务的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置。无项目上下文时不创建项目资源或画布图层。 +- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 +- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置;透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 -- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 +- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 @@ -55,7 +57,7 @@ - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 -- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 +- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 @@ -85,10 +87,10 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 -- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。 -- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=`,透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 由 api-server 解析图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 +- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 +- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 - `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。 @@ -128,7 +130,7 @@ - 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 - Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词;生成的透明 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;只有尚未登记的浏览器本地图片才先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 6908adc2c..18b59ea78 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -1,6 +1,6 @@ # 外部生成 Worker 化方案 -更新时间:`2026-06-24` +更新时间:`2026-07-15` ## 背景 @@ -13,8 +13,8 @@ - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 - 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 -- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 -- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。 +- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 +- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 - 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。 ## Module 与 Interface @@ -24,12 +24,15 @@ - `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。 - `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。 - `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。 +- `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。procedure 用结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`,调用方不解析错误文案;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试 `1` 次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。 - `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 -- `list_external_generation_jobs_and_return`:按当前账号读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格和完成提示确认状态。 -- `acknowledge_external_generation_jobs_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入 `notification_acknowledged_at` 并追加审计事件。 +- `list_external_generation_job_summaries_and_return`:按当前账号从轻量摘要投影读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格、执行阶段和完成提示确认状态。 +- `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。 - `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。 -- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 +- `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 + +不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 @@ -38,11 +41,12 @@ 队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table: - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 -- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 +- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、可选 `warning`、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 +- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 -- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 +- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `operationId`(即任务 ID)、`status`、`phaseLabel`、`phaseDetail`、`progress`、`error`、`updatedAtMicros`,以及可选、可直接展示的 `warning` 完整文案。生成页轮询只依赖状态、阶段、进度、错误和警告;`jobKind`、source 和完整时间信息继续由任务列表接口或业务快照提供。`attempt` / `maxAttempts` 属于 worker 调度事实,不向该前端契约暴露;若未来需要面向用户展示,必须单独完成产品、契约和摘要投影设计。 -BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 ## 任务表 @@ -52,7 +56,7 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍 | --- | --- | | `job_id` | 主键,`extgen-` 前缀 UUID | | `dedupe_key` | 唯一键,建议为 `play/action/session/scope` | -| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` | +| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` | | `owner_user_id` | 触发用户 | | `source_module` | 玩法或能力名,例如 `puzzle` | | `source_entity_id` | session/profile/work 等作用域 | @@ -70,6 +74,9 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍 | `price_mud_points` | 后端计算的本任务价格,用于任务列表展示和排障 | | `refund_ledger_id` | 失败退款产生的钱包退款流水 ID,便于从任务追到退款记录 | | `notification_acknowledged_at` | 用户已确认完成 / 失败提示的时间,未确认终态任务下次登录继续集中弹出 | +| `phase` | 尾部可选字段;`null / generating / processing`,claim 时写 `generating`,进入正式后处理时写 `processing` | + +用户正式读取使用私有轻量投影 `external_generation_job_summary`。该表同步保存 owner、来源、状态、`phase`、价格、有限错误/告警摘要、通知确认和时间字段,不复制 request/result payload、worker lease 或 dedupe 内部字段;enqueue、claim、renew、phase update、complete、fail 与 acknowledge 都必须维护对应投影语义。 新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id` 追 `external_generation_job_event` 时间线。 @@ -113,7 +120,7 @@ worker 配置: - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长,默认 `600`;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。 -- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS`:普通外部生成 job 的执行预算,默认 `900`。超过预算后当前 worker 停止当前尝试、写入失败 / 重试状态并释放 worker 槽位,避免任务长期保持 `running_active`;若业务 future 已在计费操作内被取消,计费层会按外部生成 job id 异步补偿退款。 +- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS`:普通外部生成 job 的执行预算,默认 `900`。超过预算后当前 worker 停止续租并释放 worker 槽位,但不取消已启动的业务 future,也不主动写入失败 / 重试状态;在途执行交由 lease fencing 仲裁:写回在租约有效期内到达则照常完成,否则被拒绝,租约过期后任务可被重新认领,attempt 耗尽时由认领事务原子标记失败并结算退款。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS`:视频、角色动作等长耗时 job 的执行预算,默认 `1800`。 controller 配置: @@ -126,7 +133,7 @@ controller 配置: - `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE`:systemd worker 模板,默认 `genarrative-external-generation-worker@{}.service`。 - `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN`:只记录决策不执行 systemctl,默认 `false`。 -动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。若 worker 内业务 future 长时间无返回,执行预算到期后会结束当前尝试并释放槽位;如果当时 SpacetimeDB 写回失败,任务也会按较短 lease 进入可重领窗口,避免无进展续租无限延长。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。 +动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。若 worker 内业务 future 长时间无返回,执行预算到期后 worker 会停止续租并释放槽位,在途 future 继续运行至租约仲裁窗口;有效租约内的写回仍可完成,租约过期后才会由其它 worker 重新认领,避免客户端取消与服务端写回竞态。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。 ## 已接入的拼图纵切 @@ -178,6 +185,7 @@ controller 配置: - `editor_image_generation`:普通图片、生成规范、角色形象、UI 设计图、宣发素材和图片快速编辑。 - `editor_image_edit`:图片编辑 / 修改结果。 +- `editor_background_removal`:手动去除任意图片背景,worker 使用 BgFilter complex 模式、首次失败后重试 `1` 次,并把执行阶段标记为 `processing`。 - `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。 - `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。 - `editor_character_animation_generation`:角色动作视频和帧素材生成。 @@ -186,6 +194,10 @@ controller 配置: 画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset` 和 `editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。 +角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 + +inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 + ## 验收 基础检查: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4510c774f..cfa43d325 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -240,7 +240,8 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一走 BgFilter,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `screen_color`;标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后逐帧固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路的 BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。 +- BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新获得同样的 deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图先落 OSS → BgFilter/降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 @@ -279,14 +280,16 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,但用户可见任务列表、价格、状态、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 -- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;图标图集生成和 UI 素材提取成功但自动拆分降级时,worker 的 `result_payload_json` 只额外保存有界的 `warning.code/reason`,不保存 spritesheet、切片列表或媒体 URL。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。 +- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。 +- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。队列结果不保存图片、切片列表或媒体 URL。 ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、有界非阻断告警 `warning_message`、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误与告警摘要都不复制内联媒体并限制为 2048 字符;`warning_message` 由完成任务的轻量 `result_payload_json.warning.reason` 提取,complete 和历史 backfill 共用同一构建路径。列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 +- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure;`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 +- 非阻断告警:摘要字段 `warning_message` 是展示投影,由完成任务的轻量 `result_payload_json.warning.reason` 原样提取,不等同于公开 inline / external v1 的原始结构化诊断字段。complete 和历史 backfill 共用同一构建路径;历史任务按其结果载荷中已写入的 `reason` 快照投影,不为格式升级重写或补前缀。单 job 状态和任务列表 BFF 以 `warning: string` 返回该可直接展示的完整文案,不再返回结构化 code,Web 不得再次补前缀或按字符串推断告警类型。错误与告警摘要都不复制内联媒体并限制为 2048 字符。`phase` 与 `warning_message` 分别表示当前执行阶段和成功降级提示,不得混用;worker / BFF / Web 必须同版本协调发布,不保证滚动混部或旧 Web 缓存下的字符串语义兼容。 - 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。 ### `external_generation_job_event` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index b7159d938..038dc8ad6 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -42,6 +42,8 @@ v1 只开放以下能力: - `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。 - `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 +角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code` 为 `postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`;服务端保证通用 `warning` 与 `sliceWarning` 互斥,防御性客户端若收到异常双字段响应仍以通用 `warning` 为准。 + 管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: ```text @@ -137,6 +139,7 @@ docs/openapi/genarrative-external-v1.openapi.json - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 - 外部图片生成、重绘、图标拆分、UI 素材拆分、视频、音效和音乐生成成功后,生成结果按请求同时出现在画布资源和账号级素材库。 +- 角色图、图标 spritesheet 和 UI 素材提取的 2xx 成功响应允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 - 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 - OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index cb4f4912e..8bc9a31a6 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-06-12` +更新时间:`2026-07-15` ## 标准开发流程 @@ -33,6 +33,8 @@ npm run dev `npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 +通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 + 单独启动主站前端: ```bash @@ -61,11 +63,12 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer 生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色不创建 API 缓存读连接,只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 `/etc/genarrative/api-server.env` 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 -生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 +生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止续租并释放 worker 槽位,但不会取消已启动的业务 future 或主动写入失败 / 重试状态;在途执行由 lease fencing 仲裁,有效租约内写回仍可完成,租约过期后任务才可重新领取,attempt 耗尽时由认领事务标记失败并结算退款。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 -图片画布角色图、图标素材和 UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看 `editor_bgfilter_request_start`、`editor_bgfilter_fallback_to_aliyun_matting`、`editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。 +图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,该值是所有路径的基准请求超时;角色动作逐帧请求的每一次 HTTP attempt 额外增加 `2000ms × 本次实际帧数`,默认 `32 / 40 / 48` 帧对应 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景继续使用基准值。该 request timeout 不是整批帧或整项任务超时,首次失败后的重试会重新计时;角色动作整项任务仍受默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800` 预算约束,排查时以 `editor_bgfilter_request_start.timeout_ms` 确认实际值。连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看带 `background_mode=flat` 的 `editor_bgfilter_request_start`、`editor_bgfilter_fallback_to_aliyun_matting`、`editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。 +手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但固定传 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传背景色;首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。 `我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。 @@ -73,7 +76,7 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 ` 自 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` 阶段。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。 +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` 阶段。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `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 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index e8d8a3677..1f8eeebb0 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -116,9 +116,9 @@ - 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。 - 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。 - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 -- 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取至少同时回填纯色背景原图与透明后处理结果;UI 素材提取继续一并回填拆分素材。`generatedLayerId` 仍锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。 +- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 -- 图标和 UI 图集自动拆分属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 warning toast 提示用户可手动重试。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成图集标记为失败。 +- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 - 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 @@ -190,7 +190,7 @@ - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 -- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化,并复用图标素材拆分流程,把透明 spritesheet 图集和拆分素材都放到画布。 +- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化。正常透明化成功时复用图标素材拆分流程,把透明 spritesheet 图集和拆分成功的素材放到画布;透明背景处理最终失败时只把 provider 原图放到画布,不继续拆分,并显示通用 warning。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index 829bf6f72..416fdd5fb 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -61,9 +61,9 @@ 仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。 ``` -- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter 按默认 `segModel=birefnet` 透明化;透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力。调用方未指定素材文件夹时落默认“项目”文件夹。 -- UI 素材自动拆分与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。 -- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。图集图层同样提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter,固定传 `background_mode=flat`、`cross_check=off` 并按默认 `segModel=birefnet` 透明化。透明背景处理正常成功时,透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- UI 素材自动拆分只在透明图集成功后执行,与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。 +- 正常透明化成功时,前端先把透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分成功的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布;透明背景处理最终失败时只消费后端快照中的 provider 原图。透明图集图层提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。 ## 验收点 @@ -72,5 +72,5 @@ - 从画布选择时只能绑定图标规范图片。 - 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。 - 上传普通参考图后,请求参考图数组同时包含图标规范和普通参考图,生成图层信息面板展示 `用户输入`、`图标规范` 与普通参考图。 -- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`,画布同时出现透明 spritesheet 图集和拆分后的独立素材。 +- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`。正常透明化和拆分成功时画布同时出现透明 spritesheet 图集和拆分后的独立素材;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图并显示通用 warning。 - UI 素材提取面板上传普通参考图后,提取请求参考图数组同时包含红框 UI 设计图和普通参考图,生成图层信息面板展示 `UI设计图` 与普通参考图。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 35fc3a1c8..e0f18415b 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -4,7 +4,7 @@ ## 背景 -图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet,在后端去背景后自动拆分为可独立编辑的素材。 +图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。 ## 入口与画布表现 @@ -12,7 +12,7 @@ - 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。 - 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。 - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 -- 生成完成后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域拆出的 `assetKind: "icon"` 素材铺到图集右侧。 +- 透明背景处理正常成功后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材铺到图集右侧;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。 - 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 - 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 @@ -59,9 +59,9 @@ ## 去背与保存 -- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 -- 带背景原图和去背后的透明 spritesheet 都先同时写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。 -- 自动拆分是生成后的 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;前端在 inline、worker 队列完成和刷新恢复三条路径统一显示 warning toast,用户可在图集工具栏手动重试。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;BgFilter multipart 固定传 `background_mode=flat`、`cross_check=off`,请求字段同时包含 `screenColor` 和 `segModel`。前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。 +- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 - 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 @@ -79,6 +79,6 @@ - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。 -- 生成成功后画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层。 +- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图。 - 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 374c65796..5e4a3465b 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,8 +65,8 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=` 和 `seg_model=`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。 -- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。 +- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回透明 PNG Data URL 及 `objectKey` / `assetObjectId`。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。 +- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;透明背景正常成功与最终失败保留 provider 原图的收口规则和角色新生成一致。普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -114,7 +114,7 @@ - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 - 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: @@ -162,7 +162,8 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 -- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。 +- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再走 `BgFilter(background_mode=flat、seg_model=birefnet、cross_check=on)→ 阿里云通用抠图 → 本地 editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 +- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 - 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `