diff --git a/.env.example b/.env.example index 03d8c2c1..b971f513 100644 --- a/.env.example +++ b/.env.example @@ -46,8 +46,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS="30" AUTH_REFRESH_COOKIE_PATH="/api/auth" AUTH_REFRESH_COOKIE_SAME_SITE="Lax" AUTH_REFRESH_COOKIE_SECURE="false" -# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。 -GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" # 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。 GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false" @@ -145,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" diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index f87187c7..5b820053 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:泥点和会员商品在小程序运行态都请求 `wechat_mp_virtual`;小程序页能按 payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment`;`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关前端测试通过。 - 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 +## 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 容易让用户误以为当前正在看的拼图失败,也不方便复制完整错误给开发排查。 @@ -209,6 +217,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、阶段和重试属性聚合排障。 @@ -515,6 +531,13 @@ - 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding`。 - 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 +## 2026-05-27 auth_store_snapshot 改为行级记录,不再保留 default 聚合单行 + +- 背景:`auth_store_snapshot/default` 聚合 JSON 行会把整份认证快照收敛到单键,过期快照一旦被导入就可能覆盖 `user_account` / `auth_identity` / `refresh_session` 的整表状态。 +- 决策:`auth_store_snapshot` 只保留行级记录,按 `meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/` 拆分存储;`api-server` 启动恢复只认正式认证表,`auth_store_snapshot` 仅作为行级备查,不再作为文件快照替代源。 +- 影响范围:`spacetime-module` auth procedures、`spacetime-client` auth facade、`api-server` 启动恢复、后端架构文档、开发运维文档、认证排障记忆。 +- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。 + ## 2026-05-13 微信小程序支付以后端通知为唯一入账事实 - 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。 @@ -1072,10 +1095,26 @@ - 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。 - 验证方式:前端可见文本中不再出现 `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 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。 - 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。 - 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/api-server/src/modules/wooden_fish.rs`、`src/services/wooden-fish/woodenFishClient.ts`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 - 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 + - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 + +## 2026-05-27 认证快照完全去文件化并仅保留行级备查 + +- 背景:`api-server` 依赖本地 `auth-store.json` 或 `GENARRATIVE_AUTH_STORE_PATH` 恢复认证真相会在 SpacetimeDB 不可用时把旧快照回灌到 `auth_identity` / `user_account`,导致用户数据被清空或覆盖。 +- 决策:`api-server` 启动时只允许从 SpacetimeDB 正式认证表恢复;`module-auth` 不再维护本地持久化文件,只保留内存工作集和 JSON 导入 / 导出;`spacetime-module` 的认证快照只保留行级 `auth_store_snapshot` 备查,不再提供旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 兼容入口。 +- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。 +- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index bf8af0d3..a6a0e5c6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -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 未必已经失败。 @@ -512,10 +529,17 @@ - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 - 原因:重置/修改密码会更新 `password_hash`、`password_login_enabled` 和 `token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()`,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。 -- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照;启动恢复时从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB。 +- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。若启动时连不上 SpacetimeDB,`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。 - 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。 - 关联:`server-rs/crates/api-server/src/password_management.rs`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。 +## 认证本地文件快照已废弃,旧 procedure 也已删 + +- 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot`,或者把 `auth-store.json` 误当成认证恢复源。 +- 原因:认证恢复已经彻底收口到 SpacetimeDB 正式表和 `module-auth` 的 JSON 导入 / 导出路径;本地文件持久化会和正式表投影打架,SpacetimeDB 不可用时还可能把旧快照回灌到用户表。 +- 处理:先用 `npm run spacetime:generate -- --rust-only` 刷新 bindings,确认 `server-rs/crates/spacetime-client/src/module_bindings.rs` 里已没有旧 procedure 导出;`module-auth` 只保留内存态,不再写本地快照文件。 +- 验证:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding`。 + ## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置 - 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。 @@ -1560,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//subscribe`,端口检查发现 `3101` 没有长期监听;手动运行 `npm run dev:spacetime` 可看到 standalone 短暂启动后退出,发布阶段报 `No database target matches ''`。 @@ -1608,6 +1634,14 @@ - 验证:`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`。 + ## 访客推荐页上下滑不要绑定登录态 - 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。 diff --git a/deploy/container/api-server.Dockerfile b/deploy/container/api-server.Dockerfile index 1a0c1eaa..1f098dfd 100644 --- a/deploy/container/api-server.Dockerfile +++ b/deploy/container/api-server.Dockerfile @@ -24,7 +24,6 @@ EXPOSE 8082 ENV GENARRATIVE_ENV=container \ GENARRATIVE_API_HOST=0.0.0.0 \ GENARRATIVE_API_PORT=8082 \ - GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json \ GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox CMD ["api-server"] diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index a3e0dd33..98a2c115 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -27,7 +27,6 @@ GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_CONTAINER GENARRATIVE_JWT_ISSUER=genarrative-container GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER AUTH_REFRESH_COOKIE_SECURE=false -GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json # 默认连接 compose 内部 SpacetimeDB;宿主机发布模块使用 127.0.0.1:13101。 GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101 diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml index afac4962..f21832db 100644 --- a/deploy/container/docker-compose.loadtest.yml +++ b/deploy/container/docker-compose.loadtest.yml @@ -52,7 +52,6 @@ services: extra_hosts: - "host.docker.internal:host-gateway" volumes: - - api-auth-store:/var/lib/genarrative/auth - api-tracking-outbox:/var/lib/genarrative/tracking-outbox ulimits: nofile: @@ -142,6 +141,5 @@ services: volumes: spacetime-data: - api-auth-store: api-tracking-outbox: nginx-logs: diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index c7a85bee..5dacb809 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -34,7 +34,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS=30 AUTH_REFRESH_COOKIE_PATH=/api/auth AUTH_REFRESH_COOKIE_SAME_SITE=Lax AUTH_REFRESH_COOKIE_SECURE=true -GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101 @@ -119,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 diff --git a/deploy/systemd/genarrative-database-backup.service b/deploy/systemd/genarrative-database-backup.service new file mode 100644 index 00000000..cde294e2 --- /dev/null +++ b/deploy/systemd/genarrative-database-backup.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 + diff --git a/deploy/systemd/genarrative-database-backup.timer b/deploy/systemd/genarrative-database-backup.timer new file mode 100644 index 00000000..4c222039 --- /dev/null +++ b/deploy/systemd/genarrative-database-backup.timer @@ -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 + diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md index fa2c56f6..6bf23f8e 100644 --- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md +++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md @@ -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. 创作入口能看到 `敲木鱼` 模板; diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 206c98b5..642b66cf 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 容器 UI:VectorEngine `/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 outbox,outbox 不可写或保护阈值拒绝时回退同步写 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 表目录 @@ -233,6 +234,10 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 + +`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 + ### `bark_battle_draft_config` - Rust 结构体:`BarkBattleDraftConfigRow` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index baa5cdd3..d2c50272 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -195,6 +195,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://///-.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`。 + ## 生产运维 生产部署当前口径: @@ -209,6 +234,9 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 生产 Jenkins 的 `Pipeline script from SCM` 由 Windows controller 读取 Jenkinsfile,SCM 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`。 @@ -266,6 +294,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - `GENARRATIVE_SPACETIME_SERVER_URL` - `GENARRATIVE_SPACETIME_DATABASE` - `GENARRATIVE_SPACETIME_TOKEN` +- `GENARRATIVE_DATABASE_BACKUP_*` - `GENARRATIVE_LLM_*` - `APIMART_*` - `VECTOR_ENGINE_*` @@ -344,7 +373,7 @@ systemctl restart genarrative-api.service journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' ``` -`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 与 auth-store 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox`、`/var/lib/genarrative/auth` 归属 `genarrative:genarrative`。 +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会等待启动恢复,超时后继续监听但进入依赖不可用模式,所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表。 常用检查思路: @@ -388,3 +417,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 --out +node scripts/rebind-orphan-work-owners.mjs --in --dry-run +node scripts/rebind-orphan-work-owners.mjs --in --out --placeholder-user-id wx-openid-placeholder +``` + +- `--in`:SpacetimeDB 导出的迁移 JSON。 +- `--out`:写回后的迁移 JSON 输出路径。 +- `--dry-run`:只统计回填行数,不写文件。 +- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 6092eeb5..4ca4e204 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -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` 只用于排序和摘要展示,不参与假进度起算。 diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md index ef97e819..760a55d7 100644 --- a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md +++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md @@ -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 描边。 - 页面在桌面和移动端都不应再出现生成步骤列表块,圆环和当前步骤卡不能被外层卡片嵌套出双层面板感。 diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 13d29880..c143df03 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -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} ' """ } diff --git a/package.json b/package.json index 9a6a6da8..853c3c9d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index 0967523e..803f0762 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -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" <] [--work-dir ] [--bucket ] [--object-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); +}); diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 992604cd..3bf57898 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -205,7 +205,7 @@ ensure_runtime_dir() { ensure_runtime_env_and_dirs() { local api_env_file="$1" - local tracking_enabled tracking_outbox_dir auth_store_path auth_store_dir + local tracking_enabled tracking_outbox_dir # 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。 # 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。 @@ -214,19 +214,12 @@ ensure_runtime_env_and_dirs() { ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456" - ensure_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json" tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")" tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")" if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then ensure_runtime_dir "${tracking_outbox_dir}" "0750" fi - - auth_store_path="$(read_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH")" - if [[ -n "${auth_store_path}" ]]; then - auth_store_dir="$(dirname "${auth_store_path}")" - ensure_runtime_dir "${auth_store_dir}" "0750" - fi } SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 38b3e73f..2b7c0c1b 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -5,13 +5,14 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --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/ --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}" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index e54b42d0..e5d4e943 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -325,7 +325,6 @@ ensure_api_runtime_env_defaults() { ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456" - ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json" } parse_json_string_field() { @@ -642,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 @@ -663,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 @@ -693,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" @@ -732,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 diff --git a/scripts/rebind-orphan-work-owners.mjs b/scripts/rebind-orphan-work-owners.mjs new file mode 100644 index 00000000..c3d6d356 --- /dev/null +++ b/scripts/rebind-orphan-work-owners.mjs @@ -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 ''; +} + +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; +} diff --git a/scripts/rebind-orphan-work-owners.test.ts b/scripts/rebind-orphan-work-owners.test.ts new file mode 100644 index 00000000..b5733586 --- /dev/null +++ b/scripts/rebind-orphan-work-owners.test.ts @@ -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'); + }); +}); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 92599040..4b0d747c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2,11 +2,12 @@ use axum::{ Router, body::Body, extract::{Extension, FromRef}, - http::Request, + http::{Request, StatusCode}, middleware, response::Response, routing::{get, post}, }; +use serde_json::json; use tower_http::{ classify::ServerErrorsFailureClass, trace::{DefaultOnRequest, TraceLayer}, @@ -18,6 +19,7 @@ use crate::{ backpressure::limit_concurrent_requests, creation_entry_config::require_creation_entry_route_enabled, error_middleware::normalize_error_response, + http_error::AppError, modules, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, @@ -164,6 +166,96 @@ pub fn build_router(state: AppState) -> Router { .with_state(state) } +pub fn build_spacetime_unavailable_router(message: String) -> Router { + Router::new() + .fallback(spacetime_unavailable_handler) + .layer(Extension(SpacetimeUnavailableState { + message: message.into(), + })) + // 依赖不可用模式不挂业务 state,统一返回 503,并继续保留 request_id / API 版本 / 耗时响应头。 + .layer(middleware::from_fn(normalize_error_response)) + .layer(middleware::from_fn(propagate_request_id_header)) + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request| { + let request_id = + resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); + let route = crate::telemetry::observability_route(request.uri().path()); + let scheme = crate::telemetry::resolve_request_scheme(request.headers()); + let span_name = format!("{} {}", request.method(), route); + + info_span!( + "http.request", + otel.kind = "server", + otel.name = %span_name, + otel.status_code = tracing::field::Empty, + http.response.status_code = tracing::field::Empty, + method = %request.method(), + http.request.method = %request.method(), + http.route = %route, + url.scheme = %scheme, + url.path = %request.uri().path(), + request_id = %request_id, + status = tracing::field::Empty, + latency_ms = tracing::field::Empty, + ) + }) + .on_request(DefaultOnRequest::new().level(Level::INFO)) + .on_response( + |response: &axum::response::Response, + latency: std::time::Duration, + span: &Span| { + let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; + let status = response.status().as_u16(); + span.record("status", status); + span.record("http.response.status_code", status); + span.record( + "otel.status_code", + if response.status().is_server_error() { + "ERROR" + } else { + "OK" + }, + ); + span.record("latency_ms", latency_ms); + }, + ) + .on_failure( + |failure: ServerErrorsFailureClass, + latency: std::time::Duration, + span: &Span| { + let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; + error!( + parent: span, + latency_ms, + failure = %failure, + "http request failed" + ); + }, + ), + ) + .layer(middleware::from_fn(attach_request_context)) +} + +#[derive(Clone, Debug)] +struct SpacetimeUnavailableState { + message: std::sync::Arc, +} + +async fn spacetime_unavailable_handler( + Extension(state): Extension, + Extension(request_context): Extension, +) -> Response { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("SpacetimeDB 暂不可用,api-server 正在等待数据库恢复") + .with_details(json!({ + "provider": "spacetimedb", + "reason": "spacetime_startup_unavailable", + "message": state.message.as_ref(), + })) + .into_response_with_context(Some(&request_context)) +} + async fn record_api_tracking_after_success( axum::extract::State(state): axum::extract::State, Extension(request_context): Extension, @@ -368,7 +460,7 @@ mod tests { use crate::{config::AppConfig, state::AppState}; - use super::build_router; + use super::{build_router, build_spacetime_unavailable_router}; const TEST_PASSWORD: &str = "secret123"; const INTERNAL_TEST_SECRET: &str = "test-internal-secret"; @@ -564,6 +656,38 @@ mod tests { ); } + #[tokio::test] + async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() { + let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string()); + + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/login-options") + .header("x-request-id", "req-spacetime-unavailable") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()), + Some("req-spacetime-unavailable") + ); + let body = read_json_response(response).await; + assert_eq!(body["error"]["code"], "SERVICE_UNAVAILABLE"); + assert_eq!( + body["error"]["details"]["reason"], + "spacetime_startup_unavailable" + ); + assert_eq!(body["error"]["details"]["provider"], "spacetimedb"); + } + #[tokio::test] async fn creation_entry_route_disabled_returns_service_unavailable() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 4610df88..beb9d940 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -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 { diff --git a/server-rs/crates/api-server/src/big_fish/formal_assets.rs b/server-rs/crates/api-server/src/big_fish/formal_assets.rs index f11beed2..e3276611 100644 --- a/server-rs/crates/api-server/src/big_fish/formal_assets.rs +++ b/server-rs/crates/api-server/src/big_fish/formal_assets.rs @@ -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 { - 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", diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 433a67bd..63937e26 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -11,7 +11,6 @@ use platform_speech::{ }; const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; -const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; @@ -45,7 +44,6 @@ pub struct AppConfig { pub refresh_cookie_secure: bool, pub refresh_cookie_same_site: String, pub refresh_session_ttl_days: u32, - pub auth_store_path: PathBuf, pub dev_password_entry_auto_register_enabled: bool, pub sms_auth_enabled: bool, pub sms_auth_provider: String, @@ -188,7 +186,6 @@ impl Default for AppConfig { refresh_cookie_secure: false, refresh_cookie_same_site: "Lax".to_string(), refresh_session_ttl_days: 30, - auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), @@ -441,9 +438,6 @@ impl AppConfig { config.refresh_session_ttl_days = refresh_session_ttl_days; } - if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { - config.auth_store_path = PathBuf::from(auth_store_path); - } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) { diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs index 46bf0976..cb0bd80b 100644 --- a/server-rs/crates/api-server/src/creation_agent_document_input.rs +++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs @@ -236,7 +236,6 @@ mod tests { AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::{Value, json}; - use std::path::PathBuf; use time::OffsetDateTime; use tower::ServiceExt; @@ -394,12 +393,7 @@ mod tests { } async fn build_test_state(label: &str) -> AppState { - let mut config = AppConfig::default(); - config.auth_store_path = PathBuf::from(format!( - ".codex-temp/api-server-auth-store-creation-doc-{label}.json" - )); - let _ = std::fs::remove_file(&config.auth_store_path); - - AppState::new(config).expect("state should build") + let _ = label; + AppState::new(AppConfig::default()).expect("state should build") } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index b8e793c1..ecd6635e 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -107,9 +107,13 @@ use std::{ use tokio::net::TcpListener; use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio::time::timeout; -use tracing::{info, warn}; +use tracing::{error, info}; -use crate::{app::build_router, config::AppConfig, state::AppState}; +use crate::{ + app::{build_router, build_spacetime_unavailable_router}, + config::AppConfig, + state::{AppState, AppStateInitError}, +}; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8); @@ -156,14 +160,21 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { let otel_enabled = config.otel_enabled; let listener = build_tcp_listener(bind_address, listen_backlog)?; - let state = restore_app_state_for_startup(config) - .await - .map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?; - state.puzzle_gallery_cache().spawn_cleanup_task(); - if let Some(outbox) = state.tracking_outbox() { - outbox.spawn_worker(); - } - let router = build_router(state); + let router = match restore_app_state_for_startup(config).await { + Ok(state) => { + state.puzzle_gallery_cache().spawn_cleanup_task(); + if let Some(outbox) = state.tracking_outbox() { + outbox.spawn_worker(); + } + build_router(state) + } + Err(AppStateInitError::DependencyUnavailable(message)) => { + build_spacetime_unavailable_router(message) + } + Err(error) => { + return Err(std::io::Error::other(format!("初始化应用状态失败:{error}"))); + } + }; info!( %bind_address, @@ -192,7 +203,6 @@ fn build_tcp_listener( async fn restore_app_state_for_startup( config: AppConfig, ) -> Result { - let fallback_config = config.clone(); match timeout( AUTH_STORE_STARTUP_RESTORE_TIMEOUT, AppState::try_restore_auth_store_from_spacetime(config), @@ -201,11 +211,13 @@ async fn restore_app_state_for_startup( { Ok(result) => result, Err(_) => { - warn!( + error!( timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(), - "启动恢复认证快照超时,跳过远端恢复并继续启动 api-server" + "启动等待 SpacetimeDB 恢复认证快照超时,api-server 将进入依赖不可用模式" ); - AppState::new(fallback_config) + Err(state::AppStateInitError::DependencyUnavailable( + "SpacetimeDB 启动恢复认证快照超时".to_string(), + )) } } } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 1c191fb2..c4e2a0f0 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -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 { + 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 { + 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 { + 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( +async fn map_platform_image_result( settings: &OpenAiImageSettings, - result: Result, -) -> Result { + result: Result, + operation: &'static str, + failure_context: &str, + request_payload: Value, + started_at_micros: i64, +) -> Result { 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 +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 56cc3b73..b4fe7b41 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index bfda79d0..6dcf8b9c 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,9 +1,8 @@ use std::{ collections::HashMap, error::Error, - fmt, fs, + fmt, sync::{Arc, Mutex}, - time::{SystemTime, UNIX_EPOCH}, }; use axum::extract::FromRef; @@ -36,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"; @@ -300,6 +302,7 @@ pub enum AppStateInitError { Jwt(JwtError), RefreshCookie(RefreshCookieError), AuthStore(String), + DependencyUnavailable(String), SmsProvider(SmsProviderError), WechatPay(String), Oss(OssError), @@ -308,12 +311,12 @@ pub enum AppStateInitError { impl AppState { pub fn new(config: AppConfig) -> Result { - #[cfg(test)] - let auth_store = InMemoryAuthStore::default(); - #[cfg(not(test))] - let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) - .map_err(AppStateInitError::AuthStore)?; - Self::new_with_auth_store(config, auth_store) + Self::new_with_empty_auth_store(config) + } + + pub fn new_with_empty_auth_store(config: AppConfig) -> Result { + // 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。 + Self::new_with_auth_store(config, InMemoryAuthStore::default()) } fn new_with_auth_store( @@ -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); @@ -549,8 +560,8 @@ impl AppState { OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; - // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 正式认证表用于跨进程恢复。 - // 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 + // 当前进程内 auth_store 是认证请求的即时工作集;SpacetimeDB 正式认证表用于跨进程恢复。 + // 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 #[cfg(not(test))] if let Err(error) = self .spacetime_client @@ -577,64 +588,42 @@ impl AppState { pool_size: config.spacetime_pool_size, procedure_timeout: config.spacetime_procedure_timeout, }); - let mut candidates = Vec::new(); + let mut spacetime_restore_available = false; + let mut restore_errors = Vec::new(); match spacetime_client .export_auth_store_snapshot_from_tables() .await { Ok(snapshot) => { + spacetime_restore_available = true; if let Some(candidate) = auth_store_candidate_from_snapshot_record( snapshot, AuthStoreRestoreSource::SpacetimeTables, )? { - candidates.push(candidate); + let state = Self::new_with_auth_store(config, candidate.auth_store)?; + info!( + source = candidate.source.as_str(), + updated_at_micros = candidate.updated_at_micros, + "已恢复认证快照" + ); + return Ok(state); } } Err(error) => { warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败"); + restore_errors.push(error.to_string()); } } - match spacetime_client.get_auth_store_snapshot().await { - Ok(snapshot) => { - if let Some(candidate) = auth_store_candidate_from_snapshot_record( - snapshot, - AuthStoreRestoreSource::SpacetimeSnapshot, - )? { - candidates.push(candidate); - } - } - Err(error) => { - warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败"); - } + if !spacetime_restore_available { + return Err(AppStateInitError::DependencyUnavailable(format!( + "SpacetimeDB 认证恢复不可用:{}", + restore_errors.join("; ") + ))); } - if let Some(candidate) = auth_store_candidate_from_local_file(&config)? { - candidates.push(candidate); - } - - if let Some(candidate) = select_auth_store_restore_candidate(candidates) { - let source = candidate.source; - let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile; - let state = Self::new_with_auth_store(config, candidate.auth_store)?; - info!( - source = source.as_str(), - updated_at_micros = candidate.updated_at_micros, - "已恢复认证快照" - ); - if should_sync_to_spacetime { - if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await { - warn!( - error = %error, - "本地认证快照回写 SpacetimeDB 失败,当前启动继续" - ); - } - } - return Ok(state); - } - - Self::new(config) + Self::new_with_empty_auth_store(config) } pub fn refresh_session_service(&self) -> &RefreshSessionService { @@ -988,16 +977,12 @@ impl AppState { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AuthStoreRestoreSource { SpacetimeTables, - SpacetimeSnapshot, - LocalFile, } impl AuthStoreRestoreSource { fn as_str(self) -> &'static str { match self { Self::SpacetimeTables => "spacetime_tables", - Self::SpacetimeSnapshot => "spacetime_snapshot", - Self::LocalFile => "local_file", } } } @@ -1029,57 +1014,14 @@ fn auth_store_candidate_from_snapshot_record( })) } -fn auth_store_candidate_from_local_file( - config: &AppConfig, -) -> Result, AppStateInitError> { - if !config.auth_store_path.is_file() { - return Ok(None); - } - - let updated_at_micros = fs::metadata(&config.auth_store_path) - .ok() - .and_then(|metadata| metadata.modified().ok()) - .and_then(system_time_to_unix_micros); - let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) - .map_err(AppStateInitError::AuthStore)?; - - Ok(Some(AuthStoreRestoreCandidate { - source: AuthStoreRestoreSource::LocalFile, - updated_at_micros, - auth_store, - })) -} - -fn system_time_to_unix_micros(system_time: SystemTime) -> Option { - let duration = system_time.duration_since(UNIX_EPOCH).ok()?; - i64::try_from(duration.as_micros()).ok() -} - -fn select_auth_store_restore_candidate( - candidates: Vec, -) -> Option { - candidates.into_iter().max_by_key(|candidate| { - ( - candidate.updated_at_micros.unwrap_or(i64::MIN), - auth_store_restore_source_priority(candidate.source), - ) - }) -} - -fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 { - match source { - AuthStoreRestoreSource::SpacetimeTables => 3, - AuthStoreRestoreSource::SpacetimeSnapshot => 2, - AuthStoreRestoreSource::LocalFile => 1, - } -} - impl fmt::Display for AppStateInitError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), - Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"), + Self::AuthStore(error) | Self::DependencyUnavailable(error) | Self::WechatPay(error) => { + write!(f, "{error}") + } Self::SmsProvider(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"), Self::Llm(error) => write!(f, "{error}"), diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 82670c35..f878902a 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -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, + provider_request_id: Option, + result_payload: Option, +) { + 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, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index a48dff16..7aca5791 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -1,9 +1,12 @@ +use serde_json::json; use shared_contracts::creation_audio; -use crate::{http_error::AppError, state::AppState}; +use crate::{ + http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success, +}; use super::{ - clock::current_utc_iso_text, + clock::{current_utc_iso_text, current_utc_micros}, errors::{map_platform_audio_error, vector_engine_bad_gateway}, publish::wait_for_generated_audio_asset, tasks::{create_background_music_task_response, create_sound_effect_task_response}, @@ -18,45 +21,69 @@ pub(crate) async fn generate_sound_effect_asset_for_creation( seed: Option, target: GeneratedCreationAudioTarget, ) -> Result { + let started_at_micros = current_utc_micros(); let normalized_prompt = platform_audio::normalize_limited_text( &prompt, "prompt", platform_audio::VIDU_PROMPT_MAX_CHARS, ) .map_err(map_platform_audio_error)?; - let task = - create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::SoundEffect, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?; + let request_payload = json!({ + "kind": "sound_effect", + "promptChars": normalized_prompt.chars().count(), + "duration": duration, + "seed": seed, + "targetEntityKind": target.entity_kind, + "targetEntityId": target.entity_id, + "targetSlot": target.slot, + "targetAssetKind": target.asset_kind, + }); + let outcome = async { + let task = + create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed) + .await?; + let target = AudioAssetBindingTarget { + storage_scope: target.entity_kind.clone(), + entity_kind: target.entity_kind, + entity_id: target.entity_id, + slot: target.slot, + asset_kind: target.asset_kind, + profile_id: target.profile_id, + storage_prefix: target.storage_prefix, + }; + let generated = wait_for_generated_audio_asset( + state, + owner_user_id, + task.task_id.clone(), + AudioAssetSlot::SoundEffect, + target, + ) + .await?; + let audio_src = generated + .audio_src + .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?; - Ok(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: None, - updated_at: Some(current_utc_iso_text()), - }) + Ok::<_, AppError>(creation_audio::CreationAudioAsset { + task_id: generated.task_id, + provider: generated.provider, + asset_object_id: generated.asset_object_id, + asset_kind: generated.asset_kind, + audio_src, + prompt: Some(normalized_prompt), + title: None, + updated_at: Some(current_utc_iso_text()), + }) + } + .await; + record_creation_audio_generation_run( + state, + "sound_effect", + request_payload, + started_at_micros, + &outcome, + ) + .await; + outcome } pub(crate) async fn generate_background_music_asset_for_creation( @@ -68,6 +95,7 @@ pub(crate) async fn generate_background_music_asset_for_creation( model: Option, target: GeneratedCreationAudioTarget, ) -> Result { + let started_at_micros = current_utc_micros(); let normalized_prompt = platform_audio::normalize_limited_text_allow_empty( &prompt, "prompt", @@ -80,43 +108,111 @@ pub(crate) async fn generate_background_music_asset_for_creation( platform_audio::SUNO_TITLE_MAX_CHARS, ) .map_err(map_platform_audio_error)?; - let task = create_background_music_task_response( - state, - normalized_prompt.clone(), - normalized_title.clone(), - tags, - model, - ) - .await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::BackgroundMusic, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; + let request_payload = json!({ + "kind": "background_music", + "promptChars": normalized_prompt.chars().count(), + "titleChars": normalized_title.chars().count(), + "hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()), + "model": model, + "targetEntityKind": target.entity_kind, + "targetEntityId": target.entity_id, + "targetSlot": target.slot, + "targetAssetKind": target.asset_kind, + }); + let outcome = async { + let task = create_background_music_task_response( + state, + normalized_prompt.clone(), + normalized_title.clone(), + tags, + model, + ) + .await?; + let target = AudioAssetBindingTarget { + storage_scope: target.entity_kind.clone(), + entity_kind: target.entity_kind, + entity_id: target.entity_id, + slot: target.slot, + asset_kind: target.asset_kind, + profile_id: target.profile_id, + storage_prefix: target.storage_prefix, + }; + let generated = wait_for_generated_audio_asset( + state, + owner_user_id, + task.task_id.clone(), + AudioAssetSlot::BackgroundMusic, + target, + ) + .await?; + let audio_src = generated + .audio_src + .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; - Ok(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: Some(normalized_title), - updated_at: Some(current_utc_iso_text()), - }) + Ok::<_, AppError>(creation_audio::CreationAudioAsset { + task_id: generated.task_id, + provider: generated.provider, + asset_object_id: generated.asset_object_id, + asset_kind: generated.asset_kind, + audio_src, + prompt: Some(normalized_prompt), + title: Some(normalized_title), + updated_at: Some(current_utc_iso_text()), + }) + } + .await; + record_creation_audio_generation_run( + state, + "background_music", + request_payload, + started_at_micros, + &outcome, + ) + .await; + outcome +} + +async fn record_creation_audio_generation_run( + state: &AppState, + operation: &'static str, + request_payload: serde_json::Value, + started_at_micros: i64, + outcome: &Result, +) { + match outcome { + Ok(asset) => { + record_external_generation_run_after_success( + state, + asset.provider.as_str(), + operation, + "创作音频生成", + request_payload, + started_at_micros, + true, + None, + Some(asset.task_id.clone()), + Some(json!({ + "assetObjectId": asset.asset_object_id, + "assetKind": asset.asset_kind, + "hasAudioSrc": !asset.audio_src.trim().is_empty(), + })), + ) + .await; + } + Err(error) => { + record_external_generation_run_after_success( + state, + "vector-engine-audio", + operation, + "创作音频生成", + request_payload, + started_at_micros, + false, + Some(error.to_string()), + None, + None, + ) + .await; + } + } } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 7763ea0e..28e31f2c 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -20,8 +20,8 @@ use shared_contracts::wooden_fish::{ WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, - WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest, - WoodenFishWorksResponse, + WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorksResponse, + WoodenFishWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; @@ -758,7 +758,7 @@ fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String { fn build_wooden_fish_background_prompt(prompt: &str) -> String { format!( - "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}", + "生成敲木鱼背景,要求主题、画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,只生成竖屏背景环境图,不生成、不描绘、不暗示新木鱼物品本体,也不要出现木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,中央区域是运行态叠放敲击物的留白区域,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } @@ -1228,14 +1228,17 @@ mod tests { fn wooden_fish_background_prompt_uses_hidden_image2_flow() { let prompt = build_wooden_fish_background_prompt("苹果"); - assert!(prompt.contains( - "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。" - )); + assert!(prompt.contains("只生成竖屏背景环境图")); + assert!(prompt.contains("不生成、不描绘、不暗示新木鱼物品本体")); + assert!(prompt.contains("不要出现木槌互动物品")); + assert!(!prompt.contains("木鱼预设在屏幕中央位置")); + assert!(!prompt.contains("木鱼主体周围元素保持干净")); assert!(prompt.contains("尺寸竖屏9:16")); assert!(prompt.contains("抠图完成后的透明图")); assert!(prompt.contains("不继承任何绿色底色")); assert!(prompt.contains("完整不透明的背景环境图")); assert!(prompt.contains("中央主体预留区")); + assert!(prompt.contains("中央区域是运行态叠放敲击物的留白区域")); assert!(prompt.contains("禁止出现主题主体")); assert!(prompt.contains("苹果")); assert!(prompt.contains("不得把主题物品画在画面中央")); diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs index 38b4bea6..2afc2447 100644 --- a/server-rs/crates/api-server/src/work_author.rs +++ b/server-rs/crates/api-server/src/work_author.rs @@ -2,6 +2,10 @@ use module_auth::AuthUser; use crate::state::{AppState, PuzzleApiState}; +pub const ORPHAN_WORK_OWNER_USER_ID: &str = "wx-openid-placeholder"; +pub const ORPHAN_WORK_AUTHOR_DISPLAY_NAME: &str = "失效作者"; +pub const ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE: &str = "SY-00000000"; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkAuthorSummary { pub display_name: String, @@ -45,21 +49,15 @@ fn resolve_work_author_by_user_id_with_service( ) -> WorkAuthorSummary { let fallback_display_name = normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string()); - let fallback_public_user_code = normalize_optional_text(fallback_public_user_code); + let _fallback_public_user_code = normalize_optional_text(fallback_public_user_code); let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else { - return WorkAuthorSummary { - display_name: fallback_display_name, - public_user_code: fallback_public_user_code, - }; + return orphan_work_author_summary(); }; match auth_user_service.get_user_by_id(&owner_user_id) { Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name), - Ok(None) | Err(_) => WorkAuthorSummary { - display_name: fallback_display_name, - public_user_code: fallback_public_user_code, - }, + Ok(None) | Err(_) => orphan_work_author_summary(), } } @@ -80,3 +78,65 @@ fn normalize_optional_text(value: Option<&str>) -> Option { .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } + +fn orphan_work_author_summary() -> WorkAuthorSummary { + WorkAuthorSummary { + display_name: ORPHAN_WORK_AUTHOR_DISPLAY_NAME.to_string(), + public_user_code: Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()), + } +} + +/// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id,避免把有效作品误转给占位账号。 +pub fn should_rebind_orphan_work_owner( + auth_user_service: &module_auth::AuthUserService, + owner_user_id: &str, +) -> bool { + let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else { + return true; + }; + if owner_user_id == ORPHAN_WORK_OWNER_USER_ID { + return false; + } + + !matches!(auth_user_service.get_user_by_id(&owner_user_id), Ok(Some(_))) +} + +#[cfg(test)] +mod tests { + use module_auth::{AuthUserService, InMemoryAuthStore}; + + use super::*; + + #[test] + fn orphan_work_author_summary_uses_placeholder_account() { + assert_eq!( + orphan_work_author_summary(), + WorkAuthorSummary { + display_name: "失效作者".to_string(), + public_user_code: Some("SY-00000000".to_string()), + } + ); + } + + #[test] + fn missing_author_resolves_to_placeholder_account() { + let service = AuthUserService::new(InMemoryAuthStore::default()); + + let author = resolve_work_author_by_user_id_with_service( + &service, + "user_missing", + Some("历史昵称"), + Some("SY-00000001"), + ); + + assert_eq!(author, orphan_work_author_summary()); + } + #[test] + fn should_rebind_orphan_work_owner_detects_missing_and_empty_author() { + let service = AuthUserService::new(InMemoryAuthStore::default()); + + assert!(should_rebind_orphan_work_owner(&service, "")); + assert!(should_rebind_orphan_work_owner(&service, "user_missing")); + assert!(!should_rebind_orphan_work_owner(&service, ORPHAN_WORK_OWNER_USER_ID)); + } +} diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index a7999183..ac63f925 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -237,6 +237,22 @@ pub fn build_system_username(prefix: &str, sequence: u64) -> String { format!("{prefix}_{sequence:08}") } +pub fn build_wechat_username(display_name: &str, provider_uid: &str) -> String { + let normalized_display_name = display_name.trim(); + let normalized_provider_uid = provider_uid.trim(); + let fallback_display_name = if normalized_display_name.is_empty() { + "微信旅人" + } else { + normalized_display_name + }; + let fallback_provider_uid = if normalized_provider_uid.is_empty() { + "openid" + } else { + normalized_provider_uid + }; + format!("{fallback_display_name}_{fallback_provider_uid}") +} + // 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。 pub fn build_public_user_code(sequence: u64) -> String { format!("SY-{sequence:08}") diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 0bbc7f00..2608c1cb 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -12,8 +12,6 @@ pub use events::*; use std::{ collections::HashMap, - fs, - path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -33,7 +31,6 @@ use tracing::{info, warn}; #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, - persistence_path: Option>, } #[derive(Debug)] @@ -804,6 +801,21 @@ impl AuthUserService { Self { store } } + pub fn ensure_orphan_work_owner_user( + &self, + user_id: &str, + username: &str, + display_name: &str, + public_user_code: &str, + ) -> Result { + self.store.ensure_orphan_work_owner_user( + user_id, + username, + display_name, + public_user_code, + ) + } + pub fn get_user_by_id(&self, user_id: &str) -> Result, LogoutError> { self.store .find_by_user_id(user_id) @@ -888,7 +900,6 @@ impl Default for InMemoryAuthStore { fn default() -> Self { Self { inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), - persistence_path: None, } } } @@ -937,14 +948,6 @@ impl InMemoryAuthStoreState { } } -fn build_temp_persistence_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("auth-store.json"); - path.with_file_name(format!("{file_name}.tmp")) -} - impl InMemoryAuthStore { pub fn from_snapshot_json(snapshot_json: &str) -> Result { let snapshot = serde_json::from_str::(snapshot_json) @@ -953,25 +956,6 @@ impl InMemoryAuthStore { inner: Arc::new(Mutex::new( InMemoryAuthStoreState::from_persistent_snapshot(snapshot), )), - persistence_path: None, - }) - } - - pub fn from_persistence_path(path: impl Into) -> Result { - let path = path.into(); - let state = if path.is_file() { - let raw_text = - fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?; - let snapshot = serde_json::from_str::(&raw_text) - .map_err(|error| format!("解析认证快照失败:{error}"))?; - InMemoryAuthStoreState::from_persistent_snapshot(snapshot) - } else { - InMemoryAuthStoreState::default() - }; - - Ok(Self { - inner: Arc::new(Mutex::new(state)), - persistence_path: Some(Arc::new(path)), }) } @@ -986,30 +970,8 @@ impl InMemoryAuthStore { } fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { - let Some(path) = self.persistence_path.as_deref() else { - return Ok(()); - }; - - if let Some(parent_dir) = path.parent() { - fs::create_dir_all(parent_dir).map_err(|error| { - format!( - "创建认证快照目录失败:{},路径:{}", - error, - parent_dir.display() - ) - })?; - } - - let snapshot = state.to_persistent_snapshot(); - let raw_text = serde_json::to_string_pretty(&snapshot) - .map_err(|error| format!("序列化认证快照失败:{error}"))?; - let temp_path = build_temp_persistence_path(path); - fs::write(&temp_path, raw_text) - .map_err(|error| format!("写入认证快照临时文件失败:{error}"))?; - fs::rename(&temp_path, path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!("替换认证快照文件失败:{error}") - }) + let _ = state; + Ok(()) } fn persist_password_state( @@ -1051,6 +1013,68 @@ impl InMemoryAuthStore { .cloned()) } + fn ensure_orphan_work_owner_user( + &self, + user_id: &str, + username: &str, + display_name: &str, + public_user_code: &str, + ) -> Result { + let user_id = normalize_required_string(user_id).ok_or_else(|| { + PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string()) + })?; + let username = normalize_required_string(username).ok_or_else(|| { + PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string()) + })?; + let display_name = normalize_required_string(display_name).ok_or_else(|| { + PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string()) + })?; + let public_user_code = normalize_required_string(public_user_code).ok_or_else(|| { + PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string()) + })?; + + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if let Some(stored) = state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == user_id) + { + return Ok(stored.user.clone()); + } + + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) + })?; + let user = AuthUser { + id: user_id, + public_user_code, + username: username.clone(), + display_name, + avatar_url: None, + phone_number_masked: None, + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + token_version: 1, + created_at, + }; + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash: String::new(), + password_login_enabled: false, + phone_number: None, + }, + ); + self.persist_password_state(&state)?; + + Ok(user) + } + fn find_by_public_user_code( &self, public_user_code: &str, @@ -1153,7 +1177,7 @@ impl InMemoryAuthStore { PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}")) })?; let sequence = state.next_user_id; - let user_id = format!("user_{sequence:08}"); + let user_id = build_prefixed_uuid_id("user_"); let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; let username = build_system_username("phone", state.next_user_id); @@ -1205,7 +1229,7 @@ impl InMemoryAuthStore { PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) })?; let sequence = state.next_user_id; - let user_id = format!("user_{sequence:08}"); + let user_id = build_prefixed_uuid_id("user_"); let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; let username = build_system_username("phone", state.next_user_id); @@ -1253,10 +1277,9 @@ impl InMemoryAuthStore { WechatAuthError::Store(format!("用户创建时间格式化失败:{message}")) })?; let sequence = state.next_user_id; - let user_id = format!("user_{sequence:08}"); + let user_id = build_prefixed_uuid_id("user_"); let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; - let username = build_system_username("wechat", state.next_user_id); let avatar_url = normalize_optional_string(profile.avatar_url.clone()); let display_name = profile .display_name @@ -1265,6 +1288,7 @@ impl InMemoryAuthStore { .filter(|value| !value.is_empty()) .unwrap_or("微信旅人") .to_string(); + let username = build_wechat_username(&display_name, &profile.provider_uid); let user = AuthUser { id: user_id.clone(), public_user_code, @@ -2224,6 +2248,18 @@ mod tests { use super::*; + #[test] + fn build_wechat_username_uses_display_name_and_provider_uid() { + assert_eq!( + build_wechat_username("小明", "wx-openid-123"), + "小明_wx-openid-123" + ); + assert_eq!( + build_wechat_username(" ", "wx-openid-123"), + "微信旅人_wx-openid-123" + ); + } + fn build_store() -> InMemoryAuthStore { InMemoryAuthStore::default() } @@ -2552,15 +2588,8 @@ mod tests { } #[tokio::test] - async fn persistent_store_restores_user_and_refresh_session_after_restart() { - let store_path = std::env::temp_dir().join(format!( - "genarrative-auth-store-{}.json", - new_uuid_simple_string() - )); - let _ = std::fs::remove_file(&store_path); - - let store = InMemoryAuthStore::from_persistence_path(store_path.clone()) - .expect("persistent store should initialize"); + async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() { + let store = InMemoryAuthStore::default(); let user = create_phone_login_user(store.clone(), "13800138003").await; let password_service = build_password_service(store.clone()); let refresh_service = build_refresh_service(store.clone()); @@ -2583,10 +2612,12 @@ mod tests { OffsetDateTime::now_utc(), ) .expect("refresh session should be persisted"); - drop(store); - let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone()) - .expect("persistent store should restore"); + let snapshot_json = store + .export_snapshot_json() + .expect("snapshot export should succeed"); + let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) + .expect("snapshot json should restore"); let restored_user = build_password_service(restored_store.clone()) .get_user_by_id(&user.id) .expect("restored user query should succeed") @@ -2604,8 +2635,6 @@ mod tests { ) .expect("restored refresh session should rotate"); assert_eq!(rotated.user.id, user.id); - - let _ = std::fs::remove_file(&store_path); } #[tokio::test] @@ -3211,6 +3240,9 @@ mod tests { first_wechat.user.binding_status, AuthBindingStatus::PendingBindPhone ); + assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first"); + assert!(first_wechat.user.id.starts_with("user_")); + assert!(!first_wechat.user.id.ends_with("00000001")); let second_wechat = wechat_service .resolve_login(ResolveWechatLoginInput { @@ -3229,6 +3261,7 @@ mod tests { assert_eq!(second_wechat.user.id, first_wechat.user.id); assert_ne!(second_wechat.user.id, phone_user.id); assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat); + assert_eq!(second_wechat.user.username, first_wechat.user.username); } #[tokio::test] diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index 0c0db1a1..df8b1c4f 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -1811,7 +1811,10 @@ pub fn select_runtime_next_profile<'a>( prefer_similar_work: bool, ) -> Option<&'a PuzzleWorkProfile> { if prefer_similar_work { - similar_work_profiles.first().copied().or(same_work_next_profile) + similar_work_profiles + .first() + .copied() + .or(same_work_next_profile) } else { same_work_next_profile.or_else(|| similar_work_profiles.first().copied()) } @@ -3281,7 +3284,10 @@ mod tests { assert_eq!(failed.generation_status, "failed"); assert_eq!(failed.levels[0].generation_status, "failed"); assert_eq!(failed.levels[1].generation_status, "ready"); - assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png")); + assert_eq!( + failed.levels[1].cover_image_src.as_deref(), + Some("/ready.png") + ); } #[test] @@ -3338,12 +3344,8 @@ mod tests { let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]); let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]); let similar_work_profiles = [&similar_work]; - let selected = select_runtime_next_profile( - Some(&same_work), - &similar_work_profiles, - true, - ) - .expect("should select similar work first"); + let selected = select_runtime_next_profile(Some(&same_work), &similar_work_profiles, true) + .expect("should select similar work first"); assert_eq!(selected.profile_id, "similar"); } diff --git a/server-rs/crates/platform-hyper3d/src/response/tests.rs b/server-rs/crates/platform-hyper3d/src/response/tests.rs index e93b1b94..e4016dfd 100644 --- a/server-rs/crates/platform-hyper3d/src/response/tests.rs +++ b/server-rs/crates/platform-hyper3d/src/response/tests.rs @@ -1,11 +1,11 @@ use serde_json::json; use shared_contracts::hyper3d as contract; +use super::status::normalize_task_status; use super::{ build_submit_response, extract_download_files, extract_job_statuses, resolve_hyper3d_overall_status, }; -use super::status::normalize_task_status; #[test] fn extracts_submit_response_from_nested_payload() { diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index e0f8faa1..057de781 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -20,46 +20,6 @@ impl SpacetimeClient { .await } - pub async fn get_auth_store_snapshot( - &self, - ) -> Result { - self.call_after_connect("get_auth_store_snapshot", move |connection, sender| { - connection - .procedures() - .get_auth_store_snapshot_then(move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_procedure_result); - send_once(&sender, mapped); - }); - }) - .await - } - - pub async fn upsert_auth_store_snapshot( - &self, - snapshot_json: String, - updated_at_micros: i64, - ) -> Result { - let procedure_input = AuthStoreSnapshotUpsertInput { - snapshot_json, - updated_at_micros, - }; - - self.call_after_connect("upsert_auth_store_snapshot", move |connection, sender| { - connection.procedures().upsert_auth_store_snapshot_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_procedure_result); - send_once(&sender, mapped); - }, - ); - }) - .await - } - pub async fn import_auth_store_snapshot_json( &self, snapshot_json: String, @@ -85,20 +45,4 @@ impl SpacetimeClient { ) .await } - - pub async fn import_auth_store_snapshot( - &self, - ) -> Result { - self.call_after_connect("import_auth_store_snapshot", move |connection, sender| { - connection - .procedures() - .import_auth_store_snapshot_then(move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_import_procedure_result); - send_once(&sender, mapped); - }); - }) - .await - } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f8a5c00a..f6bb3217 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -53,15 +53,15 @@ pub use mapper::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 1dde49d0..3d6fd06a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -102,15 +102,15 @@ pub use self::puzzle::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, }; diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 85016f4c..a07934b1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -344,7 +344,6 @@ pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; pub mod finish_wooden_fish_run_procedure; pub mod generate_big_fish_asset_procedure; -pub mod get_auth_store_snapshot_procedure; pub mod get_bark_battle_run_procedure; pub mod get_bark_battle_runtime_config_procedure; pub mod get_battle_state_procedure; @@ -393,7 +392,6 @@ pub mod grant_new_user_registration_wallet_reward_procedure; pub mod grant_player_progression_experience_and_return_procedure; pub mod grant_player_progression_experience_reducer; pub mod import_auth_store_snapshot_json_procedure; -pub mod import_auth_store_snapshot_procedure; pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; pub mod import_database_migration_incremental_from_chunks_procedure; @@ -942,7 +940,6 @@ pub mod update_puzzle_work_procedure; pub mod update_square_hole_work_procedure; pub mod update_visual_novel_work_procedure; pub mod update_wooden_fish_work_procedure; -pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; pub mod upsert_chapter_progression_reducer; pub mod upsert_creation_entry_type_config_procedure; @@ -1379,7 +1376,6 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; -pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot; pub use get_bark_battle_run_procedure::get_bark_battle_run; pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config; pub use get_battle_state_procedure::get_battle_state; @@ -1428,7 +1424,6 @@ pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_regi pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; pub use grant_player_progression_experience_reducer::grant_player_progression_experience; pub use import_auth_store_snapshot_json_procedure::import_auth_store_snapshot_json; -pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot; pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks; pub use import_database_migration_from_file_procedure::import_database_migration_from_file; pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; @@ -1977,7 +1972,6 @@ pub use update_puzzle_work_procedure::update_puzzle_work; pub use update_square_hole_work_procedure::update_square_hole_work; pub use update_visual_novel_work_procedure::update_visual_novel_work; pub use update_wooden_fish_work_procedure::update_wooden_fish_work; -pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; pub use upsert_chapter_progression_reducer::upsert_chapter_progression; pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs deleted file mode 100644 index 61c5f8fd..00000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -use super::auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -struct GetAuthStoreSnapshotArgs {} - -impl __sdk::InModule for GetAuthStoreSnapshotArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the procedure `get_auth_store_snapshot`. -/// -/// Implemented for [`super::RemoteProcedures`]. -pub trait get_auth_store_snapshot { - fn get_auth_store_snapshot(&self) { - self.get_auth_store_snapshot_then(|_, _| {}); - } - - fn get_auth_store_snapshot_then( - &self, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ); -} - -impl get_auth_store_snapshot for super::RemoteProcedures { - fn get_auth_store_snapshot_then( - &self, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ) { - self.imp - .invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>( - "get_auth_store_snapshot", - GetAuthStoreSnapshotArgs {}, - __callback, - ); - } -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs deleted file mode 100644 index b4c2dd4f..00000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -use super::auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -struct ImportAuthStoreSnapshotArgs {} - -impl __sdk::InModule for ImportAuthStoreSnapshotArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the procedure `import_auth_store_snapshot`. -/// -/// Implemented for [`super::RemoteProcedures`]. -pub trait import_auth_store_snapshot { - fn import_auth_store_snapshot(&self) { - self.import_auth_store_snapshot_then(|_, _| {}); - } - - fn import_auth_store_snapshot_then( - &self, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ); -} - -impl import_auth_store_snapshot for super::RemoteProcedures { - fn import_auth_store_snapshot_then( - &self, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ) { - self.imp - .invoke_procedure_with_callback::<_, AuthStoreSnapshotImportProcedureResult>( - "import_auth_store_snapshot", - ImportAuthStoreSnapshotArgs {}, - __callback, - ); - } -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs deleted file mode 100644 index 5d4da3cb..00000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs +++ /dev/null @@ -1,59 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -use super::auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; -use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -struct UpsertAuthStoreSnapshotArgs { - pub input: AuthStoreSnapshotUpsertInput, -} - -impl __sdk::InModule for UpsertAuthStoreSnapshotArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the procedure `upsert_auth_store_snapshot`. -/// -/// Implemented for [`super::RemoteProcedures`]. -pub trait upsert_auth_store_snapshot { - fn upsert_auth_store_snapshot(&self, input: AuthStoreSnapshotUpsertInput) { - self.upsert_auth_store_snapshot_then(input, |_, _| {}); - } - - fn upsert_auth_store_snapshot_then( - &self, - input: AuthStoreSnapshotUpsertInput, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ); -} - -impl upsert_auth_store_snapshot for super::RemoteProcedures { - fn upsert_auth_store_snapshot_then( - &self, - input: AuthStoreSnapshotUpsertInput, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ) { - self.imp - .invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>( - "upsert_auth_store_snapshot", - UpsertAuthStoreSnapshotArgs { input }, - __callback, - ); - } -} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 0d730c0f..25ec5ad9 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -183,15 +183,12 @@ impl SpacetimeClient { move |connection, sender| { connection .procedures() - .mark_puzzle_draft_generation_failed_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_puzzle_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); + .mark_puzzle_draft_generation_failed_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); }, ) .await diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 7b9ee01d..d4be23ee 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -1,3 +1,5 @@ +use serde::Serialize; + use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; use super::{ @@ -13,8 +15,14 @@ use super::{ }, }; -const AUTH_STORE_SNAPSHOT_ID: &str = "default"; const AUTH_STORE_PROJECTION_META_ID: &str = "default"; +const AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID: &str = "meta/next_user_id"; +const AUTH_STORE_SNAPSHOT_USER_PREFIX: &str = "user/"; +const AUTH_STORE_SNAPSHOT_PHONE_PREFIX: &str = "phone/"; +const AUTH_STORE_SNAPSHOT_SESSION_PREFIX: &str = "session/"; +const AUTH_STORE_SNAPSHOT_SESSION_HASH_PREFIX: &str = "session_hash/"; +const AUTH_STORE_SNAPSHOT_WECHAT_PREFIX: &str = "wechat/"; +const AUTH_STORE_SNAPSHOT_UNION_PREFIX: &str = "union/"; #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreSnapshotRecord { @@ -41,6 +49,74 @@ fn normalize_user_account_tags( module_runtime::normalize_profile_user_tags(tags.unwrap_or_default()) } +fn prefixed_snapshot_id(prefix: &str, value: &str) -> String { + format!("{prefix}{}", sanitize_identity_component(value)) +} + +fn upsert_auth_snapshot_row( + ctx: &ReducerContext, + snapshot_id: String, + snapshot_json: String, + updated_at: Timestamp, +) { + if ctx + .db + .auth_store_snapshot() + .snapshot_id() + .find(&snapshot_id) + .is_some() + { + ctx.db.auth_store_snapshot().snapshot_id().delete(&snapshot_id); + } + + ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot { + snapshot_id, + snapshot_json, + updated_at, + }); +} + +fn auth_store_snapshot_user_row_id(user_id: &str) -> String { + prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_USER_PREFIX, user_id) +} + +fn auth_store_snapshot_phone_row_id(phone_number: &str, user_id: &str) -> String { + prefixed_snapshot_id( + AUTH_STORE_SNAPSHOT_PHONE_PREFIX, + &format!("{phone_number}|{user_id}"), + ) +} + +fn auth_store_snapshot_session_row_id(session_id: &str) -> String { + prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_SESSION_PREFIX, session_id) +} + +fn auth_store_snapshot_session_hash_row_id(refresh_token_hash: &str, session_id: &str) -> String { + prefixed_snapshot_id( + AUTH_STORE_SNAPSHOT_SESSION_HASH_PREFIX, + &format!("{refresh_token_hash}|{session_id}"), + ) +} + +fn auth_store_snapshot_wechat_row_id(provider_uid: &str, user_id: &str) -> String { + prefixed_snapshot_id( + AUTH_STORE_SNAPSHOT_WECHAT_PREFIX, + &format!("{provider_uid}|{user_id}"), + ) +} + +fn auth_store_snapshot_union_row_id(union_id: &str, user_id: &str) -> String { + prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_UNION_PREFIX, &format!("{union_id}|{user_id}")) +} + +fn snapshot_has_user_rows(snapshot: &PersistentAuthStoreSnapshot) -> bool { + !snapshot.users_by_username.is_empty() +} + +fn to_snapshot_row_json(label: &str, value: &T) -> Result { + serde_json::to_string(value).map_err(|error| format!("{label} 序列化失败:{error}")) +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreSnapshotImportRecord { pub imported_user_count: u32, @@ -55,44 +131,7 @@ pub struct AuthStoreSnapshotImportProcedureResult { pub error_message: Option, } -// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态。 -#[spacetimedb::procedure] -pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult { - match ctx.try_with_tx(|tx| get_auth_store_snapshot_tx(tx)) { - Ok(record) => AuthStoreSnapshotProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AuthStoreSnapshotProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 历史迁移入口:覆盖写入整份快照,供旧库从 `auth_store_snapshot/default` 导入正式表。 -#[spacetimedb::procedure] -pub fn upsert_auth_store_snapshot( - ctx: &mut ProcedureContext, - input: AuthStoreSnapshotUpsertInput, -) -> AuthStoreSnapshotProcedureResult { - match ctx.try_with_tx(|tx| upsert_auth_store_snapshot_tx(tx, input.clone())) { - Ok(record) => AuthStoreSnapshotProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AuthStoreSnapshotProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// Axum 运行期认证变更直接导入正式认证表,不再继续刷新 `auth_store_snapshot/default`。 +// Axum 运行期认证变更直接导入正式认证表,并把快照拆成行级记录;禁止再写 `auth_store_snapshot/default`。 #[spacetimedb::procedure] pub fn import_auth_store_snapshot_json( ctx: &mut ProcedureContext, @@ -112,24 +151,6 @@ pub fn import_auth_store_snapshot_json( } } -#[spacetimedb::procedure] -pub fn import_auth_store_snapshot( - ctx: &mut ProcedureContext, -) -> AuthStoreSnapshotImportProcedureResult { - match ctx.try_with_tx(|tx| import_auth_store_snapshot_tx(tx)) { - Ok(record) => AuthStoreSnapshotImportProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AuthStoreSnapshotImportProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - // Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。 #[spacetimedb::procedure] pub fn export_auth_store_snapshot_from_tables( @@ -149,78 +170,6 @@ pub fn export_auth_store_snapshot_from_tables( } } -fn get_auth_store_snapshot_tx(ctx: &ReducerContext) -> Result { - Ok( - match ctx - .db - .auth_store_snapshot() - .snapshot_id() - .find(&AUTH_STORE_SNAPSHOT_ID.to_string()) - { - Some(row) => AuthStoreSnapshotRecord { - snapshot_json: Some(row.snapshot_json), - updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), - }, - None => AuthStoreSnapshotRecord { - snapshot_json: None, - updated_at_micros: None, - }, - }, - ) -} - -fn upsert_auth_store_snapshot_tx( - ctx: &ReducerContext, - input: AuthStoreSnapshotUpsertInput, -) -> Result { - let snapshot_json = input.snapshot_json.trim().to_string(); - if snapshot_json.is_empty() { - return Err("认证快照 JSON 不能为空".to_string()); - } - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - - if ctx - .db - .auth_store_snapshot() - .snapshot_id() - .find(&AUTH_STORE_SNAPSHOT_ID.to_string()) - .is_some() - { - ctx.db - .auth_store_snapshot() - .snapshot_id() - .delete(&AUTH_STORE_SNAPSHOT_ID.to_string()); - } - - ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot { - snapshot_id: AUTH_STORE_SNAPSHOT_ID.to_string(), - snapshot_json: snapshot_json.clone(), - updated_at, - }); - - Ok(AuthStoreSnapshotRecord { - snapshot_json: Some(snapshot_json), - updated_at_micros: Some(input.updated_at_micros), - }) -} - -fn import_auth_store_snapshot_tx( - ctx: &ReducerContext, -) -> Result { - let snapshot = ctx - .db - .auth_store_snapshot() - .snapshot_id() - .find(&AUTH_STORE_SNAPSHOT_ID.to_string()) - .ok_or_else(|| "认证快照不存在,无法导入正式表".to_string())?; - - import_auth_store_snapshot_json_value_tx( - ctx, - &snapshot.snapshot_json, - snapshot.updated_at.to_micros_since_unix_epoch(), - ) -} - fn import_auth_store_snapshot_json_tx( ctx: &ReducerContext, input: AuthStoreSnapshotUpsertInput, @@ -239,8 +188,11 @@ fn import_auth_store_snapshot_json_value_tx( } let parsed = serde_json::from_str::(snapshot_json) .map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?; + if !snapshot_has_user_rows(&parsed) { + return Err("认证快照缺少用户记录,拒绝导入正式表".to_string()); + } - clear_auth_target_tables(ctx); + upsert_auth_store_snapshot_rows(ctx, &parsed, updated_at_micros)?; upsert_auth_projection_meta(ctx, updated_at_micros); let mut imported_user_count = 0_u32; @@ -249,8 +201,18 @@ fn import_auth_store_snapshot_json_value_tx( for stored_user in parsed.users_by_username.into_values() { let user = stored_user.user; + let user_id = user.id.clone(); + if ctx + .db + .user_account() + .user_id() + .find(&user_id) + .is_some() + { + ctx.db.user_account().user_id().delete(&user_id); + } ctx.db.user_account().insert(UserAccount { - user_id: user.id.clone(), + user_id: user_id.clone(), public_user_code: user.public_user_code, username: user.username, display_name: user.display_name, @@ -271,9 +233,19 @@ fn import_auth_store_snapshot_json_value_tx( imported_user_count += 1; if let Some(phone_number) = stored_user.phone_number { + let identity_id = format!("authi_phone_{}", sanitize_identity_component(&phone_number)); + if ctx + .db + .auth_identity() + .identity_id() + .find(&identity_id) + .is_some() + { + ctx.db.auth_identity().identity_id().delete(&identity_id); + } ctx.db.auth_identity().insert(AuthIdentity { - identity_id: format!("authi_phone_{}", sanitize_identity_component(&phone_number)), - user_id: user.id, + identity_id, + user_id, provider: "phone".to_string(), provider_uid: phone_number.clone(), provider_union_id: None, @@ -286,11 +258,21 @@ fn import_auth_store_snapshot_json_value_tx( } for identity in parsed.wechat_identity_by_provider_uid.into_values() { + let identity_id = format!( + "authi_wechat_{}", + sanitize_identity_component(&identity.provider_uid) + ); + if ctx + .db + .auth_identity() + .identity_id() + .find(&identity_id) + .is_some() + { + ctx.db.auth_identity().identity_id().delete(&identity_id); + } ctx.db.auth_identity().insert(AuthIdentity { - identity_id: format!( - "authi_wechat_{}", - sanitize_identity_component(&identity.provider_uid) - ), + identity_id, user_id: identity.user_id, provider: "wechat".to_string(), provider_uid: identity.provider_uid, @@ -306,6 +288,18 @@ fn import_auth_store_snapshot_json_value_tx( let session = stored_session.session; let client_info_json = serde_json::to_string(&session.client_info) .map_err(|error| format!("客户端身份序列化失败:{error}"))?; + if ctx + .db + .refresh_session() + .session_id() + .find(&session.session_id) + .is_some() + { + ctx.db + .refresh_session() + .session_id() + .delete(&session.session_id); + } ctx.db.refresh_session().insert(RefreshSession { session_id: session.session_id, user_id: session.user_id, @@ -328,6 +322,120 @@ fn import_auth_store_snapshot_json_value_tx( }) } +fn upsert_auth_store_snapshot_rows( + ctx: &ReducerContext, + snapshot: &PersistentAuthStoreSnapshot, + updated_at_micros: i64, +) -> Result<(), String> { + let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); + let desired_ids = auth_store_snapshot_row_ids(snapshot); + for row in ctx.db.auth_store_snapshot().iter().collect::>() { + if !desired_ids.contains(&row.snapshot_id) { + ctx.db + .auth_store_snapshot() + .snapshot_id() + .delete(&row.snapshot_id); + } + } + + upsert_auth_snapshot_row( + ctx, + AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID.to_string(), + to_snapshot_row_json("认证快照 next_user_id", &snapshot.next_user_id)?, + updated_at, + ); + + for user in snapshot.users_by_username.values() { + upsert_auth_snapshot_row( + ctx, + auth_store_snapshot_user_row_id(&user.user.id), + to_snapshot_row_json("认证快照用户", user)?, + updated_at, + ); + } + + for (phone_number, user_id) in &snapshot.phone_to_user_id { + upsert_auth_snapshot_row( + ctx, + auth_store_snapshot_phone_row_id(phone_number, user_id), + to_snapshot_row_json("认证快照手机号索引", user_id)?, + updated_at, + ); + } + + for session in snapshot.sessions_by_id.values() { + upsert_auth_snapshot_row( + ctx, + auth_store_snapshot_session_row_id(&session.session.session_id), + to_snapshot_row_json("认证快照会话", session)?, + updated_at, + ); + } + + for (refresh_token_hash, session_id) in &snapshot.session_id_by_refresh_token_hash { + upsert_auth_snapshot_row( + ctx, + auth_store_snapshot_session_hash_row_id(refresh_token_hash, session_id), + to_snapshot_row_json("认证快照 refresh token 索引", session_id)?, + updated_at, + ); + } + + for identity in snapshot.wechat_identity_by_provider_uid.values() { + upsert_auth_snapshot_row( + ctx, + auth_store_snapshot_wechat_row_id(&identity.provider_uid, &identity.user_id), + to_snapshot_row_json("认证快照微信身份", identity)?, + updated_at, + ); + } + + for (union_id, user_id) in &snapshot.user_id_by_provider_union_id { + upsert_auth_snapshot_row( + ctx, + auth_store_snapshot_union_row_id(union_id, user_id), + to_snapshot_row_json("认证快照微信 union 索引", user_id)?, + updated_at, + ); + } + + Ok(()) +} + +fn auth_store_snapshot_row_ids( + snapshot: &PersistentAuthStoreSnapshot, +) -> std::collections::HashSet { + let mut ids = std::collections::HashSet::new(); + ids.insert(AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID.to_string()); + for user in snapshot.users_by_username.values() { + ids.insert(auth_store_snapshot_user_row_id(&user.user.id)); + } + for (phone_number, user_id) in &snapshot.phone_to_user_id { + ids.insert(auth_store_snapshot_phone_row_id(phone_number, user_id)); + } + for session in snapshot.sessions_by_id.values() { + ids.insert(auth_store_snapshot_session_row_id( + &session.session.session_id, + )); + } + for (refresh_token_hash, session_id) in &snapshot.session_id_by_refresh_token_hash { + ids.insert(auth_store_snapshot_session_hash_row_id( + refresh_token_hash, + session_id, + )); + } + for identity in snapshot.wechat_identity_by_provider_uid.values() { + ids.insert(auth_store_snapshot_wechat_row_id( + &identity.provider_uid, + &identity.user_id, + )); + } + for (union_id, user_id) in &snapshot.user_id_by_provider_union_id { + ids.insert(auth_store_snapshot_union_row_id(union_id, user_id)); + } + ids +} + fn export_auth_store_snapshot_from_tables_tx( ctx: &ReducerContext, ) -> Result { @@ -455,6 +563,9 @@ fn export_auth_store_snapshot_from_tables_tx( wechat_identity_by_provider_uid, user_id_by_provider_union_id, }; + if let Some(updated_at_micros) = updated_at_micros { + upsert_auth_store_snapshot_rows(ctx, &snapshot, updated_at_micros)?; + } let snapshot_json = serde_json::to_string_pretty(&snapshot) .map_err(|error| format!("序列化认证快照失败:{error}"))?; @@ -464,24 +575,6 @@ fn export_auth_store_snapshot_from_tables_tx( }) } -fn clear_auth_target_tables(ctx: &ReducerContext) { - for row in ctx.db.refresh_session().iter().collect::>() { - ctx.db - .refresh_session() - .session_id() - .delete(&row.session_id); - } - for row in ctx.db.auth_identity().iter().collect::>() { - ctx.db - .auth_identity() - .identity_id() - .delete(&row.identity_id); - } - for row in ctx.db.user_account().iter().collect::>() { - ctx.db.user_account().user_id().delete(&row.user_id); - } -} - fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string(); if ctx @@ -503,3 +596,121 @@ fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_snapshot() -> PersistentAuthStoreSnapshot { + let user = StoredPasswordUserSnapshot { + user: AuthUserSnapshot { + id: "user_00000042".to_string(), + public_user_code: "GN-000042".to_string(), + username: "phone_42".to_string(), + display_name: "测试玩家".to_string(), + avatar_url: None, + phone_number_masked: Some("138****8000".to_string()), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: true, + token_version: 3, + user_tags: vec!["early".to_string()], + }, + password_hash: "hash-42".to_string(), + password_login_enabled: true, + phone_number: Some("+8613800008000".to_string()), + }; + let session = StoredRefreshSessionSnapshot { + session: RefreshSessionSnapshot { + session_id: "usess_42".to_string(), + user_id: "user_00000042".to_string(), + refresh_token_hash: "refresh-hash-42".to_string(), + issued_by_provider: "phone".to_string(), + client_info: serde_json::json!({"clientType":"web"}), + expires_at: "2026-06-01T00:00:00Z".to_string(), + revoked_at: None, + created_at: "2026-05-27T00:00:00Z".to_string(), + updated_at: "2026-05-27T00:00:00Z".to_string(), + last_seen_at: "2026-05-27T00:00:00Z".to_string(), + }, + }; + let identity = StoredWechatIdentitySnapshot { + user_id: "user_00000042".to_string(), + provider_uid: "wx-openid-42".to_string(), + provider_union_id: Some("wx-union-42".to_string()), + display_name: Some("微信玩家".to_string()), + avatar_url: None, + }; + + PersistentAuthStoreSnapshot { + next_user_id: 43, + users_by_username: std::collections::HashMap::from([( + "phone_42".to_string(), + user, + )]), + phone_to_user_id: std::collections::HashMap::from([( + "+8613800008000".to_string(), + "user_00000042".to_string(), + )]), + sessions_by_id: std::collections::HashMap::from([("usess_42".to_string(), session)]), + session_id_by_refresh_token_hash: std::collections::HashMap::from([( + "refresh-hash-42".to_string(), + "usess_42".to_string(), + )]), + wechat_identity_by_provider_uid: std::collections::HashMap::from([( + "wx-openid-42".to_string(), + identity, + )]), + user_id_by_provider_union_id: std::collections::HashMap::from([( + "wx-union-42".to_string(), + "user_00000042".to_string(), + )]), + } + } + + #[test] + fn auth_store_snapshot_row_ids_are_row_level_without_default_aggregate() { + let ids = auth_store_snapshot_row_ids(&sample_snapshot()); + + assert!(!ids.contains("default")); + assert!(ids.contains(AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID)); + assert!(ids.contains(&auth_store_snapshot_user_row_id("user_00000042"))); + assert!(ids.contains(&auth_store_snapshot_phone_row_id( + "+8613800008000", + "user_00000042" + ))); + assert!(ids.contains(&auth_store_snapshot_session_row_id("usess_42"))); + assert!(ids.contains(&auth_store_snapshot_session_hash_row_id( + "refresh-hash-42", + "usess_42" + ))); + assert!(ids.contains(&auth_store_snapshot_wechat_row_id( + "wx-openid-42", + "user_00000042" + ))); + assert!(ids.contains(&auth_store_snapshot_union_row_id( + "wx-union-42", + "user_00000042" + ))); + } + + #[test] + fn auth_store_snapshot_user_row_key_is_stable_after_username_change() { + let mut before = sample_snapshot(); + let mut after = sample_snapshot(); + after.users_by_username.clear(); + let mut renamed_user = before + .users_by_username + .remove("phone_42") + .expect("sample user exists"); + renamed_user.user.username = "renamed_42".to_string(); + after + .users_by_username + .insert("renamed_42".to_string(), renamed_user); + + assert_eq!( + auth_store_snapshot_row_ids(&before), + auth_store_snapshot_row_ids(&after) + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index bb71ba1e..80a73e2f 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -10,8 +10,8 @@ use module_puzzle::{ PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, - PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, - PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, + PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, @@ -2193,7 +2193,7 @@ fn advance_puzzle_next_level_tx( &similar_work_profiles, input.prefer_similar_work, ) - .ok_or_else(|| "没有可用的下一关候选".to_string())?; + .ok_or_else(|| "没有可用的下一关候选".to_string())?; let mut next_run = if similar_work_next_profile.is_some() { module_puzzle::advance_to_new_work_first_level_at( ¤t_run, @@ -3745,6 +3745,12 @@ mod tests { ui_background_prompt: None, ui_background_image_src: None, ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, background_music: None, candidates: candidates.clone(), selected_candidate_id: None, diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index a8ef6954..18232d9e 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -13,6 +13,10 @@ use serde::Serialize; use serde::de::DeserializeOwned; use spacetimedb::AnonymousViewContext; +const DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID: &str = "wooden-fish-default-back-button"; +const DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_SRC: &str = "/UI/11_left_arrow.png"; +const DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_OBJECT_KEY: &str = "public/UI/11_left_arrow.png"; + #[spacetimedb::view(accessor = wooden_fish_gallery_view, public)] pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec { let mut items = ctx @@ -593,10 +597,14 @@ fn start_wooden_fish_run_tx( input: WoodenFishRunStartInput, ) -> Result { require_non_empty(&input.run_id, "wooden_fish run_id")?; - let work = find_work(ctx, &input.profile_id)?; + let stored_work = find_work(ctx, &input.profile_id)?; + let work = backfill_historical_runtime_content(&stored_work); if !is_publish_ready(&work) { return Err("敲木鱼运行态需要完整作品配置".to_string()); } + if work.back_button_asset_json != stored_work.back_button_asset_json { + replace_work(ctx, &stored_work, clone_work(&work)); + } let snapshot = WoodenFishRunSnapshot { run_id: input.run_id.clone(), profile_id: input.profile_id.clone(), @@ -740,6 +748,7 @@ fn build_session_snapshot( } fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result { + let row = backfill_historical_runtime_content(row); Ok(WoodenFishWorkSnapshot { work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), @@ -775,7 +784,7 @@ fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result bool { + is_publish_ready_except_back_button(row) + && row + .back_button_asset_json + .as_deref() + .and_then(clean_optional) + .is_some() +} + +fn is_publish_ready_except_back_button(row: &WoodenFishWorkProfileRow) -> bool { !row.work_title.trim().is_empty() && !row.hit_object_asset_json.trim().is_empty() && row @@ -1016,14 +1034,40 @@ fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool { .as_deref() .and_then(clean_optional) .is_some() - && row + && !row.hit_sound_asset_json.trim().is_empty() + && !row.floating_words_json.trim().is_empty() + && row.generation_status == WOODEN_FISH_GENERATION_READY +} + +fn backfill_historical_runtime_content(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow { + if row.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED + || !is_publish_ready_except_back_button(row) + || row .back_button_asset_json .as_deref() .and_then(clean_optional) .is_some() - && !row.hit_sound_asset_json.trim().is_empty() - && !row.floating_words_json.trim().is_empty() - && row.generation_status == WOODEN_FISH_GENERATION_READY + { + return clone_work(row); + } + + WoodenFishWorkProfileRow { + back_button_asset_json: Some(to_json_string(&default_wooden_fish_back_button_asset())), + ..clone_work(row) + } +} + +fn default_wooden_fish_back_button_asset() -> WoodenFishImageAssetSnapshot { + WoodenFishImageAssetSnapshot { + asset_id: DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID.to_string(), + image_src: DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_SRC.to_string(), + image_object_key: DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_OBJECT_KEY.to_string(), + asset_object_id: DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID.to_string(), + generation_provider: "bundled-default".to_string(), + prompt: "历史敲木鱼默认返回按钮".to_string(), + width: 28, + height: 28, + } } fn default_config_from_input( @@ -1288,3 +1332,82 @@ fn clone_run(row: &WoodenFishRuntimeRunRow) -> WoodenFishRuntimeRunRow { updated_at: row.updated_at, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn historical_published_work_without_back_button_gets_runtime_backfill() { + let row = published_ready_work_without_back_button(); + + assert!(!is_publish_ready(&row)); + let repaired = backfill_historical_runtime_content(&row); + let snapshot = build_work_snapshot(&repaired).expect("历史作品补齐后应可映射运行态快照"); + + assert!(is_publish_ready(&repaired)); + assert!(snapshot.publish_ready); + assert_eq!( + snapshot + .back_button_asset + .as_ref() + .map(|asset| asset.image_src.as_str()), + Some("/UI/11_left_arrow.png") + ); + } + + fn published_ready_work_without_back_button() -> WoodenFishWorkProfileRow { + let now = Timestamp::from_micros_since_unix_epoch(1_770_000_000_000_000); + WoodenFishWorkProfileRow { + profile_id: "wooden-fish-profile-history".to_string(), + work_id: "wooden-fish-profile-history".to_string(), + owner_user_id: "user-history".to_string(), + source_session_id: "wooden-fish-session-history".to_string(), + author_display_name: "敲木鱼玩家".to_string(), + work_title: "今日敲木鱼".to_string(), + work_description: String::new(), + theme_tags_json: to_json_string(&vec!["敲木鱼".to_string(), "解压".to_string()]), + hit_object_prompt: "默认敲击物图案,圆润木质质感,透明背景".to_string(), + hit_object_reference_image_src: String::new(), + hit_sound_prompt: String::new(), + hit_object_asset_json: to_json_string(&WoodenFishImageAssetSnapshot { + asset_id: "wooden-fish-hit-object-history".to_string(), + image_src: "/wooden-fish/default-hit-object.png".to_string(), + image_object_key: "public/wooden-fish/default-hit-object.png".to_string(), + asset_object_id: "wooden-fish-hit-object-history".to_string(), + generation_provider: "bundled-default".to_string(), + prompt: "默认敲击物图案".to_string(), + width: 1024, + height: 1024, + }), + hit_sound_asset_json: to_json_string(&WoodenFishAudioAssetSnapshot { + asset_id: "wooden-fish-hit-sound-history".to_string(), + audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(), + audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(), + asset_object_id: "wooden-fish-hit-sound-history".to_string(), + source: "bundled-default".to_string(), + prompt: Some("默认木鱼音".to_string()), + duration_ms: Some(3_000), + }), + floating_words_json: to_json_string(&default_floating_words()), + cover_image_src: "/wooden-fish/default-hit-object.png".to_string(), + generation_status: WOODEN_FISH_GENERATION_READY.to_string(), + publication_status: WOODEN_FISH_PUBLICATION_PUBLISHED.to_string(), + play_count: 0, + updated_at: now, + published_at: Some(now), + background_asset_json: Some(to_json_string(&WoodenFishImageAssetSnapshot { + asset_id: "wooden-fish-background-history".to_string(), + image_src: "/generated-wooden-fish-assets/history/background/image.png".to_string(), + image_object_key: "generated-wooden-fish-assets/history/background/image.png" + .to_string(), + asset_object_id: "wooden-fish-background-history".to_string(), + generation_provider: "image2".to_string(), + prompt: "历史背景".to_string(), + width: 1024, + height: 1536, + })), + back_button_asset_json: None, + } + } +} diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx index d20da96d..14971ec7 100644 --- a/src/components/CustomWorldGenerationView.test.tsx +++ b/src/components/CustomWorldGenerationView.test.tsx @@ -142,17 +142,22 @@ describe('CustomWorldGenerationView', () => { screen .getByRole('progressbar', { name: progressTitle }) .className, - ).toContain('w-[min(35rem,94vw)]'); + ).toContain('w-[400px]'); expect( screen .getByRole('progressbar', { name: progressTitle }) .className, - ).toContain('sm:w-[52rem]'); + ).toContain('h-[400px]'); expect( screen .getByRole('progressbar', { name: progressTitle }) .getAttribute('data-ring-start-degrees'), - ).toBe('155'); + ).toBe('135'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-fill-start-degrees'), + ).toBe('135'); expect( screen .getByRole('progressbar', { name: progressTitle }) @@ -193,12 +198,12 @@ describe('CustomWorldGenerationView', () => { screen .getByTestId('generation-hero-progress-ring-track') .getAttribute('transform'), - ).toBe('rotate(155 200 200)'); + ).toBe('rotate(135 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') .getAttribute('transform'), - ).toBe('rotate(155 200 200)'); + ).toBe('rotate(135 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') diff --git a/src/components/GenerationProgressHero.tsx b/src/components/GenerationProgressHero.tsx index 9fa0af3a..369258a6 100644 --- a/src/components/GenerationProgressHero.tsx +++ b/src/components/GenerationProgressHero.tsx @@ -4,8 +4,16 @@ import { useEffect, useId, useRef } from 'react'; import generationHeroVideo from '../../media/create_bg_video.mp4'; -const GENERATION_PROGRESS_RING_START_DEGREES = 155; -const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270; +const GENERATION_PROGRESS_RING_GAP_DEGREES = 90; +const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90; +// 中文注释:SVG 圆从 3 点钟方向起笔;起点放在 135deg,可让 90deg 开口居中落在正下方。 +const GENERATION_PROGRESS_RING_START_DEGREES = + GENERATION_PROGRESS_RING_BOTTOM_DEGREES + + GENERATION_PROGRESS_RING_GAP_DEGREES / 2; +const GENERATION_PROGRESS_RING_FILL_START_DEGREES = + GENERATION_PROGRESS_RING_START_DEGREES; +const GENERATION_PROGRESS_RING_SWEEP_DEGREES = + 360 - GENERATION_PROGRESS_RING_GAP_DEGREES; const GENERATION_PROGRESS_RING_VIEWBOX = 400; const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2; const GENERATION_PROGRESS_RING_RADIUS = 166; @@ -118,7 +126,9 @@ export function GenerationProgressHero({ const safeProgress = clampGenerationProgress(progressValue); const ringGradientId = useId().replace(/:/g, ''); const ringMetrics = buildGenerationRingMetrics(safeProgress); - const ringDegrees = Math.round((safeProgress / 100) * 270); + const ringDegrees = Math.round( + (safeProgress / 100) * GENERATION_PROGRESS_RING_SWEEP_DEGREES, + ); const ringTrackDasharray = `${ringMetrics.sweepLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`; const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`; @@ -160,16 +170,19 @@ export function GenerationProgressHero({
diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx index e2d25871..e8a0ef84 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx @@ -130,12 +130,12 @@ describe('BarkBattleGeneratingView', () => { screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) .className, - ).toContain('w-[min(35rem,94vw)]'); + ).toContain('w-[400px]'); expect( screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) .className, - ).toContain('sm:w-[52rem]'); + ).toContain('h-[400px]'); expect( screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) @@ -145,7 +145,12 @@ describe('BarkBattleGeneratingView', () => { screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) .getAttribute('data-ring-start-degrees'), - ).toBe('155'); + ).toBe('135'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-fill-start-degrees'), + ).toBe('135'); expect( screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) @@ -186,12 +191,12 @@ describe('BarkBattleGeneratingView', () => { screen .getByTestId('generation-hero-progress-ring-track') .getAttribute('transform'), - ).toBe('rotate(155 200 200)'); + ).toBe('rotate(135 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') .getAttribute('transform'), - ).toBe('rotate(155 200 200)'); + ).toBe('rotate(135 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index ff231fa7..1d0d9680 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -560,7 +560,7 @@ test('creation hub shows RPG public work code from published library entry', () expect(screen.queryByText('CW-00000001')).toBeNull(); }); -test('creation hub hides persisted draft delete action behind swipe underlay', () => { +test('creation hub exposes persisted draft delete action directly on the card', () => { const { container } = render( { @@ -607,7 +607,9 @@ test('creation hub reveals persisted draft delete action from left swipe', () => }); fireEvent.touchEnd(card); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + expect( + container.querySelector('.creation-work-card__swipe-button--danger'), + ).toBeTruthy(); expect( container.querySelector('.creation-work-card-shell--actions-visible'), ).toBeTruthy(); @@ -615,7 +617,7 @@ test('creation hub reveals persisted draft delete action from left swipe', () => test('creation hub reveals persisted draft delete action from keyboard', async () => { const user = userEvent.setup(); - render( + const { container } = render( { +test('creation hub published work delete action is directly visible', async () => { const user = userEvent.setup(); const onDeletePuzzle = vi.fn(); const onOpenPuzzleDetail = vi.fn(); @@ -751,12 +759,6 @@ test('creation hub published work delete action is revealed without opening card />, ); - expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); - expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); - - screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus(); - await user.keyboard('{ArrowLeft}'); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); @@ -768,6 +770,115 @@ test('creation hub published work delete action is revealed without opening card expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); }); +test('creation hub exposes work delete action directly on card', async () => { + const user = userEvent.setup(); + const onDeletePuzzle = vi.fn(); + const onOpenPuzzleDetail = vi.fn(); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + onDeletePuzzle={onDeletePuzzle} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + await user.click(screen.getByRole('button', { name: '删除' })); + + expect(onDeletePuzzle).toHaveBeenCalledWith( + expect.objectContaining({ profileId: 'puzzle-profile-direct-delete' }), + ); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); +}); + +test('creation hub keeps swipe delete action available', async () => { + const user = userEvent.setup(); + const onDeletePuzzle = vi.fn(); + const onOpenPuzzleDetail = vi.fn(); + + const { container } = render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + onDeletePuzzle={onDeletePuzzle} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + const card = screen.getByRole('button', { name: /查看详情《左滑删除拼图》/u }); + fireEvent.touchStart(card, { + touches: [{ clientX: 180, clientY: 20 }], + }); + fireEvent.touchMove(card, { + touches: [{ clientX: 80, clientY: 22 }], + }); + fireEvent.touchEnd(card); + + const swipeDeleteButton = container.querySelector( + '.creation-work-card__swipe-button--danger', + ) as HTMLButtonElement | null; + expect(swipeDeleteButton).toBeTruthy(); + await user.click(swipeDeleteButton!); + + expect(onDeletePuzzle).toHaveBeenCalledWith( + expect.objectContaining({ profileId: 'puzzle-profile-swipe-delete' }), + ); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); +}); + test('creation hub opens persisted rpg drafts by card click', async () => { const user = userEvent.setup(); const openedItems: CustomWorldWorkSummary[] = []; @@ -942,7 +1053,7 @@ test('creation hub left swipe draft reveals delete without opening card', () => const onDeletePublished = vi.fn(); const onOpenDraft = vi.fn(); - render( + const { container } = render( }); fireEvent.touchEnd(card); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + expect( + container.querySelector('.creation-work-card__swipe-button--danger'), + ).toBeTruthy(); expect(onOpenDraft).not.toHaveBeenCalled(); }); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 392282c4..00f38323 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -676,43 +676,75 @@ export function CustomWorldWorkCard({ {displayTitle}
- {canUseShareAction ? ( - - ) : null} +
+ {canUseShareAction ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
diff --git a/src/index.css b/src/index.css index d1807475..5d82171f 100644 --- a/src/index.css +++ b/src/index.css @@ -2044,7 +2044,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { white-space: normal; } -.creation-work-card__share-button { +.creation-work-card__quick-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0.12rem; +} + +.creation-work-card__quick-action-button { display: inline-flex; width: 2rem; height: 2rem; @@ -2061,17 +2068,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { transform 160ms ease; } -.creation-work-card__share-button:hover { +.creation-work-card__quick-action-button:hover { transform: translateY(-1px); background: color-mix(in srgb, var(--platform-cool-bg) 24%, transparent); color: var(--platform-cool-text); } -.creation-work-card__share-button:focus-visible { +.creation-work-card__quick-action-button:focus-visible { outline: 2px solid var(--platform-cool-border); outline-offset: 2px; } +.creation-work-card__quick-action-button--danger { + color: color-mix(in srgb, #c7653d 78%, var(--platform-text-soft)); +} + +.creation-work-card__quick-action-button--danger:hover { + background: color-mix(in srgb, #c7653d 18%, transparent); + color: #a9472c; +} + +.creation-work-card__quick-action-button:disabled { + cursor: not-allowed; + opacity: 0.62; + transform: none; +} + .creation-work-card__meta { display: flex; min-width: 0;