Merge branch 'master' into codex/auth-spacetime-fail-closed

This commit is contained in:
2026-05-28 00:52:15 +08:00
46 changed files with 1983 additions and 292 deletions
+11
View File
@@ -143,6 +143,17 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
# SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=""
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=""
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=""
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=""
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX="database-backups"
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL="false"
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=""
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=""
GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE=""
# Optional model name for custom-world scene image generation.
DASHSCOPE_IMAGE_MODEL="wan2.7-image"
+24
View File
@@ -16,6 +16,14 @@
---
## 2026-05-27 生成页总进度圆弧锁定固定画布
- 背景:多轮圆环角度微调后,`GenerationProgressHero` 的 SVG 圆弧仍会出现底部开口偏斜的问题,且圆环还会随着容器宽度伸缩,导致 UI 看起来时大时小、位置漂移。
- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角固定为 `135deg`,轨道和橘黄色填充都从同一个对称起点 `rotate(135 200 200)` 出发;`270deg` 扫描角配合正下方 `90deg` 留空,圆环本体改为固定 `400x400` 画布,不再跟随页面宽度缩放,外层布局只负责定位,不负责改动圆环样式。
- 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。
- 验证方式:`CustomWorldGenerationView``BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=135``data-ring-fill-start-degrees=135`,且圆环容器固定为 `h-[400px] w-[400px]`track / fill transform 都是 `rotate(135 200 200)`
- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`
## 2026-05-26 平台跨流程错误统一用可复制来源弹窗展示
- 背景:拼图等生成链路可能同时存在多个草稿或游玩实例,页面内裸错误 banner 容易让用户误以为当前正在看的拼图失败,也不方便复制完整错误给开发排查。
@@ -201,6 +209,14 @@
- 验证方式:背景 prompt 单测应包含中央禁区硬约束,试玩图中央不再出现苹果或其它主题主体。
- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-27 敲木鱼背景 prompt 不再写中央木鱼预设
- 背景:背景 prompt 曾写入“木鱼预设在屏幕中央位置”,与“背景图中不包含新木鱼物品”“中央 40% 禁止出现主题主体”直接冲突,导致 image2 偶发把静态木鱼画回背景中心。
- 决策:背景 prompt 只能写“中央主体预留区”“运行态叠放敲击物的留白区域”“只生成背景环境图”,不得再出现“木鱼预设在屏幕中央位置”或任何等价的中心主体正向描述。
- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、敲木鱼 PRD、平台链路文档、背景 prompt 单测。
- 验证方式:`wooden_fish_background_prompt_uses_hidden_image2_flow` 必须断言旧冲突句子不存在,并断言新的中央留白表述存在。
- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-21 外部 API 失败必须 OTLP 上报并落库
- 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障。
@@ -1071,6 +1087,14 @@
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-27 微信新用户用户名与孤儿作品作者回退收口
- 背景:用户数据清空后,旧作品的 `owner_user_id` 可能落到空洞或顺序号账号上,新注册用户会错误顶替历史作品;同时微信新用户默认用户名过于固定,不便于区分 openid。
- 决策:微信新用户的用户名统一改为 `名字_openid`,内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格;作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,显示名固定为 `失效作者`,公开陶泥号固定为 `SY-00000000`
- 影响范围:`module-auth``api-server` 作品作者解析、`AppState` 启动初始化、历史孤儿作品离线回填脚本与相关文档。
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author``npm run test -- scripts/rebind-orphan-work-owners.test.ts`
- 关联文档:`server-rs/crates/module-auth/src/domain.rs``server-rs/crates/module-auth/src/lib.rs``server-rs/crates/api-server/src/work_author.rs``scripts/rebind-orphan-work-owners.mjs`
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
+36 -1
View File
@@ -134,6 +134,15 @@
- 验证:浏览器里这三页的根区应仍保留 `platform-remap-surface`,但不再出现 `platform-page-stage`;草稿页顶部筛选样式应和发现页频道标签一致。
- 关联:`src/components/custom-world-home/CustomWorldCreationHub.tsx``src/components/custom-world-home/CustomWorldWorkTabs.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx``src/index.css`
## Jenkinsfile 开头不能带 UTF-8 BOM
- 现象:`Genarrative-Stdb-Module-Publish``Pipeline script from SCM` 读取 `jenkins/Jenkinsfile.production-stdb-module-publish` 后,流水线还未进入任何 stage 就失败,报 `java.lang.NoSuchMethodError: No such DSL method 'pipeline'`,堆栈位置是 `WorkflowScript.run(WorkflowScript:1)`
- 原因:该 Jenkinsfile 文件前三字节是 UTF-8 BOM `EF BB BF`Jenkins/Groovy 把它拼进首个标识符,导致实际调用的是 `\ufeffpipeline` 而不是 Declarative Pipeline 的 `pipeline` 全局。
- 处理:仓库内 `jenkins/Jenkinsfile.production-*` 保存为 UTF-8 without BOM;不要为了解决 Windows PowerShell 5.1 `.ps1` 中文解析问题而给 Jenkinsfile 本身加 BOM。只有 Jenkins helper 临时写出的 `.ps1` 才按需要转成 UTF-8 with BOM。
- 验证:检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 文件开头字节不再是 `EF BB BF`,并用 Jenkins `validateDeclarativePipeline` 或重放 `Genarrative-Stdb-Module-Publish`,不应再停在 `No such DSL method 'pipeline'`
- 关联:`jenkins/Jenkinsfile.production-stdb-module-publish``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## SpacetimeDB 入口迁移 helper 合并时不要只保留调用
- 现象:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 或 Jenkins `Genarrative-Stdb-Module-Build``E0425 cannot find function migrate_rpg_entry_from_old_hidden_default in this scope`,位置在 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 的默认入口配置播种流程。
@@ -170,7 +179,7 @@
- 现象:苹果等主题试玩时,中央敲击物图带明显黑底;背景图中央还可能出现苹果主体,或背景环境图偶发变成纯绿色底,和“中央只叠加 hitObjectAsset”的运行态设定冲突。
- 原因:gpt-image-2 对“透明底”和“背景只做外围氛围”的遵循不稳定。若 hit object 直接入库,黑底会被当成真实像素展示;若背景 prompt 只有软描述,模型会把主题主体画进中央。第一步为了去背刻意要求绿幕图时,如果第二步参考图或 prompt 没有切断绿幕语义,背景图也可能继承纯绿色画布。
- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 绿色背景主体图(纯绿色绿幕、单一 `#00FF00` 背景),再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。
- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 绿色背景主体图(纯绿色绿幕、单一 `#00FF00` 背景),再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。不要在背景 prompt 写“木鱼预设在屏幕中央位置”或类似中心主体正向描述,运行态敲击物只能由前端叠放。
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并用花朵 / 苹果 / 玉米主题跑试玩图确认绿幕被去除、主体未被抠除、背景中央不出现主题主体,背景环境图不再出现纯绿色底。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -182,6 +191,14 @@
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并重新试玩确认返回按钮只剩圆形底色和中央左箭头。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`.
## 敲木鱼历史已发布作品缺返回按钮要补齐,不要靠推荐过滤
- 现象:推荐页或公开列表中的历史敲木鱼作品点击运行态时报 `敲木鱼运行态需要完整作品配置`,但这类作品的敲击物、背景、音效和飘字都已完整,只是 `backButtonAsset` 为空。
- 原因:早期已发布作品缺少统一的默认返回按钮快照;运行态启动时如果仍直接按完整配置校验,就会把可玩的历史作品拒掉。这个问题不应通过推荐流或公开列表过滤解决。
- 处理:`spacetime-module``start_wooden_fish_run_tx` 和 work snapshot 构建时,若作品已发布且 `generationStatus=ready`,但仅缺 `backButtonAsset`,就补写内置默认返回按钮 `/UI/11_left_arrow.png`,再继续进入运行态。默认返回按钮以 `bundled-default` 资产快照写回 work profile,字段保持 `assetId=wooden-fish-default-back-button``imageObjectKey=public/UI/11_left_arrow.png`
- 验证:历史木鱼作品点击运行态不再报完整作品配置缺失;第一次进入后,work profile 里应补出 `backButtonAsset`
- 关联:`server-rs/crates/spacetime-module/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 敲木鱼创作生成不要沿用 15 秒会话超时
- 现象:敲木鱼工作台点击“生成”后,前端直接提示 `请求超时:15000ms`,但后端和 VectorEngine 未必已经失败。
@@ -1567,6 +1584,8 @@
2026-05-24 补充:生成页“预计等待 / 已耗时”卡片本身已经有标签,传给 `GenerationProgressHero` 的值只能是纯时间,例如 `4 分钟``1 分 15 秒`,不要再拼接“预计还需”或“已耗时”;两张时间卡也要和当前步骤卡一样保持半透明。拼图总进度初始帧必须允许显示 `0%`,不要再用 `Math.max(1, nextProgress)` 之类的保护把启动态抬到 `1%`
2026-05-27 补充:`generation-hero-progress-ring-fill` 里那个橘黄色小点不是背景噪点,而是 `strokeLinecap="round"` 在短弧段上的端点;当前圆环口径要求底部 `90deg` 开口居中对称,因此轨道和填充都应使用 `135deg` 起点。圆环本体现在固定为 `400x400`,排查时先看 `data-ring-start-degrees``data-ring-fill-start-degrees` 和容器尺寸,不要把尺寸伸缩误认成素材渲染问题。
## `dev:spacetime` 启动后 3101 又断开先查 publish 是否被 spacetime.json 干扰
- 现象:浏览器报 `Failed to initiate WebSocket connection`,目标为 `ws://127.0.0.1:3101/v1/database/<db>/subscribe`,端口检查发现 `3101` 没有长期监听;手动运行 `npm run dev:spacetime` 可看到 standalone 短暂启动后退出,发布阶段报 `No database target matches '<db>'`
@@ -1614,3 +1633,19 @@
- 处理:后续如果需要重新暴露存档入口,优先评估是否应回到“玩过”或别的独立弹窗流程,不要默认把存档再塞回常用功能宫格或设置列表。
- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 微信历史孤儿作品不要让新注册账号顶替
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
- 原因:作品作者解析曾经把缺失作者简单回退到普通登录用户,且微信新用户用户名 / 内部 ID 都太容易被误认或复用。
- 处理:作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,展示名固定为 `失效作者`;微信新用户用户名改为 `名字_openid`,内部 `user_id` 改成不可复用的 UUID 风格;离线回填时先识别真实有效用户,再把孤儿作品表写回占位账号。
- 验证:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author``npm run test -- scripts/rebind-orphan-work-owners.test.ts`
- 关联:`server-rs/crates/api-server/src/work_author.rs``server-rs/crates/module-auth/src/domain.rs``scripts/rebind-orphan-work-owners.mjs`
## 访客推荐页上下滑不要绑定登录态
- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。
- 原因:推荐页滑动切换逻辑 `beginRecommendDrag(...)` 误把 `isAuthenticated` 作为启用条件;访客态虽然允许浏览和通过底部按钮切换,却无法触发同一套拖拽切换。
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
+12
View File
@@ -118,3 +118,15 @@ ALIYUN_OSS_READ_EXPIRE_SECONDS=600
ALIYUN_OSS_POST_EXPIRE_SECONDS=600
ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520
ALIYUN_OSS_SUCCESS_ACTION_STATUS=200
# SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
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
# 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE=spacetimedb.service
@@ -0,0 +1,18 @@
[Unit]
Description=Genarrative SpacetimeDB OSS Backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
WorkingDirectory=/opt/genarrative/current
EnvironmentFile=/etc/genarrative/api-server.env
ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service
# 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/stdb /var/lib/genarrative
@@ -0,0 +1,12 @@
[Unit]
Description=Run Genarrative SpacetimeDB OSS Backup Daily
[Timer]
OnCalendar=*-*-* 03:20:00
Persistent=true
RandomizedDelaySec=600
Unit=genarrative-database-backup.service
[Install]
WantedBy=timers.target
@@ -166,11 +166,11 @@ WF-*
1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`
2. multipart 参考图固定为第一步敲击物图案抠图完成后的透明图;默认未生成新敲击物时使用内置默认敲击物图案的透明兜底图;
3. 尺寸固定竖屏 `9:16`
4. 背景环境图只适配新敲击物主题和画风,背景中不得包含新敲击物本体,也不得增加木槌互动物品;中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;
4. 背景环境图只适配新敲击物主题和画风,背景中不得包含新敲击物本体,也不得增加木槌互动物品;中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;运行态的敲击物只在前端叠放,不允许出现在背景图提示词里。
5. 提示词严格使用:
```text
生成敲木鱼背景,要求主题画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。
生成敲木鱼背景,要求主题画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,只生成竖屏背景环境图,不生成、不描绘、不暗示新木鱼物品本体,也不要出现木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,中央区域是运行态叠放敲击物的留白区域,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。
主题为:(用户提供参考图或用户输入关键词)
```
@@ -384,6 +384,8 @@ finish
公开列表优先消费 `wooden_fish_gallery_card_view` 订阅缓存。公开详情如果卡片摘要不足以进入运行态,必须补读完整 work profile。
历史已发布且 `generationStatus=ready` 的木鱼作品如果仅缺 `backButtonAsset`,运行态启动前必须补齐内置默认返回按钮 `/UI/11_left_arrow.png`,并持久写回 work profile;这类历史作品不应通过推荐流过滤隐藏。
## 13. 验收
1. 创作入口能看到 `敲木鱼` 模板;
@@ -159,7 +159,7 @@ npm run check:server-rs-ddd
## 外部服务与资产
- LLM`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
- 图片生成: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`APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 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 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -168,6 +168,7 @@ npm run check:server-rs-ddd
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event``event_key = external_generation_run``scope_kind = module``scope_id = provider``module_key = external-generation`。metadata 固定包含 `runId``provider``operation``requestLabel``requestPayload``status``success``failureReason``providerRequestId``resultPayload``startedAtMicros``completedAtMicros``durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
## SpacetimeDB 表目录
@@ -193,6 +193,31 @@ UI 相关修改要重点验证:
6. Jenkins 数据库导入 / 导出流水线会先加载 `scripts/jenkins-prepare-toolchain-env.sh`,显式补齐 Jenkins 用户的 Node、Cargo、SpacetimeDB 工具链目录;如果目标机器安装路径不同,用 `GENARRATIVE_JENKINS_TOOL_PATHS` 传入额外 `bin` 目录。
7. 本地 `npm run dev` / `npm run dev:api-server` 若没有显式 `GENARRATIVE_SPACETIME_TOKEN`,会在 SpacetimeDB 就绪后调用 `/v1/identity` 创建当前进程专用 Web API identity token,并只注入本次 `api-server` 环境,不写回 `.env.local`。启动日志只打印 identity 前缀,禁止打印 token 明文;若仍出现 `subscribe ... 401 Unauthorized`,先确认是否绕过了项目 dev 脚本或是否连接到非本次启动的 SpacetimeDB server。
### SpacetimeDB 数据目录 OSS 备份
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
```bash
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service
```
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。`Genarrative-Stdb-Module-Publish` 默认也会在 `spacetime publish` 前执行同一脚本;备份失败会阻断 publish,只有显式勾选 `SKIP_DATABASE_BACKUP` 或脚本参数 `--skip-backup` 才跳过。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
生产环境变量模板在 `deploy/env/api-server.env.example`
```env
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
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_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 单独配置最小权限账号。`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`
## 生产运维
生产部署当前口径:
@@ -207,6 +232,9 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
生产 Jenkins 的 `Pipeline script from SCM` 由 Windows controller 读取 JenkinsfileSCM URL 继续使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行在 `linux && genarrative-build` 构建机上的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段和 `Genarrative-Web-Build` checkout 阶段,优先使用 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;两层 checkout 都必须保留单分支 refspec、`shallow=true``depth=1``noTags=true``honorRefspec=true`,后续二次源码确认继续走 `scripts/jenkins-checkout-source.sh`
`Genarrative-Stdb-Module-Publish``Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step;本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`
@@ -264,6 +292,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
- `GENARRATIVE_SPACETIME_SERVER_URL`
- `GENARRATIVE_SPACETIME_DATABASE`
- `GENARRATIVE_SPACETIME_TOKEN`
- `GENARRATIVE_DATABASE_BACKUP_*`
- `GENARRATIVE_LLM_*`
- `APIMART_*`
- `VECTOR_ENGINE_*`
@@ -386,3 +415,24 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
## 文档维护
当前 `docs/` 只保留少量融合文档。新增稳定知识时优先更新现有文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。阶段性流水账、一次性修复记录和已关闭实验不要再新增成长期文档。
## 微信登录与孤儿作品归属处理
- 微信新用户的 `username` 统一拼为 `名字_openid`;若昵称或 openid 为空,则分别回退到 `微信旅人``openid`
- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后旧作品被后来的顺序号账号顶替。
- 当作品作者的 `owner_user_id` 找不到真实账号时,作品统一显示为占位作者:`失效作者`,公开陶泥号固定为 `SY-00000000`,占位账号 ID 为 `wx-openid-placeholder`
- 该占位账号只用于作品作者域,不扩展到全站其它身份域。
- 如需把历史孤儿作品批量回填到占位作者,使用 `scripts/rebind-orphan-work-owners.mjs` 先基于当前 auth 快照识别有效用户,再把缺失作者对应的作品表写回为占位 ID;脚本输入输出都基于 SpacetimeDB 迁移 JSON。
### 回填脚本用法
```bash
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json>
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --dry-run
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json> --placeholder-user-id wx-openid-placeholder
```
- `--in`SpacetimeDB 导出的迁移 JSON。
- `--out`:写回后的迁移 JSON 输出路径。
- `--dry-run`:只统计回填行数,不写文件。
- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。
@@ -42,7 +42,7 @@
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel``platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作继续收口到左滑或长按操作层。
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑或长按仅作为辅助操作层。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。
@@ -12,8 +12,8 @@
- 生成页背景视频必须留在生成页容器内部,直接作为 `fixed inset-0` 的底层背景,不要再通过 portal 挂到 `document.body`;页面根容器使用 `z-[1]`、背景容器使用 `z-0`,确保顶部导航、圆环和当前步骤卡都稳定覆盖在视频之上。
- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。
- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中``草稿生成中` 等调用侧传入文案。
- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166``strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
- 圆弧描边以圆心为中心整体按 `155deg` 起始;在当前 SVG 坐标系下,这相对 `160deg` 会向左逆时针回调 `5deg`。track 和 fill 都必须共用同一个 `rotate(155 200 200)` 变换,避免只改视觉起点却让填充和轨道错位
- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。圆环本体固定在 `400x400` 的 SVG 画布上,圆弧使用 `r=166``strokeWidth=18` 的 SVG 描边,不再跟随页面宽度缩放,也不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
- 圆弧描边以圆心为中心整体按 `135deg` 起始;`270deg` 扫描角配合 `90deg` 正下方缺口时,轨道和填充都从同一个对称起点出发,轨道保持 `rotate(135 200 200)`,填充端点也使用 `rotate(135 200 200)`。圆环本体尺寸固定,不允许再随容器边长伸缩,只能由外层布局决定放置位置
- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。
- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`
@@ -27,5 +27,5 @@
- `src/components/CustomWorldGenerationView.test.tsx` 覆盖圆环主视觉和单步卡片。
- `src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx` 覆盖汪汪声浪生成页对齐后的圆环布局。
- 两个生成页都应在测试里断言页面根容器层级高于背景视频容器,且背景视频确实是页面子节点,避免 portal 背景把业务 UI 压住。
- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[4%]`,圆弧 DOM 为 SVG,包含清晰的 track/fill circle 描边。
- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[2%]`,桌面端保持 `sm:pt-[1.5%]`,圆弧 DOM 为 SVG,包含清晰的 track/fill circle 描边。
- 页面在桌面和移动端都不应再出现生成步骤列表块,圆环和当前步骤卡不能被外层卡片嵌套出双层面板感。
@@ -27,6 +27,7 @@ pipeline {
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致')
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户')
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布')
booleanParam(name: 'SKIP_DATABASE_BACKUP', defaultValue: false, description: '是否跳过 publish 前 OSS 数据库备份;默认不跳过,备份失败会阻断发布')
}
stages {
@@ -138,6 +139,7 @@ pipeline {
steps {
script {
def clearArg = params.CLEAR_DATABASE ? '--clear-database' : ''
def backupArg = params.SKIP_DATABASE_BACKUP ? '--skip-backup' : ''
def rootArg = "--root-dir \"${params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb'}\""
def runAsArg = params.SPACETIME_RUN_AS_USER?.trim()
? "--run-as-user \"${params.SPACETIME_RUN_AS_USER.trim()}\""
@@ -155,7 +157,8 @@ pipeline {
${rootArg} \\
${runAsArg} \\
${serverArg} \\
${clearArg}
${clearArg} \\
${backupArg}
'
"""
}
+2 -1
View File
@@ -61,7 +61,8 @@
"codegraph:init": "codegraph init -i .",
"codegraph:index": "codegraph index .",
"codegraph:sync": "codegraph sync .",
"codegraph:status": "codegraph status ."
"codegraph:status": "codegraph status .",
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
+2 -1
View File
@@ -461,6 +461,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
@@ -480,7 +481,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
- \`deploy/\`systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
## 生产部署口径
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env node
import {spawnSync} from 'node:child_process';
import {createHash, createHmac} from 'node:crypto';
import {createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
import {basename, dirname, isAbsolute, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, '..');
const DEFAULT_LOCAL_DATA_DIR = resolve(REPO_ROOT, 'server-rs/.spacetimedb/local/data');
const DEFAULT_LOCAL_WORK_DIR = resolve(REPO_ROOT, 'server-rs/.data/database-backups');
const DEFAULT_PRODUCTION_DATA_DIR = '/stdb';
const DEFAULT_PRODUCTION_WORK_DIR = '/var/lib/genarrative/database-backups';
const OSS_ALGORITHM = 'OSS4-HMAC-SHA256';
const OSS_SERVICE = 'oss';
const OSS_REQUEST = 'aliyun_v4_request';
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
function usage() {
console.log(`用法:
npm run database:backup:oss -- [--data-dir <path>] [--work-dir <path>] [--bucket <bucket>] [--object-prefix <prefix>] [--keep-local]
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service]
说明:
将 SpacetimeDB 数据目录打包成 .tar.gz,并上传到阿里云 OSS 指定 bucket。
默认读取 .env / .env.local / .env.secrets.local;生产服务可传 --env-file /etc/genarrative/api-server.env。
shell 环境变量优先级最高,不会被 env 文件覆盖。
常用环境变量:
GENARRATIVE_DATABASE_BACKUP_DATA_DIR 数据目录;生产建议 /stdb
GENARRATIVE_DATABASE_BACKUP_WORK_DIR 本地临时备份目录;生产建议 /var/lib/genarrative/database-backups
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_KEEP_LOCAL true 时保留本地 tar.gz
ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET
`);
}
function loadEnvFile(filePath, target, protectedKeys) {
if (!existsSync(filePath)) {
return;
}
const rawText = readFileSync(filePath, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
if (protectedKeys.has(key)) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
function loadRepoEnv() {
const env = {...process.env};
const protectedKeys = new Set(
Object.entries(process.env)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
for (const fileName of ['.env', '.env.local', '.env.secrets.local']) {
loadEnvFile(resolve(REPO_ROOT, fileName), env, protectedKeys);
}
return env;
}
function loadEffectiveEnv(envFiles) {
const env = loadRepoEnv();
const protectedKeys = new Set(
Object.entries(process.env)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
for (const filePath of envFiles) {
loadEnvFile(resolvePath(filePath), env, protectedKeys);
}
return env;
}
function parseArgs(argv) {
const options = {
dataDir: '',
workDir: '',
bucket: '',
endpoint: '',
objectPrefix: '',
accessKeyId: '',
accessKeySecret: '',
envFiles: [],
keepLocal: false,
stopService: '',
database: '',
dryRun: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${arg} 缺少参数值`);
}
index += 1;
return value;
};
switch (arg) {
case '--help':
case '-h':
usage();
process.exit(0);
break;
case '--data-dir':
options.dataDir = readValue();
break;
case '--work-dir':
options.workDir = readValue();
break;
case '--bucket':
options.bucket = readValue();
break;
case '--endpoint':
options.endpoint = readValue();
break;
case '--object-prefix':
options.objectPrefix = readValue();
break;
case '--access-key-id':
options.accessKeyId = readValue();
break;
case '--access-key-secret':
options.accessKeySecret = readValue();
break;
case '--env-file':
options.envFiles.push(readValue());
break;
case '--database':
options.database = readValue();
break;
case '--stop-service':
options.stopService = readValue();
break;
case '--keep-local':
options.keepLocal = true;
break;
case '--dry-run':
options.dryRun = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
return options;
}
function firstNonEmpty(...values) {
return values.map((value) => String(value ?? '').trim()).find(Boolean) ?? '';
}
function resolvePath(value) {
return isAbsolute(value) ? value : resolve(REPO_ROOT, value);
}
function normalizeEndpoint(raw) {
return String(raw ?? '')
.trim()
.replace(/^https?:\/\//u, '')
.replace(/\/+$/u, '');
}
function sanitizeObjectPart(value, fallback) {
const sanitized = String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/gu, '-')
.replace(/-+/gu, '-')
.replace(/^-|-$/gu, '');
return sanitized || fallback;
}
function timestampForFile(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
}
function buildBackupNames({database, dataDir, objectPrefix}) {
const timestamp = timestampForFile();
const databasePart = sanitizeObjectPart(database || basename(dataDir), 'spacetimedb');
const fileName = `${databasePart}-${timestamp}.tar.gz`;
const prefix = String(objectPrefix || 'database-backups')
.trim()
.replace(/^\/+|\/+$/gu, '')
.split('/')
.filter(Boolean)
.map((part) => sanitizeObjectPart(part, 'backup'))
.join('/');
const objectKey = [prefix, databasePart, fileName].filter(Boolean).join('/');
return {fileName, objectKey};
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? REPO_ROOT,
env: options.env ?? process.env,
encoding: 'utf8',
stdio: options.stdio ?? 'pipe',
shell: process.platform === 'win32',
});
if (result.error) {
throw new Error(`${command} 启动失败: ${result.error.message}`);
}
if (result.status !== 0) {
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
throw new Error(`${command} 退出码 ${result.status}: ${output}`);
}
return result;
}
function stopServiceIfNeeded(serviceName) {
if (!serviceName) {
return false;
}
console.log(`[database-backup] 停止服务以获取冷备份: ${serviceName}`);
runCommand('systemctl', ['stop', serviceName], {stdio: 'inherit'});
return true;
}
function startServiceIfNeeded(serviceName, wasStopped) {
if (!serviceName || !wasStopped) {
return;
}
console.log(`[database-backup] 恢复服务: ${serviceName}`);
runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'});
}
function createArchive({dataDir, workDir, fileName}) {
if (!existsSync(dataDir)) {
throw new Error(`数据库数据目录不存在: ${dataDir}`);
}
const stat = statSync(dataDir);
if (!stat.isDirectory()) {
throw new Error(`数据库数据路径不是目录: ${dataDir}`);
}
mkdirSync(workDir, {recursive: true});
const archivePath = resolve(workDir, fileName);
const parentDir = dirname(dataDir);
const entryName = basename(dataDir);
console.log(`[database-backup] 打包: ${dataDir} -> ${archivePath}`);
runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], {stdio: 'inherit'});
return archivePath;
}
function hmac(key, content, encoding) {
return createHmac('sha256', key).update(content).digest(encoding);
}
function sha256Hex(content) {
return createHash('sha256').update(content).digest('hex');
}
function regionFromEndpoint(endpoint) {
const match = /^oss-([a-z0-9-]+)\./u.exec(endpoint);
if (!match) {
throw new Error(`无法从 OSS endpoint 推断 region: ${endpoint}`);
}
return match[1];
}
function formatScopeDate(date) {
return timestampForFile(date).slice(0, 8);
}
function formatOssDate(date) {
return timestampForFile(date).replace(/[-:]/gu, '');
}
function encodePath(path) {
return path
.split('/')
.map((segment) => encodeURIComponent(segment).replace(/[!'()*]/gu, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
.join('/');
}
function canonicalHeaderValue(value) {
return String(value).trim().replace(/\s+/gu, ' ');
}
function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date}) {
const region = regionFromEndpoint(endpoint);
const scopeDate = formatScopeDate(date);
const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`;
const canonicalUri = `/${encodeURIComponent(bucket)}/${encodePath(objectKey)}`;
const signedHeaders = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), canonicalHeaderValue(value)]),
);
const canonicalHeaders = Object.entries(signedHeaders)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}:${value}\n`)
.join('');
const additionalHeaders = 'host';
const canonicalRequest = [
method,
canonicalUri,
'',
canonicalHeaders,
additionalHeaders,
UNSIGNED_PAYLOAD,
].join('\n');
const stringToSign = [OSS_ALGORITHM, headers['x-oss-date'], scope, sha256Hex(canonicalRequest)].join('\n');
const signature = hmac(Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), scopeDate);
const regionKey = hmac(signature, region);
const serviceKey = hmac(regionKey, OSS_SERVICE);
const signingKey = hmac(serviceKey, OSS_REQUEST);
const finalSignature = hmac(signingKey, stringToSign, 'hex');
return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`;
}
async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}) {
const fileStat = statSync(archivePath);
const now = new Date();
const targetUrl = `https://${bucket}.${endpoint}/${encodePath(objectKey)}`;
const headers = {
host: `${bucket}.${endpoint}`,
'content-type': 'application/gzip',
'x-oss-content-sha256': UNSIGNED_PAYLOAD,
'x-oss-date': formatOssDate(now),
'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
};
const authorization = buildAuthorization({
method: 'PUT',
bucket,
endpoint,
objectKey,
accessKeyId,
accessKeySecret,
headers,
date: now,
});
console.log(`[database-backup] 上传 OSS: oss://${bucket}/${objectKey}`);
const response = await fetch(targetUrl, {
method: 'PUT',
headers: {
...headers,
authorization,
'content-length': String(fileStat.size),
},
body: createReadStream(archivePath),
duplex: 'half',
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`OSS 上传失败 HTTP ${response.status}: ${responseText.slice(0, 500)}`);
}
return {
bucket,
objectKey,
contentLength: fileStat.size,
etag: response.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const env = loadEffectiveEnv(args.envFiles);
const isProductionLike = existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32';
const dataDir = resolvePath(firstNonEmpty(
args.dataDir,
env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR,
isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR,
));
const workDir = resolvePath(firstNonEmpty(
args.workDir,
env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR,
isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR,
));
const bucket = firstNonEmpty(args.bucket, env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, env.ALIYUN_OSS_BUCKET);
const endpoint = normalizeEndpoint(firstNonEmpty(args.endpoint, env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, env.ALIYUN_OSS_ENDPOINT));
const accessKeyId = firstNonEmpty(args.accessKeyId, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, env.ALIYUN_OSS_ACCESS_KEY_ID);
const accessKeySecret = firstNonEmpty(args.accessKeySecret, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, env.ALIYUN_OSS_ACCESS_KEY_SECRET);
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';
for (const [label, value] of Object.entries({bucket, endpoint, accessKeyId, accessKeySecret})) {
if (!value) {
throw new Error(`缺少 ${label} 配置`);
}
}
const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix});
console.log(`[database-backup] 数据目录: ${dataDir}`);
console.log(`[database-backup] 本地临时目录: ${workDir}`);
console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`);
if (args.dryRun) {
console.log('[database-backup] dry-run,仅校验配置,不打包上传。');
return;
}
let archivePath = '';
let serviceStopped = false;
try {
serviceStopped = stopServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE));
archivePath = createArchive({dataDir, workDir, fileName});
} finally {
startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped);
}
const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret});
console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`);
const manifestPath = `${archivePath}.manifest.json`;
writeFileSync(
manifestPath,
`${JSON.stringify({
createdAt: new Date().toISOString(),
dataDir,
bucket: result.bucket,
objectKey: result.objectKey,
contentLength: result.contentLength,
etag: result.etag,
}, null, 2)}\n`,
'utf8',
);
if (!keepLocal) {
rmSync(archivePath, {force: true});
rmSync(manifestPath, {force: true});
console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。');
} else {
console.log(`[database-backup] 已保留本地备份: ${archivePath}`);
console.log(`[database-backup] 已保留备份清单: ${manifestPath}`);
}
}
main().catch((error) => {
console.error(`[database-backup] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
+27 -1
View File
@@ -5,13 +5,14 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database]
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--skip-backup]
说明:
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。
默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
publish 前默认执行一次 OSS 冷备份;备份失败会阻断 publish。仅明确传入 --skip-backup 时跳过。
失败时保留维护模式。
EOF
}
@@ -43,6 +44,7 @@ SERVER_URL="http://127.0.0.1:3101"
SPACETIME_ROOT_DIR="/stdb"
RUN_AS_USER="spacetimedb"
CLEAR_DATABASE=0
SKIP_BACKUP=0
DEPLOY_COMPLETED=0
PUBLISH_TMP_DIR=""
@@ -81,6 +83,10 @@ while [[ $# -gt 0 ]]; do
CLEAR_DATABASE=1
shift
;;
--skip-backup)
SKIP_BACKUP=1
shift
;;
*)
echo "[production-stdb-publish] 未知参数: $1" >&2
usage >&2
@@ -130,6 +136,26 @@ trap on_exit EXIT
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
if [[ "${SKIP_BACKUP}" -ne 1 ]]; then
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
exit 1
fi
echo "[production-stdb-publish] publish 前执行 OSS 冷备份"
node "${BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--stop-service spacetimedb.service
else
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
fi
echo "[production-stdb-publish] 校验 wasm"
(
cd "${SOURCE_DIR}"
+19 -3
View File
@@ -641,8 +641,20 @@ render_api_service() {
deploy/systemd/genarrative-api.service
}
render_database_backup_service() {
local current_escaped env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \
deploy/systemd/genarrative-database-backup.service
}
require_path deploy/systemd/spacetimedb.service
require_path deploy/systemd/genarrative-api.service
require_path deploy/systemd/genarrative-database-backup.service
require_path deploy/systemd/genarrative-database-backup.timer
require_path deploy/systemd/otelcol-contrib.service
require_path deploy/otelcol/genarrative-debug.yaml
require_path deploy/nginx/genarrative.conf
@@ -662,7 +674,7 @@ run_cmd id
install_build_dependencies
install_nginx_brotli_modules
install_sccache
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/database-backups
if ! id spacetimedb >/dev/null 2>&1; then
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
@@ -692,11 +704,15 @@ sync_spacetime_install "${SPACETIME_ROOT}"
spacetimedb_service="$(mktemp)"
api_service="$(mktemp)"
database_backup_service="$(mktemp)"
render_spacetimedb_service >"${spacetimedb_service}"
render_api_service >"${api_service}"
render_database_backup_service >"${database_backup_service}"
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
rm -f "${spacetimedb_service}" "${api_service}"
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}"
if [[ ! -f "${API_ENV_FILE}" ]]; then
echo "+ create ${API_ENV_FILE} from example"
@@ -731,7 +747,7 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
run_cmd systemctl enable otelcol-contrib.service
fi
run_cmd systemctl enable spacetimedb.service genarrative-api.service
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
run_cmd systemctl restart otelcol-contrib.service
fi
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder';
export const WORK_OWNER_TABLES = [
'custom_world_profile',
'custom_world_gallery_entry',
'custom_world_session',
'custom_world_agent_session',
'custom_world_draft_card',
'puzzle_agent_session',
'puzzle_work_profile',
'bark_battle_draft_config',
'bark_battle_published_config',
'match3d_agent_session',
'match3d_work_profile',
'jump_hop_agent_session',
'jump_hop_work_profile',
'wooden_fish_agent_session',
'wooden_fish_work_profile',
'square_hole_agent_session',
'square_hole_work_profile',
'visual_novel_agent_session',
'visual_novel_work_profile',
'big_fish_creation_session',
];
const ROW_KEY_FIELDS = ['profile_id', 'work_id', 'session_id', 'draft_id', 'gallery_entry_id', 'id'];
if (isCliEntry()) {
runCli(process.argv.slice(2)).catch((error) => {
console.error(
`[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
});
}
export function rebindOrphanWorkOwnersInMigration(
migration,
{ placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID, validUserIds = [] } = {},
) {
if (!migration || !Array.isArray(migration.tables)) {
throw new Error('迁移 JSON 必须包含 tables 数组。');
}
const normalizedPlaceholderUserId = placeholderUserId.trim();
const validUserIdSet = new Set(
(Array.isArray(validUserIds) ? validUserIds : [])
.map((value) => String(value).trim())
.filter(Boolean),
);
validUserIdSet.add(normalizedPlaceholderUserId);
const reboundRows = [];
for (const table of migration.tables) {
if (!table || !WORK_OWNER_TABLES.includes(table.name) || !Array.isArray(table.rows)) {
continue;
}
for (const row of table.rows) {
if (!row || typeof row !== 'object') {
continue;
}
const currentOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : '';
if (currentOwner === normalizedPlaceholderUserId || validUserIdSet.has(currentOwner)) {
continue;
}
const originalOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id : '';
row.owner_user_id = normalizedPlaceholderUserId;
reboundRows.push({
table: table.name,
rowKey: resolveRowKey(row),
from: originalOwner,
to: normalizedPlaceholderUserId,
});
}
}
return { reboundRows, validUserCount: validUserIdSet.size };
}
function resolveRowKey(row) {
for (const field of ROW_KEY_FIELDS) {
const value = row[field];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
return '<unknown>';
}
async function runCli(argv) {
const options = parseCliArgs(argv);
const inputPath = path.resolve(options.in);
const outputPath = path.resolve(options.out);
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
const result = rebindOrphanWorkOwnersInMigration(migration, {
placeholderUserId: options.placeholderUserId,
validUserIds: collectValidUserIds(migration),
});
if (!options.dryRun) {
await writeFile(outputPath, `${JSON.stringify(migration, null, 2)}\n`, 'utf8');
}
console.log(
`[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length}`,
);
}
function parseCliArgs(argv) {
const options = {
in: '',
out: '',
placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
dryRun: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (name) => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${name} 缺少参数值。`);
}
index += 1;
return value;
};
if (arg === '--in') {
options.in = readValue(arg);
} else if (arg === '--out') {
options.out = readValue(arg);
} else if (arg === '--placeholder-user-id') {
options.placeholderUserId = readValue(arg);
} else if (arg === '--dry-run') {
options.dryRun = true;
} else {
throw new Error(`未知参数: ${arg}`);
}
}
if (!options.in) {
throw new Error('必须传入 --in。');
}
if (!options.out && !options.dryRun) {
throw new Error('非 dry-run 必须传入 --out。');
}
return options;
}
function collectValidUserIds(migration) {
const result = new Set();
for (const table of migration.tables ?? []) {
if (!table || !Array.isArray(table.rows)) {
continue;
}
if (table.name === 'user_account') {
for (const row of table.rows) {
if (typeof row?.user_id === 'string' && row.user_id.trim()) {
result.add(row.user_id.trim());
}
}
}
}
return result;
}
function isCliEntry() {
const entry = process.argv[1];
return entry ? import.meta.url === `file://${entry.replace(/\\/gu, '/')}` : false;
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs';
const placeholderUserId = 'wx-openid-placeholder';
function table(name, rows) {
return { name, rows };
}
describe('rebindOrphanWorkOwnersInMigration', () => {
it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => {
const migration = {
schema_version: 1,
exported_at_micros: 1,
tables: [
table('user_account', [{ user_id: 'user_alive' }, { user_id: placeholderUserId }]),
table('puzzle_work_profile', [
{ profile_id: 'p1', owner_user_id: 'user_missing' },
{ profile_id: 'p2', owner_user_id: 'user_alive' },
{ profile_id: 'p3', owner_user_id: placeholderUserId },
]),
table('puzzle_agent_session', [{ session_id: 'draft-1', owner_user_id: '' }]),
table('tracking_event', [{ event_id: 't1', owner_user_id: 'user_missing' }]),
],
};
const result = rebindOrphanWorkOwnersInMigration(migration, {
placeholderUserId,
validUserIds: ['user_alive'],
});
expect(result.reboundRows).toEqual([
{ table: 'puzzle_work_profile', rowKey: 'p1', from: 'user_missing', to: placeholderUserId },
{ table: 'puzzle_agent_session', rowKey: 'draft-1', from: '', to: placeholderUserId },
]);
expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive');
expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing');
});
});
+2 -11
View File
@@ -51,6 +51,7 @@ use crate::{
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -1015,17 +1016,7 @@ fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &
}
fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String {
let display_name = if owner_user_id.trim().is_empty() {
None
} else {
state
.auth_user_service()
.get_user_by_id(owner_user_id)
.ok()
.flatten()
.map(|user| user.display_name)
};
normalize_author_display_name(display_name)
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
}
fn normalize_author_display_name(display_name: Option<String>) -> String {
@@ -1,4 +1,5 @@
use super::*;
use crate::tracking::record_external_generation_run_after_success;
struct BigFishDashScopeSettings {
base_url: String,
@@ -39,52 +40,99 @@ pub(super) async fn generate_big_fish_formal_asset(
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<String, AppError> {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
let started_at_micros = current_utc_micros();
let request_payload = json!({
"assetKind": asset_kind,
"level": level,
"motionKey": motion_key,
"sessionId": session_id,
"ownerUserId": owner_user_id,
});
let outcome = async {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
}
.await;
match outcome {
Ok(value) => {
record_external_generation_run_after_success(
state,
"dashscope",
"big_fish_text_to_image",
"大鱼正式图片生成",
request_payload,
started_at_micros,
true,
None,
None,
Some(json!({
"legacyPublicPath": value.clone(),
})),
)
.await;
Ok(value)
}
Err(error) => {
record_external_generation_run_after_success(
state,
"dashscope",
"big_fish_text_to_image",
"大鱼正式图片生成",
request_payload,
started_at_micros,
false,
Some(error.to_string()),
None,
None,
)
.await;
Err(error)
}
}
}
fn build_big_fish_formal_asset_context(
@@ -626,6 +674,10 @@ fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> App
}))
}
fn current_utc_micros() -> i64 {
(time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
@@ -8,6 +8,7 @@ use platform_image::{
vector_engine_images_generation_url,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use crate::{
external_api_audit::{
@@ -16,6 +17,7 @@ use crate::{
},
http_error::AppError,
state::AppState,
tracking::record_external_generation_run_after_success,
};
pub(crate) use platform_image::GPT_IMAGE_2_MODEL;
@@ -105,6 +107,14 @@ pub(crate) async fn create_openai_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"candidateCount": candidate_count,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_generation(
http_client,
&settings.provider_settings(),
@@ -116,7 +126,15 @@ pub(crate) async fn create_openai_image_generation(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_generation",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn create_openai_image_edit(
@@ -128,6 +146,13 @@ pub(crate) async fn create_openai_image_edit(
reference_image: &OpenAiReferenceImage,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": 1,
});
let result = create_vector_engine_image_edit(
http_client,
&settings.provider_settings(),
@@ -138,7 +163,15 @@ pub(crate) async fn create_openai_image_edit(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_edit",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn create_openai_image_edit_with_references(
@@ -151,6 +184,14 @@ pub(crate) async fn create_openai_image_edit_with_references(
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"candidateCount": candidate_count,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_edit_with_references(
http_client,
&settings.provider_settings(),
@@ -162,7 +203,15 @@ pub(crate) async fn create_openai_image_edit_with_references(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_edit_with_references",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn download_remote_image(
@@ -200,19 +249,57 @@ impl OpenAiImageSettings {
}
}
async fn map_platform_image_result<T>(
async fn map_platform_image_result(
settings: &OpenAiImageSettings,
result: Result<T, PlatformImageError>,
) -> Result<T, AppError> {
result: Result<OpenAiGeneratedImages, PlatformImageError>,
operation: &'static str,
failure_context: &str,
request_payload: Value,
started_at_micros: i64,
) -> Result<OpenAiGeneratedImages, AppError> {
match result {
Ok(value) => Ok(value),
Ok(value) => {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
VECTOR_ENGINE_PROVIDER,
operation,
failure_context,
request_payload,
started_at_micros,
true,
None,
Some(value.task_id.clone()),
Some(json!({
"imageCount": value.images.len(),
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
})),
)
.await;
}
Ok(value)
}
Err(error) => {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
VECTOR_ENGINE_PROVIDER,
operation,
failure_context,
request_payload,
started_at_micros,
false,
Some(error.message().to_string()),
None,
None,
)
.await;
}
record_openai_image_failure_if_configured(settings, &error).await;
Err(map_platform_image_error(error))
}
}
}
pub(crate) async fn record_openai_image_failure_if_configured(
settings: &OpenAiImageSettings,
error: &PlatformImageError,
@@ -457,3 +544,7 @@ mod tests {
);
}
}
fn current_utc_micros() -> i64 {
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
+2 -2
View File
@@ -62,8 +62,8 @@ use spacetime_client::{
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
+11
View File
@@ -35,6 +35,9 @@ use crate::puzzle_gallery_cache::PuzzleGalleryCache;
use crate::tracking_outbox::TrackingOutbox;
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
use crate::wechat_provider::build_wechat_provider;
use crate::work_author::{
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
};
const ADMIN_ROLE: &str = "admin";
@@ -361,6 +364,14 @@ impl AppState {
)?)?;
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let auth_user_service = AuthUserService::new(auth_store.clone());
auth_user_service
.ensure_orphan_work_owner_user(
ORPHAN_WORK_OWNER_USER_ID,
ORPHAN_WORK_OWNER_USER_ID,
ORPHAN_WORK_AUTHOR_DISPLAY_NAME,
ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE,
)
.map_err(|error| AppStateInitError::AuthStore(error.to_string()))?;
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
let wechat_auth_state_service =
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
@@ -53,6 +53,55 @@ struct RouteTrackingSpec {
scope_id: &'static str,
}
pub async fn record_external_generation_run_after_success(
state: &AppState,
provider: &str,
operation: &str,
request_label: &str,
request_payload: Value,
started_at_micros: i64,
success: bool,
failure_reason: Option<String>,
provider_request_id: Option<String>,
result_payload: Option<Value>,
) {
let completed_at_micros = current_utc_micros();
let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000;
let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation");
draft.scope_kind = RuntimeTrackingScopeKind::Module;
draft.scope_id = provider.to_string();
draft.metadata = json!({
"runId": format!("external-generation-{}", Uuid::new_v4()),
"provider": provider,
"operation": operation,
"requestLabel": request_label.trim(),
"requestPayload": request_payload,
"status": if success { "succeeded" } else { "failed" },
"success": success,
"failureReason": failure_reason,
"providerRequestId": provider_request_id,
"resultPayload": result_payload,
"startedAtMicros": started_at_micros,
"completedAtMicros": completed_at_micros,
"durationMs": duration_ms,
});
record_tracking_event_after_success(state, &external_generation_request_context(), draft).await;
}
fn current_utc_micros() -> i64 {
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
fn external_generation_request_context() -> RequestContext {
RequestContext::new(
format!("external-generation-{}", Uuid::new_v4()),
"external generation run".to_string(),
std::time::Duration::ZERO,
false,
)
}
pub async fn record_route_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,

Some files were not shown because too many files have changed in this diff Show More