From a9febe767839712bedbfe5f36b7a04fefd16ff01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 28 Apr 2026 10:57:40 +0800 Subject: [PATCH 1/5] 1 --- ...TION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md | 20 ++ ...RPG_REVIVE_CONTINUE_FLOW_FIX_2026-04-28.md | 48 +++ ...G_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md | 65 ++++ docs/experience/README.md | 1 + ...LT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md | 58 ++++ docs/technical/README.md | 1 + ...PG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md | 21 ++ ...IME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md | 44 +++ .../src/creation_agent_anchor_templates.json | 10 +- .../crates/spacetime-client/src/big_fish.rs | 20 +- .../crates/spacetime-client/src/mapper.rs | 127 ++++++- .../PlatformEntryFlowShellImpl.tsx | 15 + .../puzzle-result/PuzzleResultView.test.tsx | 122 ++++++- .../puzzle-result/PuzzleResultView.tsx | 118 ++++++- .../RpgAdventurePanel.questOffer.test.tsx | 236 +++++++++++++ .../rpg-runtime-panels/RpgAdventurePanel.tsx | 3 + src/data/sceneEncounterPreviews.test.ts | 1 + src/data/sceneEncounterPreviews.ts | 4 +- .../rpg-runtime-story/choiceActions.test.ts | 82 +---- .../npcEncounterActions.test.ts | 53 +++ .../rpg-runtime-story/postBattleFlow.test.ts | 327 ++++++++++++++++++ src/hooks/rpg-runtime-story/postBattleFlow.ts | 22 +- .../rpgRuntimeStoryGateway.ts | 7 + .../runtimeStoryCoordinator.test.ts | 3 + .../storyChoiceContinuation.ts | 6 +- .../storyChoiceRuntime.test.ts | 10 +- .../rpg-runtime-story/storyChoiceRuntime.ts | 6 +- .../useRpgRuntimeNpcInteraction.ts | 1 + 28 files changed, 1342 insertions(+), 89 deletions(-) create mode 100644 docs/audits/RPG_REVIVE_CONTINUE_FLOW_FIX_2026-04-28.md create mode 100644 docs/experience/BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md create mode 100644 docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md create mode 100644 src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx create mode 100644 src/hooks/rpg-runtime-story/postBattleFlow.test.ts diff --git a/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md b/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md index 8e011b49..bc2730f9 100644 --- a/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md +++ b/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md @@ -210,3 +210,23 @@ npm.cmd run build - 没有人手逐个点击整局游戏所有 function 的视觉回放,本轮重点是自动化测试、服务端测试、内容校验与 smoke 门禁。 因此,本审计可以说明“当前 function 系统的自动化测试层状况”,但不等于“所有视觉演出与在线模型联动都已人工验证完毕”。 + +## 8. 执行回填(2026-04-28,修复聊天任务领取入口) + +- 问题现象: + - NPC 聊天中的待领取任务,点击“查看任务”进入详情后,再点“领取任务”没有把面板切到正式已接任务状态,表现上像“无法领取”。 +- 根因: + - `npcChatQuestOfferUi.acceptPendingOffer()` 会异步把 `npc_quest_accept` 发到服务端。 + - 但 [`src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`](../../src/components/rpg-runtime-panels/RpgAdventurePanel.tsx) 里“待领取任务详情弹层”的 `onAcceptPendingNpcQuestOffer` 只返回了 `questId`,没有把它写入共享的 `pendingAcceptedQuestId`。 + - 结果是本来负责等待 quest 真正进入 `quests` 后再统一收口面板状态的 `useEffect` 根本不会触发。 +- 修复: + - 在 `onAcceptPendingNpcQuestOffer` 中补写 `setPendingAcceptedQuestId(acceptedQuestId)`,让待领取任务详情弹层复用普通 `npc_quest_accept` 已有的异步收口链。 + - 新增 [`src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`](../../src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx),覆盖“查看任务 -> 领取任务 -> 服务端异步写回 quest log -> 面板切到正式任务状态”的回归路径。 +- 本次回归验证: + - `src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx` + - `src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx` + - `src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` + - `src/hooks/rpg-runtime-story/choiceActions.test.ts` + - `src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts` + - `src/hooks/rpg-runtime-story/sessionActions.test.ts` + - 共 `57` 条测试通过。 diff --git a/docs/audits/RPG_REVIVE_CONTINUE_FLOW_FIX_2026-04-28.md b/docs/audits/RPG_REVIVE_CONTINUE_FLOW_FIX_2026-04-28.md new file mode 100644 index 00000000..598609ff --- /dev/null +++ b/docs/audits/RPG_REVIVE_CONTINUE_FLOW_FIX_2026-04-28.md @@ -0,0 +1,48 @@ +# RPG 复活后继续冒险链路修复记录 2026-04-28 + +## 问题现象 + +RPG 运行态里,角色在战斗死亡并复活后,面板会显示一个“继续前进”入口。 + +此前这一步只有 `story_continue_adventure` 控制项,没有同步挂出复活后首场景应该展示的 `deferredOptions`。因此玩家点击继续后,系统会把它当作一次普通剧情续推入口,而不是单纯展示“复活后的下一批可选动作”。 + +在带有场景章节主 NPC 的自定义世界里,这会让玩家看起来像是“刚复活就直接和对面主 NPC 聊天”,造成复活后第一拍体验被主 NPC 对话链抢走。 + +## 根因结论 + +根因不在 NPC 聊天函数本身,而在死亡复活链没有沿用 `story_continue_adventure -> deferredOptions` 的延迟展示协议。 + +- 战后胜利链已经使用 `story_continue_adventure + deferredOptions` +- 死亡复活链此前只保留了 `story_continue_adventure` +- 结果是“继续前进”点击后无法走纯展示分支,只能落回普通续推 + +## 本次修复 + +本次在 `src/hooks/rpg-runtime-story/postBattleFlow.ts` 与复活调用链中补齐: + +- `buildDeathStory(...)` 现在支持在复活文案上同步挂出 `deferredOptions` +- 这些 `deferredOptions` 复用 `buildFallbackStoryForState(...)` 产出的复活后可用入口 +- 点击“继续前进”时只揭示这些入口,不再额外触发一次普通剧情推演 + +## 当前行为规则 + +角色死亡复活后: + +1. 先显示“你在战斗中倒下,随后重新醒来” +2. 面板只展示一个 `story_continue_adventure` +3. 点击后展示复活后首场景已有的后续动作 +4. 不应直接自动推进到主 NPC 聊天执行态 + +## 回归覆盖 + +已补两条测试: + +- `src/hooks/rpg-runtime-story/choiceActions.test.ts` + - 覆盖本地战斗失败后的复活链 +- `src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts` + - 覆盖服务端战斗失败后的复活链 + +两条测试都要求复活文案返回: + +- `story_continue_adventure` +- 非空 `deferredOptions` diff --git a/docs/experience/BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md b/docs/experience/BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md new file mode 100644 index 00000000..6f14dc1e --- /dev/null +++ b/docs/experience/BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md @@ -0,0 +1,65 @@ +# 大鱼作品列表 `items_json` 兼容修复 2026-04-28 + +## 背景 + +大鱼吃小鱼作品列表在 `server-rs` 链路里由 SpacetimeDB procedure 返回 `items_json`,再由 `spacetime-client` 反序列化成 `BigFishWorkSummaryRecord`。 + +本轮出现的线上报错为: + +```text +big fish works items_json 非法: missing field `owner_user_id` +``` + +这说明: + +1. 客户端 record 结构已经把 `owner_user_id` 当成必填字段。 +2. 某些历史 `items_json` 仍是旧字段集,没有带上 `owner_user_id`。 +3. 一旦直接按新结构强反序列化,整个 works 列表接口都会失败,而不是只丢失单字段。 + +## 根因判断 + +这不是前端展示问题,也不是 Axum 路由参数问题,而是: + +1. SpacetimeDB procedure 输出 JSON 的结构发生过升级。 +2. `spacetime-client` 映射层没有为旧 JSON 做向后兼容。 +3. 作品列表是聚合读模型,一条旧记录就可能拖垮整批列表读取。 + +## 本次落地口径 + +本次只做最小风险修复,不改前端契约,不改现有表结构: + +1. `server-rs/crates/spacetime-client/src/mapper.rs` + - 大鱼 works 反序列化改为先读兼容结构。 + - `owner_user_id` 改为兼容层里的可缺省字段。 + +2. 私有 works 列表 + - 若旧 JSON 缺 `owner_user_id`,用当前查询的 `owner_user_id` 回填。 + - 这样不会破坏创作中心里依赖 `ownerUserId` 的恢复、归属和 key 逻辑。 + +3. 公开 gallery 列表 + - 若旧 JSON 缺 `owner_user_id`,先回填空串,保证列表接口不再整体失败。 + - 后续若公开画廊明确需要作者归属真相,再补模块端回填或数据修复。 + +4. 新增定向测试 + - 覆盖“私有 works 旧 JSON 缺字段仍可回填” + - 覆盖“公开 works 旧 JSON 缺字段不再报错” + +## 经验结论 + +以后只要是 `procedure -> items_json -> client record` 这类链路,都要默认遵守下面两条: + +1. 聚合读模型的 JSON 字段升级不能假设全量历史数据同步完成。 +2. `spacetime-client` 的映射层必须承担兼容旧 JSON 的责任,不能把结构升级风险直接抛给上层接口。 + +尤其是 works / gallery / library 这种平台入口级接口: + +1. 允许单字段降级 +2. 不允许整批列表因单字段缺失而 500 / 400 + +## 后续建议 + +如果后面继续演进大鱼 works 字段,推荐优先遵守: + +1. 新增字段优先 `Option` 或兼容层解析。 +2. 聚合 JSON 升级时同步补回归测试。 +3. 如果字段已经进入前端关键逻辑,再决定是在模块端回填、客户端兜底,还是补历史数据迁移。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 679ee7e7..76c9f614 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -31,3 +31,4 @@ - [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。 - [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。 - [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。 +- [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。 diff --git a/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md b/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md new file mode 100644 index 00000000..0c1aadc9 --- /dev/null +++ b/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md @@ -0,0 +1,58 @@ +# 拼图结果页自动保存与标签发布门槛修复 + +## 背景 + +拼图结果页此前存在两个串联问题: + +1. 创作者在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。 +2. 发布弹窗同时混用了旧 session 内的 `publishReady` 与前端本地编辑态,导致标签已经在界面里补够,但发布校验仍然盯着旧草稿里的标签数量,用户无法通过发布检验。 + +这会直接破坏拼图创作主链的可用性:用户明明已经在结果页补齐正式标签,却因为没有自动保存、也没有按当前编辑态重算门槛而卡在发布前。 + +## 修复目标 + +1. 拼图结果页中的 `关卡名`、`添加标签`、`删除标签` 统一接入自动保存。 +2. 自动保存复用现有 `PUT /api/runtime/puzzle/works/:profileId`,不新增新系统。 +3. 前端发布门槛与后端 `module-puzzle` 规则显式对齐,统一采用 `3~6` 个正式标签。 +4. 发布弹窗要基于“当前可编辑态”判断是否通过,不再被旧 session 中可由本地编辑修复的 blocker 卡死。 + +## 实现口径 + +### 1. 结果页自动保存 + +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + - 为拼图结果页显式透传稳定 `profileId`。 + - 草稿结果页默认按 `puzzle-session-* -> puzzle-profile-*` 规则推导 profileId;已发布作品优先复用 `publishedProfileId`。 + +- `src/components/puzzle-result/PuzzleResultView.tsx` + - 当 `levelName / summary / themeTags` 相对当前 `draft` 发生变化时,触发防抖自动保存。 + - 自动保存复用 `updatePuzzleWork(...)`,同步写回: + - `levelName` + - `summary` + - `themeTags` + - 当前正式图 `coverImageSrc / coverAssetId` + - 顶部只展示轻量保存状态角标:`保存中 / 已自动保存 / 保存失败`。 + +### 2. 发布门槛统一 + +- 前端结果页发布判定不再使用“至少 1 个标签”的旧口径。 +- 统一改为和后端 `module-puzzle` 一致的规则:正式标签数量必须在 `3 到 6` 之间。 + +### 3. 结果页 blocker 重算策略 + +`session.resultPreview.blockers` 中与可编辑字段直接相关的 blocker: + +- `MISSING_LEVEL_NAME` +- `INVALID_TAG_COUNT` +- `MISSING_COVER_IMAGE` + +这些项不再原样阻断前端发布按钮,而是改由结果页基于当前 `editState + formalImageSrc` 重新计算。 +其余后端 blocker 仍继续保留,避免前端绕过真正不可编辑的发布门禁。 + +## 验收点 + +1. 修改拼图关卡名后,不点发布也会自动写回作品 profile。 +2. 添加标签、删除标签后,不点发布也会自动写回作品 profile。 +3. 标签少于 `3` 个时,发布弹窗明确提示“正式标签数量必须在 3 到 6 之间”。 +4. 标签补到 `3~6` 个后,无需刷新页面即可通过前端发布校验。 +5. 结果页顶部能看到轻量自动保存状态,不额外堆叠说明文案。 diff --git a/docs/technical/README.md b/docs/technical/README.md index cab7e99d..84a9a38b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。 - [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log`、`server ping`、端口监听和 root-dir 相关进程。 - [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse` 真流式输出的后端落地口径。 - [SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md](./SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md):记录发布包 `start.sh` root-dir 占用检测把 `grep -F .../.spacetimedb` 误判为 SpacetimeDB 实例的根因、脚本修复和现场处理方式。 diff --git a/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md b/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md index 29185198..2939d28f 100644 --- a/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md +++ b/docs/technical/RPG_BATTLE_DEFEAT_OUTCOME_FIX_2026-04-27.md @@ -96,6 +96,9 @@ 4. 前端 `storyChoiceContinuation` / `useRpgRuntimeNpcInteraction`: - `fight_defeat` 不能再被当成“本地 NPC 战斗胜利”进入战后收束 - 玩家死亡后必须直接走死亡复活链,且复活时重置到开局场景第一幕,不能先推进下一幕 +5. 前端 `postBattleFlow`: + - 复活回到开局场景时,必须重新走首幕 encounter preview 恢复链 + - 第一幕主交互 NPC 与同幕陪衬 NPC 要继续沿用既有场景槽位,不能退化成全部站成一排 ## 继续收口(2026-04-28) @@ -118,6 +121,24 @@ - 非 NPC 通用敌对战斗 `!inBattle` 5. 这样可以保证作品测试、幕预览与正式游戏在“死亡后回开局第一幕”这一口径上继续对齐 +## 继续收口:复活后首幕 NPC 与站位恢复(2026-04-28) + +在继续复测后,又确认死亡复活链还有一层表现问题: + +1. 角色虽然已经回到开局场景第一幕; +2. 但复活态旧实现只是重置 `currentSceneActState`,没有重新恢复第一幕 encounter preview; +3. 于是画布只能把第一幕 NPC 都按普通 ambient 角色绘制; +4. 视觉上就会表现为: + - 主交互 NPC 没有按首幕重新成为前景目标 + - 同幕 NPC 失去原本的前后排关系 + - 最终看起来像“所有人站成一排” + +本轮补充修正如下: + +1. `buildRevivedFirstSceneState(...)` 在重置到首幕之后,立即复用 `ensureSceneEncounterPreview(...)` +2. 这样复活链与“开局进入世界 / 场景正常进场”继续共用同一套首幕恢复逻辑 +3. 第一幕主交互 NPC、同幕陪衬 NPC 与既有槽位会一起恢复,不再额外发明一套复活专用站位规则 + ## 结论 本次修复后,RPG 战斗 compat 主链的胜负判定口径变为: diff --git a/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md b/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md index 373d36d2..f9b55270 100644 --- a/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md +++ b/docs/technical/RPG_TEST_RUNTIME_ALIGNMENT_WITH_PLAY_MODE_2026-04-27.md @@ -200,3 +200,47 @@ 1. 自定义世界角色型敌人在战斗态不会再重复叠加场景立绘下沉偏移; 2. 相关战斗编队、runtime gateway 与 battle plan 既有回归继续通过。 + +## 9. 本轮继续修正:战斗结束后和平态站位被战斗坐标污染 + +在前面解决“开战瞬间跳位”后,用户继续反馈战斗结束时敌对角色站位仍会被改掉。继续顺着 NPC 战斗收尾链核对后,确认这次的问题不在画布层,而在“战后恢复使用了哪一份 encounter 真相”。 + +### 9.1 根因梳理 + +此前 `fight_victory` 收尾时,恢复 `currentEncounter` 的优先级仍可能落到: + +1. 战斗中的 `currentEncounter` +2. `activeBattleHostiles[0]?.encounter` + +这两份 encounter 都已经是战斗态里被压到前排中心位后的数据,`xMeters` 往往已经变成 `3.2`。因此即使战前和平态 NPC 原本站在更靠后的场景位置,战斗结束后也会被错误恢复到战斗中心位,表现为“打完架后角色站位被改掉”。 + +### 9.2 本次收口 + +这次修正把“战前原始 encounter 保存”和“战后 encounter 恢复”两端一起收口: + +1. `sceneEncounterPreviews.ts` + - NPC 自动开战时立即保存战前原始 encounter + - 复用现有 `sparReturnEncounter` 存槽,避免新增一套并行状态 +2. `rpgRuntimeStoryGateway.ts` + - 若服务端战斗快照未带回 `sparReturnEncounter` + - 网关自动沿用进入战斗前的原始 NPC encounter 回填 +3. `useRpgRuntimeNpcInteraction.ts` + - `fight_victory` 恢复和平态时,优先使用保存下来的战前 encounter + - 只在缺失时才退回到 battle encounter / fallback encounter + +### 9.3 效果 + +这样处理后: + +1. 战斗结束后恢复到场景中的 NPC,会回到战前那份 encounter 对应的位置; +2. 不会再把战斗前排中心位误带回和平态; +3. `fight` 与 `spar` 两条 NPC 战斗收尾链恢复口径保持一致; +4. 作品测试、幕预览与正式运行的战后站位表现继续对齐。 + +### 9.4 验证 + +本轮新增并通过了以下回归验证: + +1. 负好感 NPC 自动开战后会保存战前 encounter; +2. `npc_fight` 服务端空战场快照桥接后会保留战前 encounter; +3. `fight_victory` 收尾时会恢复战前 encounter,而不是战斗态 encounter。 diff --git a/server-rs/crates/api-server/src/creation_agent_anchor_templates.json b/server-rs/crates/api-server/src/creation_agent_anchor_templates.json index 4a6c55b1..d61a3a89 100644 --- a/server-rs/crates/api-server/src/creation_agent_anchor_templates.json +++ b/server-rs/crates/api-server/src/creation_agent_anchor_templates.json @@ -94,19 +94,19 @@ }, { "templateId": "big_fish", - "displayName": "大鱼吃小鱼共创", + "displayName": "大鱼吃小鱼", "creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。", "anchorQuestions": [ { "key": "gameplayPromise", - "label": "玩法承诺", - "question": "这版大鱼吃小鱼最核心的吞噬成长爽点是什么?", + "label": "玩法爽点", + "question": "核心的吞噬成长爽点是什么?", "requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。" }, { "key": "ecologyVisualTheme", - "label": "生态视觉主题", - "question": "鱼群、场景和敌我生态的视觉主题是什么?", + "label": "视觉主题", + "question": "场景和形象的视觉主题是什么?", "requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。" }, { diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index fb8272ac..758f18f3 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -80,12 +80,22 @@ impl SpacetimeClient { procedure_input: BigFishWorksListInput, ) -> Result, SpacetimeClientError> { self.call_after_connect(move |connection, sender| { + let fallback_owner_user_id = if procedure_input.published_only { + None + } else { + Some(procedure_input.owner_user_id.clone()) + }; connection .procedures() .list_big_fish_works_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_big_fish_works_procedure_result); + .and_then(|result| { + map_big_fish_works_procedure_result( + result, + fallback_owner_user_id.as_deref(), + ) + }); send_once(&sender, mapped); }); }) @@ -103,12 +113,18 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { + let fallback_owner_user_id = Some(procedure_input.owner_user_id.clone()); connection .procedures() .delete_big_fish_work_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_big_fish_works_procedure_result); + .and_then(|result| { + map_big_fish_works_procedure_result( + result, + fallback_owner_user_id.as_deref(), + ) + }); send_once(&sender, mapped); }); }) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 22d85795..3e32f9fe 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1278,6 +1278,7 @@ pub(crate) fn map_big_fish_session_procedure_result( pub(crate) fn map_big_fish_works_procedure_result( result: BigFishWorksProcedureResult, + fallback_owner_user_id: Option<&str>, ) -> Result, SpacetimeClientError> { if !result.ok { return Err(SpacetimeClientError::Procedure( @@ -1292,9 +1293,15 @@ pub(crate) fn map_big_fish_works_procedure_result( "SpacetimeDB procedure 未返回 big fish works 快照".to_string(), ) })?; - serde_json::from_str::>(&items_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) - }) + let items = serde_json::from_str::>(&items_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) + })?; + + Ok(items + .into_iter() + .map(|item| item.into_record(fallback_owner_user_id)) + .collect()) } pub(crate) fn map_story_session_procedure_result( @@ -4601,6 +4608,120 @@ pub struct BigFishWorkSummaryRecord { pub background_ready: bool, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +struct CompatibleBigFishWorkSummaryRecord { + work_id: String, + source_session_id: String, + #[serde(default)] + owner_user_id: Option, + title: String, + subtitle: String, + summary: String, + cover_image_src: Option, + status: String, + updated_at_micros: i64, + publish_ready: bool, + level_count: u32, + level_main_image_ready_count: u32, + level_motion_ready_count: u32, + background_ready: bool, +} + +impl CompatibleBigFishWorkSummaryRecord { + fn into_record(self, fallback_owner_user_id: Option<&str>) -> BigFishWorkSummaryRecord { + BigFishWorkSummaryRecord { + work_id: self.work_id, + source_session_id: self.source_session_id, + // 中文注释:兼容旧 works JSON 没有 owner_user_id 的历史数据,避免一次字段升级把整个作品列表打崩。 + owner_user_id: self.owner_user_id.unwrap_or_else(|| { + fallback_owner_user_id + .map(str::to_string) + .unwrap_or_default() + }), + title: self.title, + subtitle: self.subtitle, + summary: self.summary, + cover_image_src: self.cover_image_src, + status: self.status, + updated_at_micros: self.updated_at_micros, + publish_ready: self.publish_ready, + level_count: self.level_count, + level_main_image_ready_count: self.level_main_image_ready_count, + level_motion_ready_count: self.level_motion_ready_count, + background_ready: self.background_ready, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn big_fish_works_mapper_backfills_missing_owner_user_id_for_private_lists() { + let result = BigFishWorksProcedureResult { + ok: true, + items_json: Some( + r#"[{ + "work_id":"big-fish-work-session-1", + "source_session_id":"session-1", + "title":"深海草稿", + "subtitle":"副标题", + "summary":"摘要", + "cover_image_src":null, + "status":"draft", + "updated_at_micros":123, + "publish_ready":false, + "level_count":8, + "level_main_image_ready_count":0, + "level_motion_ready_count":0, + "background_ready":false + }]"# + .to_string(), + ), + error_message: None, + }; + + let items = map_big_fish_works_procedure_result(result, Some("user-1")) + .expect("旧 works JSON 应能被兼容解析"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].owner_user_id, "user-1"); + } + + #[test] + fn big_fish_works_mapper_keeps_empty_owner_when_gallery_legacy_json_lacks_field() { + let result = BigFishWorksProcedureResult { + ok: true, + items_json: Some( + r#"[{ + "work_id":"big-fish-work-session-2", + "source_session_id":"session-2", + "title":"公开作品", + "subtitle":"副标题", + "summary":"摘要", + "cover_image_src":null, + "status":"published", + "updated_at_micros":456, + "publish_ready":true, + "level_count":8, + "level_main_image_ready_count":8, + "level_motion_ready_count":16, + "background_ready":true + }]"# + .to_string(), + ), + error_message: None, + }; + + let items = map_big_fish_works_procedure_result(result, None) + .expect("公开 works 旧 JSON 也不应因缺字段报错"); + + assert_eq!(items.len(), 1); + assert!(items[0].owner_user_id.is_empty()); + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolveNpcBattleInteractionInput { pub npc_interaction: DomainResolveNpcInteractionInput, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 281d7331..0e321392 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -316,6 +316,17 @@ function buildAgentResultPublishGateView( }; } +function buildPuzzleResultProfileId(sessionId: string | null | undefined) { + const normalizedSessionId = sessionId?.trim(); + if (!normalizedSessionId) { + return null; + } + const stableSuffix = normalizedSessionId.startsWith('puzzle-session-') + ? normalizedSessionId.slice('puzzle-session-'.length) + : normalizedSessionId; + return `puzzle-profile-${stableSuffix}`; +} + const CustomWorldGenerationView = lazy(async () => { const module = await import('../CustomWorldGenerationView'); return { @@ -2450,6 +2461,10 @@ export function PlatformEntryFlowShellImpl({ > { diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 6ea38574..8f7404e5 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -1,16 +1,18 @@ // @vitest-environment jsdom import { + act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; +import * as puzzleWorksService from '../../services/puzzle-works'; import { PuzzleResultView } from './PuzzleResultView'; vi.mock('../ResolvedAssetImage', () => ({ @@ -31,6 +33,15 @@ vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({ }, })); +vi.mock('../../services/puzzle-works', () => ({ + updatePuzzleWork: vi.fn(), +})); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + function createSession( overrides: Partial = {}, ): PuzzleAgentSessionSnapshot { @@ -149,6 +160,39 @@ function createSession( } describe('PuzzleResultView', () => { + test('auto saves renamed title to the puzzle work profile', async () => { + vi.useFakeTimers(); + vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ + item: {} as never, + }); + + render( + {}} + onExecuteAction={() => {}} + />, + ); + + fireEvent.change(screen.getByDisplayValue('雨夜猫街'), { + target: { value: '暖灯猫街' }, + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith( + 'puzzle-profile-session-1', + expect.objectContaining({ + levelName: '暖灯猫街', + summary: '屋檐下的猫与暖灯街角。', + themeTags: ['猫咪', '雨夜'], + }), + ); + }); + test('uses two tabs without author preview or persistent publish validation', () => { render( { }); test('edits theme tags with chips instead of a persistent tag input', () => { + vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ + item: {} as never, + }); render( {}} onExecuteAction={() => {}} />, @@ -256,6 +304,78 @@ describe('PuzzleResultView', () => { ); }); + test('requires at least three theme tags before publish can pass', () => { + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByLabelText('删除标签 猫咪')); + fireEvent.click(screen.getByRole('button', { name: /发布/u })); + + const dialog = screen.getByRole('dialog', { name: '发布拼图作品' }); + expect( + within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'), + ).toBeTruthy(); + expect( + ( + within(dialog).getByRole('button', { + name: '发布到广场', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + test('auto saves added and removed theme tags', async () => { + vi.useFakeTimers(); + vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({ + item: {} as never, + }); + + render( + {}} + onExecuteAction={() => {}} + />, + ); + + fireEvent.click(screen.getByLabelText('新增题材标签')); + fireEvent.change(screen.getByLabelText('新题材标签'), { + target: { value: '暖灯' }, + }); + fireEvent.click(screen.getByRole('button', { name: '添加' })); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith( + 'puzzle-profile-session-1', + expect.objectContaining({ + themeTags: ['猫咪', '雨夜', '暖灯'], + }), + ); + + fireEvent.click(screen.getByLabelText('删除标签 猫咪')); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith( + 'puzzle-profile-session-1', + expect.objectContaining({ + themeTags: ['雨夜', '暖灯'], + }), + ); + }); + test('generates one image from the picture description and replaces current image', () => { const onExecuteAction = vi.fn(); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index e1fc05f3..9661900b 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -15,6 +15,7 @@ import { createPortal } from 'react-dom'; import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { updatePuzzleWork } from '../../services/puzzle-works'; import { puzzleAssetClient, type PuzzleHistoryAsset, @@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage'; type PuzzleResultViewProps = { session: PuzzleAgentSessionSnapshot; + profileId?: string | null; isBusy?: boolean; error?: string | null; onBack: () => void; @@ -32,6 +34,7 @@ type PuzzleResultViewProps = { }; type PuzzleResultTab = 'basic' | 'images'; +type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; type DraftEditState = { levelName: string; @@ -39,6 +42,10 @@ type DraftEditState = { themeTags: string[]; }; +const PUZZLE_MIN_THEME_TAG_COUNT = 3; +const PUZZLE_MAX_THEME_TAG_COUNT = 6; +const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600; + function normalizeThemeTagInput(value: string) { return [ ...new Set( @@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) { if (!session.resultPreview) { return ['等待结果页草稿完成后再发布。']; } - return session.resultPreview.blockers.map((entry) => entry.message); + return session.resultPreview.blockers + .filter( + (entry) => + ![ + 'MISSING_LEVEL_NAME', + 'INVALID_TAG_COUNT', + 'MISSING_COVER_IMAGE', + ].includes(entry.code), + ) + .map((entry) => entry.message); } function buildPublishReady( @@ -96,7 +112,10 @@ function buildPublishReady( const blockers = [ ...publishBlockedReason(session), ...(editState.levelName.trim() ? [] : ['关卡名不能为空。']), - ...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']), + ...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT && + editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT + ? [] + : [`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT} 到 ${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`]), ...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']), ]; @@ -105,7 +124,8 @@ function buildPublishReady( publishReady: Boolean(session.resultPreview?.publishReady) && Boolean(editState.levelName.trim()) && - editState.themeTags.length > 0 && + editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT && + editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT && Boolean(formalImageSrc), }; } @@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) { } function PuzzleResultHeader({ + autoSaveState, isBusy, onBack, }: { + autoSaveState: PuzzleAutoSaveState; isBusy: boolean; onBack: () => void; }) { + const autoSaveBadge = + autoSaveState === 'saving' ? ( +
+ 保存中 +
+ ) : autoSaveState === 'saved' ? ( +
+ 已自动保存 +
+ ) : autoSaveState === 'error' ? ( +
+ 保存失败 +
+ ) : null; + return (
+ {autoSaveBadge}
); } @@ -871,6 +909,7 @@ function PuzzleResultActionBar({ */ export function PuzzleResultView({ session, + profileId = null, isBusy = false, error = null, onBack, @@ -884,15 +923,77 @@ export function PuzzleResultView({ const [editState, setEditState] = useState( draft ? createDraftEditState(draft) : null, ); + const [autoSaveState, setAutoSaveState] = useState('idle'); + const [autoSaveError, setAutoSaveError] = useState(null); useEffect(() => { if (!draft) { setEditState(null); + setAutoSaveState('idle'); + setAutoSaveError(null); return; } setEditState(createDraftEditState(draft)); + setAutoSaveState('idle'); + setAutoSaveError(null); }, [draft]); + useEffect(() => { + if (!draft || !editState || !profileId) { + return; + } + + const normalizedLevelName = editState.levelName.trim(); + const normalizedSummary = editState.summary.trim(); + const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(',')); + const draftLevelName = draft.levelName.trim(); + const draftSummary = draft.summary.trim(); + const draftTags = normalizeThemeTagInput(draft.themeTags.join(',')); + const levelNameChanged = normalizedLevelName !== draftLevelName; + const summaryChanged = normalizedSummary !== draftSummary; + const tagsChanged = + normalizedTags.length !== draftTags.length || + normalizedTags.some((tag, index) => tag !== draftTags[index]); + + if (!levelNameChanged && !summaryChanged && !tagsChanged) { + return; + } + + setAutoSaveState('saving'); + setAutoSaveError(null); + + let cancelled = false; + const timer = window.setTimeout(() => { + void updatePuzzleWork(profileId, { + levelName: normalizedLevelName, + summary: normalizedSummary, + themeTags: normalizedTags, + coverImageSrc: formalImageSrc || null, + coverAssetId: draft.coverAssetId ?? null, + }) + .then(() => { + if (cancelled) { + return; + } + setAutoSaveState('saved'); + }) + .catch((saveError) => { + if (cancelled) { + return; + } + setAutoSaveState('error'); + setAutoSaveError( + saveError instanceof Error ? saveError.message : '自动保存失败。', + ); + }); + }, PUZZLE_AUTOSAVE_DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [draft, editState, formalImageSrc, profileId]); + const publishState = useMemo(() => { if (!draft || !editState) { return { @@ -915,7 +1016,11 @@ export function PuzzleResultView({ return (
- + ) : null} + {!error && autoSaveError ? ( +
+ {autoSaveError} +
+ ) : null} ( + createPendingQuestStory(pendingQuest), + ); + const [quests, setQuests] = useState([]); + const acceptPendingOffer = vi.fn(() => { + queueMicrotask(() => { + setQuests([pendingQuest]); + setCurrentStory(createAcceptedQuestStory(pendingQuest)); + }); + return pendingQuest.id; + }); + + return ( + undefined} + onChoice={() => undefined} + onSubmitNpcChatInput={() => true} + onExitNpcChat={() => true} + onOpenCharacter={() => undefined} + onOpenInventory={() => undefined} + playerCharacter={createCharacter()} + worldType={WorldType.WUXIA} + quests={quests} + questUi={{ + acknowledgeQuestCompletion: () => undefined, + claimQuestReward: () => null, + }} + npcChatQuestOfferUi={{ + replacePendingOffer: () => false, + abandonPendingOffer: () => false, + acceptPendingOffer, + }} + goalStack={{ + northStarGoal: null, + activeGoal: null, + immediateStepGoal: null, + supportGoals: [], + }} + goalPulse={null} + onDismissGoalPulse={() => undefined} + battleRewardUi={{ + reward: null, + dismiss: () => undefined, + }} + playerHp={100} + playerMaxHp={100} + playerMana={20} + playerMaxMana={20} + playerSkillCooldowns={{}} + inBattle={false} + currentNpcBattleMode={null} + statistics={{ + playTimeMs: 0, + hostileNpcsDefeated: 0, + questsAccepted: 0, + questsCompleted: 0, + questsTurnedIn: 0, + itemsUsed: 0, + scenesTraveled: 0, + currentSceneName: '竹林古道', + playerCurrency: 0, + inventoryItemCount: 0, + inventoryStackCount: 0, + activeCompanionCount: 0, + rosterCompanionCount: 0, + }} + musicVolume={0.6} + onMusicVolumeChange={() => undefined} + onSaveAndExit={() => undefined} + /> + ); +} + +beforeAll(() => { + if (!HTMLElement.prototype.scrollTo) { + HTMLElement.prototype.scrollTo = () => undefined; + } +}); + +test('quest offer accept button reuses the shared accepted-quest follow-up chain', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /查看任务/u })); + await user.click(await screen.findByRole('button', { name: '领取任务' })); + + expect(await screen.findByText('任务进度:0/1')).toBeTruthy(); + expect(screen.getAllByText('竹林密信').length).toBeGreaterThan(0); + expect(screen.queryByText('待领取')).toBeNull(); + expect(screen.getByText('这件事里你最担心哪一步')).toBeTruthy(); +}); diff --git a/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx b/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx index 2f20d73f..fafbd603 100644 --- a/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx +++ b/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx @@ -1724,6 +1724,9 @@ export function RpgAdventurePanel({ onAcceptPendingNpcQuestOffer={() => { const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer(); if (!acceptedQuestId) return null; + // 中文注释:待领取任务详情弹层走的是异步服务端接取链路, + // 这里先记录 questId,等 quest 真正进入日志后再由 effect 统一收口面板状态。 + setPendingAcceptedQuestId(acceptedQuestId); setSelectedQuestId(null); return acceptedQuestId; }} diff --git a/src/data/sceneEncounterPreviews.test.ts b/src/data/sceneEncounterPreviews.test.ts index a494f127..31fcd1b8 100644 --- a/src/data/sceneEncounterPreviews.test.ts +++ b/src/data/sceneEncounterPreviews.test.ts @@ -137,6 +137,7 @@ describe('sceneEncounterPreviews', () => { expect(resolved.currentEncounter).toBeNull(); expect(resolved.currentBattleNpcId).toBe('npc-trader'); expect(resolved.currentNpcBattleMode).toBe('fight'); + expect(resolved.sparReturnEncounter).toEqual(state.currentEncounter); expect(resolved.sceneHostileNpcs).toHaveLength(1); expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin'); }); diff --git a/src/data/sceneEncounterPreviews.ts b/src/data/sceneEncounterPreviews.ts index eb77ea19..34dfae3d 100644 --- a/src/data/sceneEncounterPreviews.ts +++ b/src/data/sceneEncounterPreviews.ts @@ -240,7 +240,9 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) { currentBattleNpcId: battleNpcId, currentNpcBattleMode: 'fight' as const, currentNpcBattleOutcome: null, - sparReturnEncounter: null, + // 中文注释:NPC 开战后要保留战前原始遭遇,供战斗收尾时恢复和平态站位。 + // 这里复用现有 sparReturnEncounter 存槽,避免战后误把 battle encounter 的临时坐标带回场景。 + sparReturnEncounter: encounter, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, diff --git a/src/hooks/rpg-runtime-story/choiceActions.test.ts b/src/hooks/rpg-runtime-story/choiceActions.test.ts index d3dc54e9..9259dc97 100644 --- a/src/hooks/rpg-runtime-story/choiceActions.test.ts +++ b/src/hooks/rpg-runtime-story/choiceActions.test.ts @@ -132,67 +132,16 @@ function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption { }; } -function createFallbackStory(text = 'fallback'): StoryMoment { +function createFallbackStory( + text = 'fallback', + options: StoryOption[] = [], +): StoryMoment { return { text, - options: [], + options, }; } -function createCustomWorldProfileForSceneAct(sceneId: string) { - return { - id: 'custom-world-test', - name: '场景幕重置测试', - summary: '用于验证战败后回到首幕。', - playableNpcs: [], - storyNpcs: [], - sceneChapterBlueprints: [ - { - id: `${sceneId}-chapter`, - sceneId, - title: '测试章节', - summary: '测试章节摘要', - linkedThreadIds: [], - linkedLandmarkIds: [], - acts: [ - { - id: `${sceneId}-act-1`, - sceneId, - title: '第一幕', - summary: '开场第一幕', - stageCoverage: ['opening'], - backgroundImageSrc: '/act-1.png', - encounterNpcIds: [], - primaryNpcId: null, - oppositeNpcId: null, - eventDescription: '第一幕事件', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '完成第一幕目标', - transitionHook: '第一幕过渡', - }, - { - id: `${sceneId}-act-2`, - sceneId, - title: '第二幕', - summary: '推进第二幕', - stageCoverage: ['expansion'], - backgroundImageSrc: '/act-2.png', - encounterNpcIds: [], - primaryNpcId: null, - oppositeNpcId: null, - eventDescription: '第二幕事件', - linkedThreadIds: [], - advanceRule: 'after_primary_contact', - actGoal: '完成第二幕目标', - transitionHook: '第二幕过渡', - }, - ], - }, - ], - } as NonNullable; -} - const neverNpcEncounter = ( encounter: GameState['currentEncounter'], ): encounter is Encounter => false; @@ -692,10 +641,8 @@ describe('createStoryChoiceActions', () => { it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => { vi.useFakeTimers(); const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!; - const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id); const state = { ...createBaseState(), - customWorldProfile, currentScenePreset: firstScene, storyEngineMemory: { discoveredFactIds: [], @@ -735,7 +682,6 @@ describe('createStoryChoiceActions', () => { })); const setCurrentStory = vi.fn(); const setGameState = vi.fn(); - const { handleChoice } = createStoryChoiceActions({ gameState: state, currentStory: createFallbackStory(), @@ -763,7 +709,7 @@ describe('createStoryChoiceActions', () => { skillCooldowns: {}, })), buildStoryFromResponse: vi.fn((_, __, response) => response), - buildFallbackStoryForState: vi.fn(() => createFallbackStory()), + buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')), generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(() => null), getStoryGenerationHostileNpcs: vi.fn(() => []), @@ -809,22 +755,24 @@ describe('createStoryChoiceActions', () => { id: firstScene.id, }), playerHp: 100, + playerMana: 20, inBattle: false, currentNpcBattleOutcome: null, - storyEngineMemory: expect.objectContaining({ - currentSceneActState: expect.objectContaining({ - sceneId: firstScene.id, - currentActId: `${firstScene.id}-act-1`, - currentActIndex: 0, - }), - }), }), ); + const revivedState = setGameState.mock.calls[1]?.[0] as GameState; + expect(revivedState.currentBattleNpcId).toBeNull(); + expect(revivedState.currentNpcBattleMode).toBeNull(); + expect(revivedState.currentNpcBattleOutcome).toBeNull(); + expect( + revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0, + ).toBe(true); expect(setCurrentStory).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringContaining('重新醒来'), }), ); + vi.useRealTimers(); }); it('settles escape locally without ai continuation', async () => { diff --git a/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts b/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts index 796680af..6e770f07 100644 --- a/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts +++ b/src/hooks/rpg-runtime-story/npcEncounterActions.test.ts @@ -721,6 +721,59 @@ describe('npcEncounterActions', () => { expect(result).toBeNull(); }); + it('restores the pre-battle encounter after fight_victory instead of using the battle encounter position', () => { + const preBattleEncounter = { + ...createEncounter(), + xMeters: 12, + context: '断桥外侧', + }; + const battleEncounter = { + ...createEncounter(), + xMeters: 3.2, + context: '战斗中心位', + }; + const actions = createNpcEncounterActions({ + gameState: createState({ + currentEncounter: battleEncounter, + inBattle: true, + currentBattleNpcId: 'npc-rival', + currentNpcBattleMode: 'fight', + currentNpcBattleOutcome: 'fight_victory', + sparReturnEncounter: preBattleEncounter, + sceneHostileNpcs: [ + { + id: 'npc-rival', + name: '断桥客', + action: '逼近', + description: '拦路旧敌', + animation: 'idle', + xMeters: 3.2, + yOffset: 0, + facing: 'left', + attackRange: 1.4, + speed: 7, + hp: 0, + maxHp: 12, + renderKind: 'npc', + encounter: battleEncounter, + }, + ], + }), + }); + + const result = actions.finalizeNpcBattleResult( + actions.gameState, + actions.gameState.playerCharacter!, + 'fight', + 'fight_victory', + ); + + expect(result).not.toBeNull(); + expect(result?.nextState.currentEncounter).toEqual(preBattleEncounter); + expect(result?.nextState.currentEncounter?.xMeters).toBe(12); + expect(result?.nextState.sparReturnEncounter).toBeNull(); + }); + it('streams a model-driven npc-initiated opening on first meaningful contact', async () => { const encounter = createEncounter(); streamNpcChatTurnMock.mockResolvedValueOnce({ diff --git a/src/hooks/rpg-runtime-story/postBattleFlow.test.ts b/src/hooks/rpg-runtime-story/postBattleFlow.test.ts new file mode 100644 index 00000000..31dc0d23 --- /dev/null +++ b/src/hooks/rpg-runtime-story/postBattleFlow.test.ts @@ -0,0 +1,327 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({ + ensureSceneEncounterPreviewMock: vi.fn(), +})); + +vi.mock('../../data/sceneEncounterPreviews', () => ({ + ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock, +})); + +import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime'; +import { getScenePresetsByWorld } from '../../data/scenePresets'; +import { AnimationState, type GameState, WorldType } from '../../types'; +import { buildRevivedFirstSceneState } from './postBattleFlow'; + +function createBackstoryReveal(label: string) { + return { + publicSummary: `${label}的公开背景`, + chapters: [ + { + id: `${label}-surface`, + title: '表层来意', + affinityRequired: 15, + teaser: `${label}先收着话。`, + content: `${label}把真正目的藏在后面。`, + contextSnippet: `${label}表面上仍在试探。`, + }, + { + id: `${label}-scar`, + title: '旧事裂痕', + affinityRequired: 30, + teaser: `${label}提到旧事会迟疑。`, + content: `${label}背后压着旧伤。`, + contextSnippet: `${label}仍被旧事牵制。`, + }, + { + id: `${label}-hidden`, + title: '隐藏执念', + affinityRequired: 60, + teaser: `${label}真正执念并不在表面。`, + content: `${label}真正想守住的是另一条暗线。`, + contextSnippet: `${label}另有没说出口的理由。`, + }, + { + id: `${label}-final`, + title: '最终底牌', + affinityRequired: 90, + teaser: `${label}手里还扣着底牌。`, + content: `${label}掌握能改写局势的最后证据。`, + contextSnippet: `${label}最后底牌还没翻出。`, + }, + ], + }; +} + +function createStoryRole(id: string, name: string, hostile = false) { + return { + id, + name, + title: `${name}的头衔`, + role: hostile ? '敌对角色' : '同幕角色', + description: `${name}的测试描述`, + backstory: `${name}的测试背景`, + personality: '冷静克制', + motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化', + combatStyle: hostile ? '正面压制' : '后排支援', + initialAffinity: hostile ? -20 : 12, + relationshipHooks: [], + tags: [], + backstoryReveal: createBackstoryReveal(name), + skills: [], + initialItems: [], + }; +} + +function createReviveState(): GameState { + const customWorldProfile = { + id: 'custom-revive-test', + name: '复活回场测试世界', + subtitle: '首幕站位恢复', + summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。', + settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。', + tone: '紧张、克制', + playerGoal: '复活后重新回到第一幕并面对主交互角色。', + templateWorldType: WorldType.WUXIA, + majorFactions: [], + coreConflicts: [], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试属性', + generatedFrom: { + worldType: WorldType.CUSTOM, + worldName: '复活回场测试世界', + settingSummary: '首幕站位恢复', + tone: '紧张、克制', + conflictCore: '复活后重新面对主交互角色', + }, + slots: [], + }, + playableNpcs: [], + storyNpcs: [ + createStoryRole('npc-front', '正面对手', true), + createStoryRole('npc-back-1', '后排甲'), + createStoryRole('npc-back-2', '后排乙'), + ], + items: [], + landmarks: [], + camp: { + id: 'custom-scene-camp', + name: '开局营地', + description: '用于复活回场测试。', + visualDescription: '营地火光映着即将重开的第一幕。', + imageSrc: '/camp.png', + sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], + connections: [], + narrativeResidues: null, + }, + sceneChapterBlueprints: [ + { + id: 'custom-scene-camp-chapter', + sceneId: 'custom-scene-camp', + title: '开局章节', + summary: '复活后应回到这里的第一幕。', + sceneTaskDescription: '', + linkedThreadIds: [], + linkedLandmarkIds: [], + acts: [ + { + id: 'custom-scene-camp-act-1', + sceneId: 'custom-scene-camp', + title: '第一幕', + summary: '主交互角色与后排角色一同出现。', + stageCoverage: ['opening'], + backgroundImageSrc: '/act-1.png', + encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], + primaryNpcId: 'npc-front', + oppositeNpcId: 'npc-front', + eventDescription: '第一幕事件', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '重新进入首幕', + transitionHook: '首幕回场', + }, + { + id: 'custom-scene-camp-act-2', + sceneId: 'custom-scene-camp', + title: '第二幕', + summary: '这是死亡前已经推进到的幕。', + stageCoverage: ['expansion'], + backgroundImageSrc: '/act-2.png', + encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'], + primaryNpcId: 'npc-front', + oppositeNpcId: 'npc-front', + eventDescription: '第二幕事件', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '推进第二幕', + transitionHook: '第二幕推进', + }, + ], + }, + ], + } as NonNullable; + + setRuntimeCustomWorldProfile(customWorldProfile); + const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!; + + return { + worldType: WorldType.CUSTOM, + customWorldProfile, + playerCharacter: { + id: 'hero', + name: '测试主角', + title: '旅人', + description: '测试角色', + backstory: '测试背景', + avatar: '/hero.png', + portrait: '/hero.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 10, + spirit: 10, + }, + personality: 'calm', + skills: [], + adventureOpenings: {}, + }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'Story', + storyHistory: [], + characterChats: {}, + animationState: AnimationState.DIE, + currentEncounter: null, + npcInteractionActive: false, + currentScenePreset: firstScene, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 0, + playerMaxHp: 100, + playerMana: 0, + playerMaxMana: 20, + playerSkillCooldowns: {}, + activeCombatEffects: [], + playerCurrency: 0, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: { + 'npc-front': { + affinity: -20, + helpUsed: false, + chattedCount: 0, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + 'npc-back-1': { + affinity: 8, + helpUsed: false, + chattedCount: 0, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + 'npc-back-2': { + affinity: 6, + helpUsed: false, + chattedCount: 0, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: 'npc-front', + currentNpcBattleMode: 'fight', + currentNpcBattleOutcome: 'fight_defeat', + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + storyEngineMemory: { + discoveredFactIds: [], + activeThreadIds: [], + resolvedScarIds: [], + recentCarrierIds: [], + currentSceneActState: { + sceneId: 'custom-scene-camp', + chapterId: 'custom-scene-camp-chapter', + currentActId: 'custom-scene-camp-act-2', + currentActIndex: 1, + completedActIds: ['custom-scene-camp-act-1'], + visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'], + }, + }, + } as GameState; +} + +describe('postBattleFlow', () => { + afterEach(() => { + ensureSceneEncounterPreviewMock.mockReset(); + setRuntimeCustomWorldProfile(null); + }); + + it('rebuilds revived first-scene state through encounter preview restoration', () => { + const reviveState = createReviveState(); + const previewRestoredState = { + ...reviveState, + currentEncounter: { + id: 'npc-front', + kind: 'npc' as const, + characterId: 'npc-front', + npcName: '正面对手', + npcDescription: '正面对手的测试描述', + npcAvatar: '正', + context: '敌对角色', + xMeters: 12, + }, + }; + ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState); + + const revived = buildRevivedFirstSceneState(reviveState); + + expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith( + expect.objectContaining({ + currentScenePreset: expect.objectContaining({ + id: 'custom-scene-camp', + }), + currentEncounter: null, + sceneHostileNpcs: [], + playerHp: 100, + playerMana: 20, + inBattle: false, + currentNpcBattleOutcome: null, + storyEngineMemory: expect.objectContaining({ + currentSceneActState: expect.objectContaining({ + currentActId: 'custom-scene-camp-act-1', + currentActIndex: 0, + }), + }), + }), + ); + expect(revived).toBe(previewRestoredState); + }); +}); diff --git a/src/hooks/rpg-runtime-story/postBattleFlow.ts b/src/hooks/rpg-runtime-story/postBattleFlow.ts index 6e5f1a71..08c75979 100644 --- a/src/hooks/rpg-runtime-story/postBattleFlow.ts +++ b/src/hooks/rpg-runtime-story/postBattleFlow.ts @@ -1,4 +1,5 @@ import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets'; +import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews'; import { advanceSceneActRuntimeState, buildInitialSceneActRuntimeState, @@ -169,7 +170,7 @@ export function buildRevivedFirstSceneState(state: GameState): GameState { storyEngineMemory: undefined, }); - return { + const revivedBaseState = { ...state, currentScenePreset: firstScene, currentEncounter: null, @@ -195,19 +196,34 @@ export function buildRevivedFirstSceneState(state: GameState): GameState { ...storyEngineMemory, currentSceneActState: firstActState, }, - }; + } satisfies GameState; + + // 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview + // 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC + // 会按既有槽位一起恢复,避免退化成所有人站成一排。 + return ensureSceneEncounterPreview(revivedBaseState); } -export function buildDeathStory(state: GameState): StoryMoment { +export function buildDeathStory( + state: GameState, + deferredOptions?: StoryOption[], +): StoryMoment { const firstSceneName = state.worldType ? getScenePresetsByWorld(state.worldType)[0]?.name : state.currentScenePreset?.name; + return { text: firstSceneName ? `你在战斗中倒下,随后在${firstSceneName}重新醒来。` : '你在战斗中倒下,随后重新醒来。', options: [buildContinueOption()], + // 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口, + // 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。 + deferredOptions: + deferredOptions && deferredOptions.length > 0 + ? deferredOptions + : undefined, streaming: false, }; } diff --git a/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts b/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts index 1b1307c6..ec35e9d5 100644 --- a/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts +++ b/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts @@ -301,6 +301,13 @@ function bridgeServerNpcBattleSnapshot(params: { sceneHostileNpcs: resolvedBattleFormation, currentEncounter: null, npcInteractionActive: false, + // 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter, + // 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。 + sparReturnEncounter: + snapshotState.sparReturnEncounter ?? + (previousState.currentEncounter?.kind === 'npc' + ? previousState.currentEncounter + : null), }, } satisfies HydratedSavedGameSnapshot; } diff --git a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts index a21ba4a8..a117b104 100644 --- a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts +++ b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts @@ -937,6 +937,9 @@ describe('runtimeStoryCoordinator', () => { yOffset: 62, }, ]); + expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual( + gameState.currentEncounter, + ); }); it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => { diff --git a/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts b/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts index 6161bf20..475b71d4 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceContinuation.ts @@ -446,8 +446,12 @@ export async function runLocalStoryChoiceContinuation(params: { ], }; fallbackState = revivedState; + const revivedDeferredOptions = + params.buildFallbackStoryForState(revivedState, params.character).options; params.setGameState(revivedState); - params.setCurrentStory(buildDeathStory(revivedState)); + params.setCurrentStory( + buildDeathStory(revivedState, revivedDeferredOptions), + ); return; } diff --git a/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts b/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts index b32a1919..4dc4fb2a 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts @@ -527,7 +527,10 @@ describe('storyChoiceRuntime', () => { setIsLoading: vi.fn(), setGameState, setCurrentStory: setCurrentStory as (story: StoryMoment) => void, - buildFallbackStoryForState: () => createStory('fallback'), + buildFallbackStoryForState: () => + createStory('fallback', [ + createOption('idle_explore_forward'), + ]), turnVisualMs: 1, }); @@ -541,6 +544,11 @@ describe('storyChoiceRuntime', () => { expect(setCurrentStory).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringContaining('重新醒来'), + options: [ + expect.objectContaining({ + functionId: 'story_continue_adventure', + }), + ], }), ); expect(setCurrentStory).not.toHaveBeenCalledWith( diff --git a/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts b/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts index 39f5e2ec..43a12475 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceRuntime.ts @@ -350,8 +350,12 @@ export async function runServerRuntimeChoiceAction(params: { params.setGameState(deathState); await sleep(PLAYER_REVIVE_DELAY_MS); const revivedState = buildRevivedFirstSceneState(deathState); + const revivedDeferredOptions = + params.buildFallbackStoryForState(revivedState, params.character).options; params.setGameState(revivedState); - params.setCurrentStory(buildDeathStory(revivedState)); + params.setCurrentStory( + buildDeathStory(revivedState, revivedDeferredOptions), + ); return; } diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts index eea70085..f8f759fe 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts @@ -490,6 +490,7 @@ export function createStoryNpcEncounterActions({ (hostileNpc) => hostileNpc.id, ); const restoredEncounter = + state.sparReturnEncounter ?? (state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ?? activeBattleHostiles[0]?.encounter ?? ({ From 3cdbf36859f72e209c6799d01056e92bbd41d333 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 11:11:01 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E6=8B=BC=E5=9B=BE=E5=92=8C=E5=A4=A7?= =?UTF-8?q?=E9=B1=BC=E5=90=83=E5=B0=8F=E9=B1=BC=E8=A1=A5=E5=85=85=E6=B8=B8?= =?UTF-8?q?=E7=8E=A9=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 10 +++ ...OARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md | 11 +++ .../src/contracts/bigFishWorkSummary.ts | 1 + server-rs/crates/api-server/src/app.rs | 9 ++- server-rs/crates/api-server/src/big_fish.rs | 27 +++++++ server-rs/crates/module-big-fish/src/lib.rs | 16 +++- server-rs/crates/module-puzzle/src/lib.rs | 12 ++- .../shared-contracts/src/big_fish_works.rs | 2 + .../crates/spacetime-client/src/big_fish.rs | 25 +++++++ server-rs/crates/spacetime-client/src/lib.rs | 8 +- .../crates/spacetime-client/src/mapper.rs | 1 + .../big_fish_creation_session_type.rs | 3 + .../big_fish_play_record_input_type.rs | 16 ++++ .../src/module_bindings/mod.rs | 4 + .../record_big_fish_play_procedure.rs | 59 +++++++++++++++ .../crates/spacetime-client/src/puzzle.rs | 9 +-- .../spacetime-module/src/big_fish/assets.rs | 2 + .../spacetime-module/src/big_fish/session.rs | 74 +++++++++++++++++++ .../spacetime-module/src/big_fish/tables.rs | 1 + .../crates/spacetime-module/src/migration.rs | 16 +++- .../crates/spacetime-module/src/puzzle.rs | 41 +++++----- .../custom-world-home/creationWorkShelf.ts | 1 + .../PlatformEntryFlowShellImpl.tsx | 25 +++++-- .../rpg-session/useRpgSessionPersistence.ts | 11 ++- src/hooks/runtimeAuthGuards.test.tsx | 65 ++++++++++++++++ .../big-fish-works/bigFishWorksClient.ts | 17 +++++ src/services/big-fish-works/index.ts | 1 + 27 files changed, 419 insertions(+), 48 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs diff --git a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index 00377a5b..9d6e3d24 100644 --- a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -16,6 +16,16 @@ 6. 启动测试运行态 7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决 +### 1.1 2026-04-27 公开游玩次数补充 + +正式发布的大鱼吃小鱼作品需要记录公开游玩次数,落地口径如下: + +1. `big_fish_creation_session.play_count` 保存该作品被正式启动的次数,默认值为 `0`。 +2. 只有平台作品详情、作品架等正式入口启动已发布作品时递增;创作结果页内的测试运行不计入。 +3. 前端作品摘要 contract 暴露 `playCount`,作品架展示与拼图一致使用该后端值。 +4. 本轮仅记录“进入玩法”次数,不记录大鱼吃小鱼总时长;个人 profile 的 RPG 时长统计仍由 runtime snapshot 负责。 +5. schema 变更需要同步 `migration.rs` 已纳入的 `big_fish_creation_session` 导入导出结构。 + ## 2. 本轮明确不做 1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。 diff --git a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md index ee919e86..b64db9ba 100644 --- a/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md +++ b/docs/technical/M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md @@ -377,6 +377,17 @@ Node 侧入口位于: 这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。 +## 10.1 2026-04-27 统计写链修正 + +`runtime_snapshot / save archive` 主链已接入后,profile projection 的写入语义补充冻结如下: + +1. 正式 RPG 游玩只通过 `PUT /api/runtime/save/snapshot` 刷新 `profile_dashboard_state` 与 `profile_played_world`。 +2. `runtimeMode = "preview"`、`runtimeMode = "test"` 或 `runtimePersistenceDisabled = true` 的快照不刷新 profile projection。 +3. 前端发起自动保存与手动保存前,必须先把 `runtimeStats.lastPlayTickAt` 到当前时间的 live 时长同步进 `runtimeStats.playTimeMs`,避免 15 秒内进入又退出时保存 0。 +4. `profile_played_world` 的一行表示“当前用户玩过这个世界”,不是全站作品热度计数;`playedWorldCount` 读取当前用户的去重世界数。 +5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。 +6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`。 + ## 11. 测试策略 ### 11.1 必跑 diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index 30ec15f1..21b7f2a3 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -15,6 +15,7 @@ export interface BigFishWorkSummary { levelMainImageReadyCount: number; levelMotionReadyCount: number; backgroundReady: boolean; + playCount?: number; } export interface BigFishWorksResponse { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..28008d2f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -35,7 +35,7 @@ use crate::{ big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, stream_big_fish_message, - submit_big_fish_message, + record_big_fish_play, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -83,8 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, - swap_puzzle_pieces, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -575,6 +574,10 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/works/{session_id}/play", + post(record_big_fish_play), + ) .route( "/api/runtime/puzzle/agent/sessions", post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index b5f804e3..3fa14974 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -191,6 +191,32 @@ pub async fn delete_big_fish_work( )) } +pub async fn record_big_fish_play( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .record_big_fish_play(session_id, current_utc_micros()) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, @@ -924,6 +950,7 @@ fn map_big_fish_work_summary_response( level_main_image_ready_count: item.level_main_image_ready_count, level_motion_ready_count: item.level_motion_ready_count, background_ready: item.background_ready, + play_count: item.play_count, } } diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index adeec338..778d937b 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -221,6 +221,7 @@ pub struct BigFishWorkSummarySnapshot { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + pub play_count: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -316,6 +317,13 @@ pub struct BigFishPublishInput { pub published_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishPlayRecordInput { + pub session_id: String, + pub played_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum BigFishFieldError { MissingSessionId, @@ -654,6 +662,13 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish validate_session_owner(&input.session_id, &input.owner_user_id) } +pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.session_id).is_none() { + return Err(BigFishFieldError::MissingSessionId); + } + Ok(()) +} + pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result { serde_json::to_string(anchor_pack) } @@ -861,5 +876,4 @@ mod tests { ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } - } diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 448db2c6..28c7ddf7 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -1964,14 +1964,18 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { let cleared_at_ms = current_unix_ms(); current_level.cleared_at_ms = Some(cleared_at_ms); - current_level.elapsed_ms = - Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000)); + current_level.elapsed_ms = Some( + cleared_at_ms + .saturating_sub(current_level.started_at_ms) + .max(1_000), + ); } current_level.status = next_level_status; } - if is_cleared && run.current_level.as_ref().map(|level| level.status) - != Some(PuzzleRuntimeLevelStatus::Cleared) + if is_cleared + && run.current_level.as_ref().map(|level| level.status) + != Some(PuzzleRuntimeLevelStatus::Cleared) { next_run.cleared_level_count += 1; } diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index 1f876bf7..b44cd94a 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + #[serde(default)] + pub play_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 758f18f3..5544f606 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -1,6 +1,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; +use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -131,6 +132,30 @@ impl SpacetimeClient { .await } + pub async fn record_big_fish_play( + &self, + session_id: String, + played_at_micros: i64, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishPlayRecordInput { + session_id, + played_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().record_big_fish_play_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_big_fish_message( &self, input: BigFishMessageSubmitRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..fea844c4 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,10 +30,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 3e32f9fe..92e78bbb 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4606,6 +4606,7 @@ pub struct BigFishWorkSummaryRecord { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, + pub play_count: u32, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index 353343fd..a760fea0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -20,6 +20,7 @@ pub struct BigFishCreationSession { pub asset_coverage_json: String, pub last_assistant_reply: Option, pub publish_ready: bool, + pub play_count: u32, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols { pub asset_coverage_json: __sdk::__query_builder::Col, pub last_assistant_reply: __sdk::__query_builder::Col>, pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { "last_assistant_reply", ), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs new file mode 100644 index 00000000..dc9ec79b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_play_record_input_type.rs @@ -0,0 +1,16 @@ +// 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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishPlayRecordInput { + pub session_id: String, + pub played_at_micros: i64, +} + +impl __sdk::InModule for BigFishPlayRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 76bf4e34..386e98f4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -89,6 +89,7 @@ pub mod big_fish_game_draft_type; pub mod big_fish_level_blueprint_type; pub mod big_fish_message_finalize_input_type; pub mod big_fish_message_submit_input_type; +pub mod big_fish_play_record_input_type; pub mod big_fish_publish_input_type; pub mod big_fish_runtime_params_type; pub mod big_fish_session_create_input_type; @@ -331,6 +332,7 @@ pub mod quest_objective_snapshot_type; pub mod quest_progress_signal_type; pub mod quest_record_input_type; pub mod quest_record_type; +pub mod record_big_fish_play_procedure; pub mod quest_reward_equipment_slot_type; pub mod quest_reward_intel_type; pub mod quest_reward_item_rarity_type; @@ -558,6 +560,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft; pub use big_fish_level_blueprint_type::BigFishLevelBlueprint; pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput; pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput; +pub use big_fish_play_record_input_type::BigFishPlayRecordInput; pub use big_fish_publish_input_type::BigFishPublishInput; pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_session_create_input_type::BigFishSessionCreateInput; @@ -800,6 +803,7 @@ pub use quest_objective_snapshot_type::QuestObjectiveSnapshot; pub use quest_progress_signal_type::QuestProgressSignal; pub use quest_record_input_type::QuestRecordInput; pub use quest_record_type::QuestRecord; +pub use record_big_fish_play_procedure::record_big_fish_play; pub use quest_reward_equipment_slot_type::QuestRewardEquipmentSlot; pub use quest_reward_intel_type::QuestRewardIntel; pub use quest_reward_item_rarity_type::QuestRewardItemRarity; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs new file mode 100644 index 00000000..f4cfaa6b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs @@ -0,0 +1,59 @@ +// 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::big_fish_play_record_input_type::BigFishPlayRecordInput; +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordBigFishPlayArgs { + pub input: BigFishPlayRecordInput, +} + +impl __sdk::InModule for RecordBigFishPlayArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_big_fish_play`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_big_fish_play { + fn record_big_fish_play(&self, input: BigFishPlayRecordInput) { + self.record_big_fish_play_then(input, |_, _| {}); + } + + fn record_big_fish_play_then( + &self, + input: BigFishPlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_big_fish_play for super::RemoteProcedures { + fn record_big_fish_play_then( + &self, + input: BigFishPlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "record_big_fish_play", + RecordBigFishPlayArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index c3c09287..9636ed13 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -478,15 +478,14 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().submit_puzzle_leaderboard_entry_then( - procedure_input, - move |_, result| { + connection + .procedures() + .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_puzzle_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index 3e68e92f..ed97fd62 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), publish_ready: true, + play_count: session.play_count, created_at: session.created_at, updated_at: published_at, }; diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 01459d39..0c0faa92 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -93,6 +93,32 @@ pub fn delete_big_fish_work( } } +#[spacetimedb::procedure] +pub fn record_big_fish_play( + ctx: &mut ProcedureContext, + input: BigFishPlayRecordInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, @@ -194,6 +220,7 @@ pub(crate) fn create_big_fish_session_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(input.welcome_message_text.clone()), publish_ready: false, + play_count: 0, created_at, updated_at: created_at, }); @@ -383,6 +410,7 @@ pub(crate) fn submit_big_fish_message_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at: submitted_at, }; @@ -429,6 +457,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -483,6 +512,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( asset_coverage_json: session.asset_coverage_json.clone(), last_assistant_reply: Some(assistant_reply_text), publish_ready: session.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at, }; @@ -530,6 +560,7 @@ pub(crate) fn compile_big_fish_draft_tx( .map_err(|error| error.to_string())?, last_assistant_reply: Some(reply.clone()), publish_ready: coverage.publish_ready, + play_count: session.play_count, created_at: session.created_at, updated_at: compiled_at, }; @@ -657,9 +688,51 @@ pub(crate) fn build_big_fish_work_summary( level_main_image_ready_count: coverage.level_main_image_ready_count, level_motion_ready_count: coverage.level_motion_ready_count, background_ready: coverage.background_ready, + play_count: row.play_count, }) } +pub(crate) fn record_big_fish_play_tx( + ctx: &ReducerContext, + input: BigFishPlayRecordInput, +) -> Result, String> { + validate_play_record_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布作品不存在".to_string())?; + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + last_assistant_reply: session.last_assistant_reply.clone(), + publish_ready: session.publish_ready, + // 中文注释:这里只记录正式发布作品的进入次数,创作结果页测试运行不走这个 procedure。 + play_count: session.play_count.saturating_add(1), + created_at: session.created_at, + updated_at: played_at, + }; + replace_big_fish_session(ctx, &session, next_session); + + list_big_fish_works_tx( + ctx, + BigFishWorksListInput { + owner_user_id: String::new(), + published_only: true, + }, + ) +} + pub(crate) fn replace_big_fish_session( ctx: &ReducerContext, current: &BigFishCreationSession, @@ -693,6 +766,7 @@ mod tests { asset_coverage_json: "{}".to_string(), last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), publish_ready: false, + play_count: 0, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index 1e280ef6..7e82cd91 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -17,6 +17,7 @@ pub struct BigFishCreationSession { pub(crate) asset_coverage_json: String, pub(crate) last_assistant_reply: Option, pub(crate) publish_ready: bool, + pub(crate) play_count: u32, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 37a52649..f2f6be5b 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -659,6 +659,19 @@ where Ok(wrapped.0) } +fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { + let mut next_value = value.clone(); + if table_name == "big_fish_creation_session" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } + next_value +} + fn insert_migration_table_rows( ctx: &ReducerContext, table: &MigrationTable, @@ -672,7 +685,8 @@ fn insert_migration_table_rows( let mut imported = 0u64; let mut skipped = 0u64; for value in &table.rows { - let row = row_from_json(value) + let normalized_value = normalize_migration_row(stringify!($table), value); + let row = row_from_json(&normalized_value) .map_err(|error| format!("{}: {error}", stringify!($table)))?; let insert_result = ctx.db .$table() diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a1087aa0..7c27aba7 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -3,10 +3,10 @@ use module_puzzle::{ PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, - PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, - PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, + PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, + PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, + PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, @@ -1689,12 +1689,7 @@ fn upsert_puzzle_leaderboard_entry( ) { let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); - if let Some(existing) = ctx - .db - .puzzle_leaderboard_entry() - .entry_id() - .find(&entry_id) - { + if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) { let should_replace = elapsed_ms < existing.best_elapsed_ms || (elapsed_ms == existing.best_elapsed_ms && updated_at.to_micros_since_unix_epoch() @@ -1725,16 +1720,18 @@ fn upsert_puzzle_leaderboard_entry( return; } - ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow { - entry_id, - profile_id: profile_id.to_string(), - grid_size, - user_id: user_id.to_string(), - nickname: nickname.to_string(), - best_elapsed_ms: elapsed_ms, - last_run_id: run_id.to_string(), - updated_at, - }); + ctx.db + .puzzle_leaderboard_entry() + .insert(PuzzleLeaderboardEntryRow { + entry_id, + profile_id: profile_id.to_string(), + grid_size, + user_id: user_id.to_string(), + nickname: nickname.to_string(), + best_elapsed_ms: elapsed_ms, + last_run_id: run_id.to_string(), + updated_at, + }); } fn list_puzzle_leaderboard_entries( @@ -1799,8 +1796,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, - PuzzleLeaderboardEntry, + PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, + recommendation_score, tag_similarity_score, }; #[test] diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 6a0c2c3b..1fa56e98 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem( id: 'level-motion-ready-count', label: `动作 ${item.levelMotionReadyCount}`, }, + { id: 'play-count', label: `游玩 ${item.playCount ?? 0}` }, ...(item.backgroundReady ? [ { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0e321392..4af6f0f1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -60,6 +60,7 @@ import { import { deleteBigFishWork, listBigFishWorks, + recordBigFishWorkPlay, } from '../../services/big-fish-works'; import { readCustomWorldAgentUiState, @@ -91,6 +92,7 @@ import { } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, + startPuzzleRun, submitPuzzleLeaderboard, } from '../../services/puzzle-runtime'; import { @@ -1147,11 +1149,21 @@ export function PlatformEntryFlowShellImpl({ return; } - setBigFishError(null); - setBigFishRuntimeShare(null); - setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); - setSelectionStage('big-fish-runtime'); - }, [bigFishSession, setSelectionStage]); + const run = async () => { + setBigFishError(null); + setBigFishRuntimeShare(null); + if (bigFishSession.stage === 'published') { + await recordBigFishWorkPlay(bigFishSession.sessionId); + await refreshBigFishShelf(); + } + setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession })); + setSelectionStage('big-fish-runtime'); + }; + + void run().catch((error) => { + setBigFishError(resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。')); + }); + }, [bigFishSession, refreshBigFishShelf, resolveBigFishErrorMessage, setSelectionStage]); const restartBigFishRun = useCallback(() => { if (!bigFishSession && !bigFishRun) { @@ -1175,8 +1187,9 @@ export function PlatformEntryFlowShellImpl({ try { const { item } = await getPuzzleGalleryDetail(profileId); + const { run } = await startPuzzleRun({ profileId: item.profileId }); setSelectedPuzzleDetail(item); - setPuzzleRun(startLocalPuzzleRun(item)); + setPuzzleRun(run); setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setSelectionStage('puzzle-runtime'); pushAppHistoryPath( diff --git a/src/hooks/rpg-session/useRpgSessionPersistence.ts b/src/hooks/rpg-session/useRpgSessionPersistence.ts index 00ccfe2b..3836af74 100644 --- a/src/hooks/rpg-session/useRpgSessionPersistence.ts +++ b/src/hooks/rpg-session/useRpgSessionPersistence.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { syncGameStatePlayTime } from '../../data/runtimeStats'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { isAbortError } from '../../services/apiClient'; import { rpgSnapshotClient } from '../../services/rpg-runtime'; @@ -37,6 +38,10 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { }; } +function buildPersistedGameState(gameState: GameState) { + return syncGameStatePlayTime(gameState); +} + export type UseRpgSessionPersistenceParams = { authenticatedUserId: string | null; gameState: GameState; @@ -208,9 +213,10 @@ export function useRpgSessionPersistence({ if (!canPersist) return; const timeoutId = window.setTimeout(() => { + const persistedGameState = buildPersistedGameState(gameState); void persistSnapshot({ payload: { - gameState, + gameState: persistedGameState, bottomTab, currentStory, }, @@ -235,9 +241,10 @@ export function useRpgSessionPersistence({ return false; } + const persistedGameState = buildPersistedGameState(nextGameState); const snapshot = await persistSnapshot({ payload: { - gameState: nextGameState, + gameState: persistedGameState, bottomTab: nextBottomTab, currentStory: nextStory, }, diff --git a/src/hooks/runtimeAuthGuards.test.tsx b/src/hooks/runtimeAuthGuards.test.tsx index 42100d03..5b8872d4 100644 --- a/src/hooks/runtimeAuthGuards.test.tsx +++ b/src/hooks/runtimeAuthGuards.test.tsx @@ -161,3 +161,68 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => { expect(screen.getByTestId('saved-game').textContent).toBe('no'); expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); }); + +test('authenticated runtime autosave syncs live play time before remote snapshot upload', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-27T10:00:02.000Z')); + storageMocks.putSaveSnapshot.mockResolvedValue({ + gameState: {}, + bottomTab: 'adventure', + currentStory: null, + }); + + const gameState = { + runtimePersistenceDisabled: false, + runtimeMode: 'play', + currentScene: 'Story', + worldType: 'CUSTOM', + playerCharacter: { id: 'hero-1' }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: '2026-04-27T10:00:00.000Z', + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + } as GameState; + + function AutosaveHarness() { + useRpgSessionPersistence({ + authenticatedUserId: 'user-1', + gameState, + bottomTab: 'adventure' as BottomTab, + currentStory: { streaming: false } as StoryMoment, + isLoading: false, + setGameState: () => {}, + setBottomTab: () => {}, + hydrateStoryState: () => {}, + resetStoryState: () => {}, + }); + + return null; + } + + render(); + + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledTimes(1); + expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: expect.objectContaining({ + runtimeStats: expect.objectContaining({ + playTimeMs: 2400, + lastPlayTickAt: '2026-04-27T10:00:02.400Z', + }), + }), + }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); +}); diff --git a/src/services/big-fish-works/bigFishWorksClient.ts b/src/services/big-fish-works/bigFishWorksClient.ts index 54392319..66cadc82 100644 --- a/src/services/big-fish-works/bigFishWorksClient.ts +++ b/src/services/big-fish-works/bigFishWorksClient.ts @@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) { ); } +/** + * 记录已发布大鱼吃小鱼作品的一次正式进入。 + */ +export async function recordBigFishWorkPlay(sessionId: string) { + return requestJson( + `${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`, + { + method: 'POST', + }, + '记录大鱼吃小鱼游玩次数失败', + { + retry: BIG_FISH_WORKS_WRITE_RETRY, + }, + ); +} + export const bigFishWorksClient = { delete: deleteBigFishWork, list: listBigFishWorks, + recordPlay: recordBigFishWorkPlay, }; diff --git a/src/services/big-fish-works/index.ts b/src/services/big-fish-works/index.ts index 4b1b6798..2ab2252a 100644 --- a/src/services/big-fish-works/index.ts +++ b/src/services/big-fish-works/index.ts @@ -2,4 +2,5 @@ export { bigFishWorksClient, deleteBigFishWork, listBigFishWorks, + recordBigFishWorkPlay, } from './bigFishWorksClient'; From 6611852a97c3335d8901ceb478ab254f31b3e4ea Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 12:56:38 +0800 Subject: [PATCH 3/5] feat: add profile redeem code flow --- ...E_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md | 131 +++++++ docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 23 +- packages/shared/src/contracts/runtime.ts | 13 +- server-rs/crates/api-server/src/app.rs | 34 +- .../crates/api-server/src/runtime_profile.rs | 159 +++++++- server-rs/crates/module-runtime/src/lib.rs | 218 +++++++++++ .../crates/shared-contracts/src/runtime.rs | 55 +++ server-rs/crates/spacetime-client/src/lib.rs | 18 +- .../crates/spacetime-client/src/mapper.rs | 146 +++++++ ...n_disable_profile_redeem_code_procedure.rs | 53 +++ ...in_upsert_profile_redeem_code_procedure.rs | 53 +++ .../src/module_bindings/mod.rs | 22 ++ .../redeem_profile_reward_code_procedure.rs | 53 +++ ...le_redeem_code_admin_disable_input_type.rs | 17 + ...redeem_code_admin_procedure_result_type.rs | 19 + ...ile_redeem_code_admin_upsert_input_type.rs | 25 ++ .../runtime_profile_redeem_code_mode_type.rs | 20 + ...ntime_profile_redeem_code_snapshot_type.rs | 26 ++ ...e_profile_reward_code_redeem_input_type.rs | 17 + ...eward_code_redeem_procedure_result_type.rs | 19 + ...rofile_reward_code_redeem_snapshot_type.rs | 19 + ..._profile_wallet_ledger_source_type_type.rs | 2 + .../crates/spacetime-client/src/runtime.rs | 91 +++++ .../crates/spacetime-module/src/migration.rs | 2 + .../spacetime-module/src/runtime/profile.rs | 343 +++++++++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 355 +++++------------- src/services/rpg-entry/rpgProfileClient.ts | 17 + 27 files changed, 1671 insertions(+), 279 deletions(-) create mode 100644 docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs diff --git a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md new file mode 100644 index 00000000..02c593b3 --- /dev/null +++ b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md @@ -0,0 +1,131 @@ +# 资料兑换码模块落地设计 + +## 1. 目标 + +本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加叙世币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。 + +管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开叙世号两类输入,后端创建阶段统一解析成内部 `userId` 存储。 + +## 2. 兑换码类型 + +`RuntimeProfileRedeemCodeMode` 固定为三种: + +| 类型 | 规则 | +| --- | --- | +| `Public` | 任意用户可兑换,`max_uses` 按用户独立计算。 | +| `Unique` | 任意用户可兑换,`max_uses` 全局共用。 | +| `Private` | 仅 `allowed_user_ids` 中的用户可兑换,`max_uses` 全局共用。 | + +兑换码入库前必须 `trim + uppercase`。空兑换码、奖励为 0、次数为 0 均拒绝。 + +## 3. 表结构 + +### 3.1 `profile_redeem_code` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `code` | `String` | 主键,标准化后的兑换码。 | +| `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 | +| `reward_points` | `u64` | 单次到账叙世币。 | +| `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 | +| `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 | +| `enabled` | `bool` | 是否启用。 | +| `allowed_user_ids` | `Vec` | 私有码允许用户;公共/唯一码存空数组。 | +| `created_by` | `String` | 管理员用户 ID。 | +| `created_at` | `Timestamp` | 创建时间。 | +| `updated_at` | `Timestamp` | 更新时间。 | + +### 3.2 `profile_redeem_code_usage` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 | +| `code` | `String` | 兑换码。 | +| `user_id` | `String` | 兑换用户。 | +| `amount_granted` | `u64` | 到账叙世币。 | +| `created_at` | `Timestamp` | 兑换时间。 | + +索引:`code`、`user_id`、`(code, user_id)`。 + +## 4. SpacetimeDB 过程 + +### 4.1 用户兑换 + +`redeem_profile_reward_code(input: RuntimeProfileRewardCodeRedeemInput) -> RuntimeProfileRewardCodeRedeemProcedureResult` + +流程: + +1. 标准化 code。 +2. 校验兑换码存在、启用、奖励大于 0。 +3. 按模式校验使用范围与次数。 +4. 同一事务内写入 `profile_redeem_code_usage`、增加钱包余额、写入 `profile_wallet_ledger`,最后更新 `profile_redeem_code.global_used_count`。 +5. 返回 `walletBalance`、`amountGranted` 与本次 `ledgerEntry`。 + +### 4.2 管理创建/更新 + +`admin_upsert_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminUpsertInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` + +私有码必须至少解析出一个内部用户 ID。公共码与唯一码忽略 allowed 列表并存空数组。 + +### 4.3 管理停用 + +`admin_disable_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminDisableInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` + +只更新 `enabled=false` 与 `updated_at`,不存在时返回“兑换码不存在”。 + +## 5. Axum API + +用户接口: + +- `POST /api/profile/redeem-codes/redeem` +- `POST /api/runtime/profile/redeem-codes/redeem` + +请求:`{ "code": "WELCOME2026" }` + +成功返回: + +```json +{ + "walletBalance": 130, + "amountGranted": 100, + "ledgerEntry": { + "id": "redeem:WELCOME2026:user:1777392000000000:0", + "amountDelta": 100, + "balanceAfter": 130, + "sourceType": "redeem_code_reward", + "createdAt": "2026-04-28T00:00:00Z" + } +} +``` + +管理员接口: + +- `POST /admin/api/profile/redeem-codes` +- `POST /admin/api/profile/redeem-codes/disable` + +管理员接口复用现有 `require_admin_auth`。 + +## 6. 错误文案 + +| 场景 | message | +| --- | --- | +| 空 code | `兑换码不能为空` | +| 不存在 | `兑换码不存在` | +| 停用 | `兑换码已停用` | +| 奖励为 0 | `兑换码奖励无效` | +| 次数耗尽 | `兑换次数已用完` | +| 私有码账号不匹配 | `该兑换码不适用于当前账号` | +| 私有码无允许用户 | `私有兑换码必须指定可兑换用户` | + +## 7. 前端交互 + +“我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。 + +成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message`。 + +## 8. 测试矩阵 + +- Rust/module-runtime:覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。 +- Axum:覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。 +- 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。 +- 验证命令:`cargo test`、目标前端测试、`npm run api-server:maincloud`、`npm run check:encoding`。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index bbd7f5c3..edbb6008 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -23,7 +23,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | 领域 | 表 | | --- | --- | | 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | -| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` | +| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_played_world`, `profile_save_archive` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | @@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = ''; SELECT * FROM profile_wallet_ledger WHERE user_id = '' ORDER BY created_at DESC; ``` +### `profile_redeem_code` + +- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。 +- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:主键 `code`。 + +```sql +SELECT * FROM profile_redeem_code WHERE code = ''; +``` + +### `profile_redeem_code_usage` + +- 作用:记录每一次兑换行为,为公共码用户维度计次、唯一/私有码全局计次提供依据。 +- 结构:`usage_id PK: String`, `code: String`, `user_id: String`, `amount_granted: u64`, `created_at: Timestamp`。 +- 索引:`code`, `user_id`, `(code, user_id)`。 + +```sql +SELECT * FROM profile_redeem_code_usage WHERE code = ''; +SELECT * FROM profile_redeem_code_usage WHERE user_id = ''; +``` + ### `profile_played_world` - 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e11beb06..6345b70b 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -57,7 +57,8 @@ export type ProfileWalletLedgerEntry = { | 'invite_invitee_reward' | 'points_recharge' | 'asset_generation_consume' - | 'asset_generation_refund'; + | 'asset_generation_refund' + | 'redeem_code_reward'; createdAt: string; }; @@ -159,6 +160,16 @@ export type RedeemProfileReferralInviteCodeResponse = { inviterBalanceAfter: number; }; +export type RedeemProfileRewardCodeRequest = { + code: string; +}; + +export type RedeemProfileRewardCodeResponse = { + walletBalance: number; + amountGranted: number; + ledgerEntry: ProfileWalletLedgerEntry; +}; + export type ProfilePlayedWorkSummary = { worldKey: string; ownerUserId: string | null; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d732ca65..8cc51fb1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -83,8 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, - submit_puzzle_leaderboard, - swap_puzzle_pieces, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -95,9 +94,10 @@ use crate::{ runtime_chat::stream_runtime_npc_chat_turn, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ + admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, + redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -144,6 +144,20 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/profile/redeem-codes", + post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/redeem-codes/disable", + post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { @@ -848,6 +862,20 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/profile/redeem-codes/redeem", + post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/redeem-codes/redeem", + post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/profile/play-stats", get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 2efe3856..35034922 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -7,30 +7,36 @@ use axum::{ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, + RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, + RuntimeReferralRedeemRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ + AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse, - ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, - RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, + ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, + ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, + ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, + RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, + RedeemProfileRewardCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken, + http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn get_profile_dashboard( @@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND } + RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD + } } } @@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code( )) } +pub async fn redeem_profile_reward_code( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_redeem_profile_reward_code_response(record), + )) +} + +pub async fn admin_upsert_profile_redeem_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let record = state + .spacetime_client() + .admin_upsert_profile_redeem_code( + admin.session().subject.clone(), + payload.code, + mode, + payload.reward_points, + payload.max_uses, + payload.enabled, + payload.allowed_user_ids, + payload.allowed_public_user_codes, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_redeem_code_admin_response(record), + )) +} + +pub async fn admin_disable_profile_redeem_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_disable_profile_redeem_code( + admin.session().subject.clone(), + payload.code, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_redeem_code_admin_response(record), + )) +} + pub async fn get_profile_play_stats( State(state): State, Extension(request_context): Extension, @@ -396,6 +498,49 @@ fn build_redeem_profile_referral_invite_code_response( } } +fn build_redeem_profile_reward_code_response( + record: RuntimeProfileRewardCodeRedeemRecord, +) -> RedeemProfileRewardCodeResponse { + RedeemProfileRewardCodeResponse { + wallet_balance: record.wallet_balance, + amount_granted: record.amount_granted, + ledger_entry: ProfileWalletLedgerEntryResponse { + id: record.ledger_entry.wallet_ledger_id, + amount_delta: record.ledger_entry.amount_delta, + balance_after: record.ledger_entry.balance_after, + source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type) + .to_string(), + created_at: record.ledger_entry.created_at, + }, + } +} + +fn parse_profile_redeem_code_mode(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "public" => Ok(RuntimeProfileRedeemCodeMode::Public), + "unique" => Ok(RuntimeProfileRedeemCodeMode::Unique), + "private" => Ok(RuntimeProfileRedeemCodeMode::Private), + _ => Err("兑换码类型无效".to_string()), + } +} + +fn build_profile_redeem_code_admin_response( + record: RuntimeProfileRedeemCodeRecord, +) -> ProfileRedeemCodeAdminResponse { + ProfileRedeemCodeAdminResponse { + code: record.code, + mode: record.mode.as_str().to_string(), + reward_points: record.reward_points, + max_uses: record.max_uses, + global_used_count: record.global_used_count, + enabled: record.enabled, + allowed_user_ids: record.allowed_user_ids, + created_by: record.created_by, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + #[cfg(test)] mod tests { use module_runtime::RuntimeProfileWalletLedgerSourceType; diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 60a4a02a..fdf6de87 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType { PointsRecharge, AssetGenerationConsume, AssetGenerationRefund, + RedeemCodeReward, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRedeemCodeMode { + Public, + Unique, + Private, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput { pub created_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemInput { + pub user_id: String, + pub code: String, + pub redeemed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemSnapshot { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRewardCodeRedeemProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminUpsertInput { + pub admin_user_id: String, + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub allowed_public_user_codes: Vec, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminDisableInput { + pub admin_user_id: String, + pub code: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeSnapshot { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRedeemCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError { MissingLedgerId, InvalidWalletAmount, MissingInviteCode, + MissingRedeemCode, + InvalidRedeemCodeReward, + InvalidRedeemCodeMaxUses, MissingProductId, MissingWorldKey, MissingBottomTab, @@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord { pub has_points_recharged: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRewardCodeRedeemRecord { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRedeemCodeRecord { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: String, + pub created_at_micros: i64, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq)] pub struct RuntimeReferralInviteCenterRecord { pub user_id: String, @@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input( }) } +pub fn build_runtime_profile_reward_code_redeem_input( + user_id: String, + code: String, + redeemed_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + Ok(RuntimeProfileRewardCodeRedeemInput { + user_id, + code, + redeemed_at_micros, + }) +} + +pub fn build_runtime_profile_redeem_code_admin_upsert_input( + admin_user_id: String, + code: String, + mode: RuntimeProfileRedeemCodeMode, + reward_points: u64, + max_uses: u32, + enabled: bool, + allowed_user_ids: Vec, + allowed_public_user_codes: Vec, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + if reward_points == 0 { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); + } + if max_uses == 0 { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); + } + + Ok(RuntimeProfileRedeemCodeAdminUpsertInput { + admin_user_id, + code, + mode, + reward_points, + max_uses, + enabled, + allowed_user_ids: allowed_user_ids + .into_iter() + .filter_map(|value| normalize_optional_string(Some(value))) + .collect(), + allowed_public_user_codes: allowed_public_user_codes + .into_iter() + .filter_map(|value| normalize_optional_string(Some(value))) + .collect(), + updated_at_micros, + }) +} + +pub fn build_runtime_profile_redeem_code_admin_disable_input( + admin_user_id: String, + code: String, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; + Ok(RuntimeProfileRedeemCodeAdminDisableInput { + admin_user_id, + code, + updated_at_micros, + }) +} + pub fn build_runtime_profile_play_stats_get_input( user_id: String, ) -> Result { @@ -1323,6 +1494,35 @@ pub fn build_runtime_referral_redeem_record( } } +pub fn build_runtime_profile_reward_code_redeem_record( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> RuntimeProfileRewardCodeRedeemRecord { + RuntimeProfileRewardCodeRedeemRecord { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry), + } +} + +pub fn build_runtime_profile_redeem_code_record( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> RuntimeProfileRedeemCodeRecord { + RuntimeProfileRedeemCodeRecord { + code: snapshot.code, + mode: snapshot.mode, + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + pub fn build_runtime_profile_played_world_record( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> RuntimeProfilePlayedWorldRecord { @@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType { Self::PointsRecharge => "points_recharge", Self::AssetGenerationConsume => "asset_generation_consume", Self::AssetGenerationRefund => "asset_generation_refund", + Self::RedeemCodeReward => "redeem_code_reward", + } + } +} + +impl RuntimeProfileRedeemCodeMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Public => "public", + Self::Unique => "unique", + Self::Private => "private", } } } @@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option { } } +pub fn normalize_redeem_code(value: String) -> Option { + normalize_invite_code(value) +} + impl std::fmt::Display for RuntimeProfileFieldError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), + Self::MissingRedeemCode => f.write_str("兑换码不能为空"), + Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), + Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 57d5671d..6a89d016 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -11,6 +11,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str = "asset_generation_consume"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = "asset_generation_refund"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse { pub inviter_balance_after: u64, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedeemProfileRewardCodeRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedeemProfileRewardCodeResponse { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: ProfileWalletLedgerEntryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileRedeemCodeRequest { + pub code: String, + pub mode: String, + pub reward_points: u64, + pub max_uses: u32, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub allowed_user_ids: Vec, + #[serde(default)] + pub allowed_public_user_codes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDisableProfileRedeemCodeRequest { + pub code: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRedeemCodeAdminResponse { + pub code: String, + pub mode: String, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} + +fn default_true() -> bool { + true +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfilePlayedWorkSummaryResponse { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f990651d..cfbe290e 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,10 +30,10 @@ pub use mapper::{ PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; @@ -120,6 +120,8 @@ use module_runtime::{ RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, @@ -129,8 +131,12 @@ use module_runtime::{ build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, - build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, - build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input, + build_runtime_profile_redeem_code_admin_disable_input, + build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, + build_runtime_profile_reward_code_redeem_input, + build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input, + build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input, + build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 22d85795..c7a8f4dd 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -161,6 +161,48 @@ impl From } } +impl From + for RuntimeProfileRewardCodeRedeemInput +{ + fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self { + Self { + user_id: input.user_id, + code: input.code, + redeemed_at_micros: input.redeemed_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + mode: map_runtime_profile_redeem_code_mode(input.mode), + reward_points: input.reward_points, + max_uses: input.max_uses, + enabled: input.enabled, + allowed_user_ids: input.allowed_user_ids, + allowed_public_user_codes: input.allowed_public_user_codes, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminDisableInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for RuntimeReferralInviteCenterGetInput { @@ -802,6 +844,48 @@ pub(crate) fn map_runtime_referral_redeem_procedure_result( )) } +pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( + result: RuntimeProfileRewardCodeRedeemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 reward redeem 快照".to_string(), + ) + })?; + + Ok(build_runtime_profile_reward_code_redeem_record( + map_runtime_profile_reward_code_redeem_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( + result: RuntimeProfileRedeemCodeAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 redeem code 快照".to_string()) + })?; + + Ok(build_runtime_profile_redeem_code_record( + map_runtime_profile_redeem_code_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_play_stats_procedure_result( result: RuntimeProfilePlayStatsProcedureResult, ) -> Result { @@ -1666,6 +1750,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot( } } +pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), + } +} + +pub(crate) fn map_runtime_profile_redeem_code_snapshot( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> module_runtime::RuntimeProfileRedeemCodeSnapshot { + module_runtime::RuntimeProfileRedeemCodeSnapshot { + code: snapshot.code, + mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode), + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_profile_played_world_snapshot( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { @@ -3277,6 +3388,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode( + value: module_runtime::RuntimeProfileRedeemCodeMode, +) -> crate::module_bindings::RuntimeProfileRedeemCodeMode { + match value { + module_runtime::RuntimeProfileRedeemCodeMode::Public => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public + } + module_runtime::RuntimeProfileRedeemCodeMode::Unique => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique + } + module_runtime::RuntimeProfileRedeemCodeMode::Private => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeMode, +) -> module_runtime::RuntimeProfileRedeemCodeMode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => { + module_runtime::RuntimeProfileRedeemCodeMode::Public + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => { + module_runtime::RuntimeProfileRedeemCodeMode::Unique + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => { + module_runtime::RuntimeProfileRedeemCodeMode::Private + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs new file mode 100644 index 00000000..c254d1f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs @@ -0,0 +1,53 @@ +// 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::runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminDisableProfileRedeemCodeArgs { + pub input: RuntimeProfileRedeemCodeAdminDisableInput, +} + +impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs { + type Module = super::RemoteModule; +} + +pub trait admin_disable_profile_redeem_code { + fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) { + self.admin_disable_profile_redeem_code_then(input, |_, _| {}); + } + + fn admin_disable_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminDisableInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_disable_profile_redeem_code for super::RemoteProcedures { + fn admin_disable_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminDisableInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( + "admin_disable_profile_redeem_code", + AdminDisableProfileRedeemCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs new file mode 100644 index 00000000..cafe2382 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs @@ -0,0 +1,53 @@ +// 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::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; +use super::runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileRedeemCodeArgs { + pub input: RuntimeProfileRedeemCodeAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs { + type Module = super::RemoteModule; +} + +pub trait admin_upsert_profile_redeem_code { + fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) { + self.admin_upsert_profile_redeem_code_then(input, |_, _| {}); + } + + fn admin_upsert_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminUpsertInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_redeem_code for super::RemoteProcedures { + fn admin_upsert_profile_redeem_code_then( + &self, + input: RuntimeProfileRedeemCodeAdminUpsertInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>( + "admin_upsert_profile_redeem_code", + AdminUpsertProfileRedeemCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 76bf4e34..9acfb86d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -250,6 +250,9 @@ pub mod list_custom_world_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; +pub mod redeem_profile_reward_code_procedure; +pub mod admin_upsert_profile_redeem_code_procedure; +pub mod admin_disable_profile_redeem_code_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; pub mod npc_battle_interaction_procedure_result_type; @@ -413,6 +416,14 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_list_input_type; pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; +pub mod runtime_profile_redeem_code_mode_type; +pub mod runtime_profile_reward_code_redeem_input_type; +pub mod runtime_profile_reward_code_redeem_snapshot_type; +pub mod runtime_profile_reward_code_redeem_procedure_result_type; +pub mod runtime_profile_redeem_code_admin_upsert_input_type; +pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -719,6 +730,9 @@ pub use list_custom_world_works_procedure::list_custom_world_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; +pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; +pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; +pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; @@ -882,6 +896,14 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; +pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; +pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; +pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; +pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; +pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs new file mode 100644 index 00000000..5f5e7400 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs @@ -0,0 +1,53 @@ +// 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::runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; +use super::runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RedeemProfileRewardCodeArgs { + pub input: RuntimeProfileRewardCodeRedeemInput, +} + +impl __sdk::InModule for RedeemProfileRewardCodeArgs { + type Module = super::RemoteModule; +} + +pub trait redeem_profile_reward_code { + fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) { + self.redeem_profile_reward_code_then(input, |_, _| {}); + } + + fn redeem_profile_reward_code_then( + &self, + input: RuntimeProfileRewardCodeRedeemInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl redeem_profile_reward_code for super::RemoteProcedures { + fn redeem_profile_reward_code_then( + &self, + input: RuntimeProfileRewardCodeRedeemInput, + callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>( + "redeem_profile_reward_code", + RedeemProfileRewardCodeArgs { input }, + callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs new file mode 100644 index 00000000..5a7ed897 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_disable_input_type.rs @@ -0,0 +1,17 @@ +// 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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminDisableInput { + pub admin_user_id: String, + pub code: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminDisableInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs new file mode 100644 index 00000000..62254ff9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs new file mode 100644 index 00000000..5f18a875 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs @@ -0,0 +1,25 @@ +// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeAdminUpsertInput { + pub admin_user_id: String, + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub allowed_public_user_codes: Vec, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs new file mode 100644 index 00000000..4bea6d79 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_mode_type.rs @@ -0,0 +1,20 @@ +// 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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRedeemCodeMode { + Public, + + Unique, + + Private, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeMode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs new file mode 100644 index 00000000..aea09f25 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs @@ -0,0 +1,26 @@ +// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRedeemCodeSnapshot { + pub code: String, + pub mode: RuntimeProfileRedeemCodeMode, + pub reward_points: u64, + pub max_uses: u32, + pub global_used_count: u32, + pub enabled: bool, + pub allowed_user_ids: Vec, + pub created_by: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs new file mode 100644 index 00000000..e99bc781 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_input_type.rs @@ -0,0 +1,17 @@ +// 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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemInput { + pub user_id: String, + pub code: String, + pub redeemed_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs new file mode 100644 index 00000000..dd8936d7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs new file mode 100644 index 00000000..614e5d78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_snapshot_type.rs @@ -0,0 +1,19 @@ +// 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::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRewardCodeRedeemSnapshot { + pub wallet_balance: u64, + pub amount_granted: u64, + pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot, +} + +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index fc2093e3..3697b09f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -19,6 +19,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { AssetGenerationConsume, AssetGenerationRefund, + + RedeemCodeReward, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 67560b74..f95407cf 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -255,6 +255,97 @@ impl SpacetimeClient { .await } + pub async fn redeem_profile_reward_code( + &self, + user_id: String, + code: String, + redeemed_at_micros: i64, + ) -> Result { + let procedure_input = + build_runtime_profile_reward_code_redeem_input(user_id, code, redeemed_at_micros) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection.procedures().redeem_profile_reward_code_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_reward_code_redeem_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn admin_upsert_profile_redeem_code( + &self, + admin_user_id: String, + code: String, + mode: DomainRuntimeProfileRedeemCodeMode, + reward_points: u64, + max_uses: u32, + enabled: bool, + allowed_user_ids: Vec, + allowed_public_user_codes: Vec, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input( + admin_user_id, + code, + mode, + reward_points, + max_uses, + enabled, + allowed_user_ids, + allowed_public_user_codes, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn admin_disable_profile_redeem_code( + &self, + admin_user_id: String, + code: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_redeem_code_admin_disable_input( + admin_user_id, + code, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_disable_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_profile_play_stats( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 37a52649..34f500a4 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -109,6 +109,8 @@ macro_rules! migration_tables { user_browse_history, profile_dashboard_state, profile_wallet_ledger, + profile_redeem_code, + profile_redeem_code_usage, profile_invite_code, profile_referral_relation, profile_played_world, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 09ca0cc7..0042254a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -28,6 +28,39 @@ pub struct ProfileWalletLedger { pub(crate) created_at: Timestamp, } +#[spacetimedb::table(accessor = profile_redeem_code)] +pub struct ProfileRedeemCode { + #[primary_key] + pub(crate) code: String, + pub(crate) mode: RuntimeProfileRedeemCodeMode, + pub(crate) reward_points: u64, + pub(crate) max_uses: u32, + pub(crate) global_used_count: u32, + pub(crate) enabled: bool, + pub(crate) allowed_user_ids: Vec, + pub(crate) created_by: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_redeem_code_usage, + index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])), + index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_redeem_code_usage_code_user_id, + btree(columns = [code, user_id]) + ) +)] +pub struct ProfileRedeemCodeUsage { + #[primary_key] + pub(crate) usage_id: String, + pub(crate) code: String, + pub(crate) user_id: String, + pub(crate) amount_granted: u64, + pub(crate) created_at: Timestamp, +} + #[spacetimedb::table(accessor = profile_invite_code)] pub struct ProfileInviteCode { #[primary_key] @@ -396,6 +429,64 @@ pub fn redeem_profile_referral_invite_code( } } +// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。 +#[spacetimedb::procedure] +pub fn redeem_profile_reward_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRewardCodeRedeemInput, +) -> RuntimeProfileRewardCodeRedeemProcedureResult { + match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_redeem_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRedeemCodeAdminUpsertInput, +) -> RuntimeProfileRedeemCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_disable_profile_redeem_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileRedeemCodeAdminDisableInput, +) -> RuntimeProfileRedeemCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + pub(crate) fn list_profile_save_archive_rows( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, @@ -1194,6 +1285,185 @@ fn redeem_profile_referral_invite_code_record( }) } +fn redeem_profile_reward_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRewardCodeRedeemInput, +) -> Result { + let validated_input = build_runtime_profile_reward_code_redeem_input( + input.user_id, + input.code, + input.redeemed_at_micros, + ) + .map_err(|error| error.to_string())?; + let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); + let user_id = validated_input.user_id; + let code = validated_input.code; + let redeem_code = ctx + .db + .profile_redeem_code() + .code() + .find(&code) + .ok_or_else(|| "兑换码不存在".to_string())?; + + if !redeem_code.enabled { + return Err("兑换码已停用".to_string()); + } + if redeem_code.reward_points == 0 { + return Err("兑换码奖励无效".to_string()); + } + + let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); + match redeem_code.mode { + RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => { + return Err("兑换次数已用完".to_string()); + } + RuntimeProfileRedeemCodeMode::Unique + if redeem_code.global_used_count >= redeem_code.max_uses => + { + return Err("兑换次数已用完".to_string()); + } + RuntimeProfileRedeemCodeMode::Private => { + if !redeem_code + .allowed_user_ids + .iter() + .any(|item| item == &user_id) + { + return Err("该兑换码不适用于当前账号".to_string()); + } + if redeem_code.global_used_count >= redeem_code.max_uses { + return Err("兑换次数已用完".to_string()); + } + } + _ => {} + } + + let usage_id = build_profile_redeem_code_usage_id( + ctx, + &code, + &user_id, + validated_input.redeemed_at_micros, + ); + let wallet_ledger_id = format!("{}:ledger", usage_id); + let wallet_balance = apply_profile_wallet_delta( + ctx, + &user_id, + redeem_code.reward_points, + RuntimeProfileWalletLedgerSourceType::RedeemCodeReward, + &wallet_ledger_id, + redeemed_at, + )?; + + ctx.db + .profile_redeem_code_usage() + .insert(ProfileRedeemCodeUsage { + usage_id, + code: code.clone(), + user_id, + amount_granted: redeem_code.reward_points, + created_at: redeemed_at, + }); + + let next_code = ProfileRedeemCode { + global_used_count: redeem_code.global_used_count.saturating_add(1), + updated_at: redeemed_at, + ..redeem_code + }; + ctx.db.profile_redeem_code().code().delete(&code); + ctx.db.profile_redeem_code().insert(next_code); + + let ledger_entry = ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&wallet_ledger_id) + .ok_or_else(|| "兑换码钱包流水写入失败".to_string())?; + + Ok(RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance, + amount_granted: ledger_entry.amount_delta.max(0) as u64, + ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), + }) +} + +fn admin_upsert_profile_redeem_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRedeemCodeAdminUpsertInput, +) -> Result { + let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( + input.admin_user_id, + input.code, + input.mode, + input.reward_points, + input.max_uses, + input.enabled, + input.allowed_user_ids, + input.allowed_public_user_codes, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; + let existing = ctx + .db + .profile_redeem_code() + .code() + .find(&validated_input.code); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(updated_at); + let global_used_count = existing + .as_ref() + .map(|row| row.global_used_count) + .unwrap_or(0); + + if let Some(existing) = existing { + ctx.db.profile_redeem_code().code().delete(&existing.code); + } + + let row = ProfileRedeemCode { + code: validated_input.code, + mode: validated_input.mode, + reward_points: validated_input.reward_points, + max_uses: validated_input.max_uses, + global_used_count, + enabled: validated_input.enabled, + allowed_user_ids, + created_by: validated_input.admin_user_id, + created_at, + updated_at, + }; + let inserted = ctx.db.profile_redeem_code().insert(row); + Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) +} + +fn admin_disable_profile_redeem_code_record( + ctx: &ReducerContext, + input: RuntimeProfileRedeemCodeAdminDisableInput, +) -> Result { + let validated_input = build_runtime_profile_redeem_code_admin_disable_input( + input.admin_user_id, + input.code, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let existing = ctx + .db + .profile_redeem_code() + .code() + .find(&validated_input.code) + .ok_or_else(|| "兑换码不存在".to_string())?; + + ctx.db.profile_redeem_code().code().delete(&existing.code); + let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { + enabled: false, + updated_at, + ..existing + }); + Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) +} + fn build_profile_referral_invite_center_snapshot( ctx: &ReducerContext, user_id: &str, @@ -1579,6 +1849,79 @@ fn latest_profile_recharge_order( orders.into_iter().next() } +fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { + ctx.db + .profile_redeem_code_usage() + .iter() + .filter(|row| row.code == code && row.user_id == user_id) + .count() as u32 +} + +fn build_profile_redeem_code_usage_id( + ctx: &ReducerContext, + code: &str, + user_id: &str, + redeemed_at_micros: i64, +) -> String { + let sequence = ctx + .db + .profile_redeem_code_usage() + .iter() + .filter(|row| row.code == code && row.user_id == user_id) + .count(); + format!( + "redeem:{}:{}:{}:{}", + code, user_id, redeemed_at_micros, sequence + ) +} + +fn resolve_profile_redeem_code_allowed_user_ids( + ctx: &ReducerContext, + input: &RuntimeProfileRedeemCodeAdminUpsertInput, +) -> Result, String> { + if input.mode != RuntimeProfileRedeemCodeMode::Private { + return Ok(Vec::new()); + } + + let mut allowed_user_ids = input.allowed_user_ids.clone(); + for public_user_code in &input.allowed_public_user_codes { + if let Some(account) = ctx + .db + .user_account() + .by_user_account_public_code() + .filter(public_user_code) + .next() + { + allowed_user_ids.push(account.user_id); + } + } + allowed_user_ids.sort(); + allowed_user_ids.dedup(); + + if allowed_user_ids.is_empty() { + return Err("私有兑换码必须指定可兑换用户".to_string()); + } + + Ok(allowed_user_ids) +} + +fn build_profile_redeem_code_snapshot_from_row( + row: &ProfileRedeemCode, +) -> RuntimeProfileRedeemCodeSnapshot { + RuntimeProfileRedeemCodeSnapshot { + code: row.code.clone(), + mode: row.mode, + reward_points: row.reward_points, + max_uses: row.max_uses, + global_used_count: row.global_used_count, + enabled: row.enabled, + allowed_user_ids: row.allowed_user_ids.clone(), + created_by: row.created_by.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn build_profile_wallet_ledger_snapshot_from_row( row: &ProfileWalletLedger, ) -> RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 5b9d370c..b2e5462e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -8,7 +8,6 @@ import { Clock3, Coins, Copy, - Crown, House, LogIn, MessageCircle, @@ -34,19 +33,17 @@ import type { PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, - ProfileRechargeCenterResponse, - ProfileRechargeProduct, ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; import { - createRpgProfileRechargeOrder, - getRpgProfileRechargeCenter, getRpgProfileReferralInviteCenter, redeemRpgProfileReferralInviteCode, + redeemRpgProfileRewardCode, } from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -910,206 +907,68 @@ function ProfileShortcutButton({ ); } -function formatRechargePrice(priceCents: number) { - const yuan = priceCents / 100; - return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`; -} - -function formatMembershipDuration(days: number) { - if (days >= 365) { - return '365天'; - } - - return `${days}天`; -} - -function AccountRechargeModal({ - center, - activeTab, - isLoading, +function RewardCodeRedeemModal({ + value, isSubmitting, error, - onTabChange, + success, + onChange, + onSubmit, onClose, - onSelectProduct, }: { - center: ProfileRechargeCenterResponse | null; - activeTab: 'points' | 'membership'; - isLoading: boolean; - isSubmitting: string | null; + value: string; + isSubmitting: boolean; error: string | null; - onTabChange: (tab: 'points' | 'membership') => void; + success: string | null; + onChange: (value: string) => void; + onSubmit: () => void; onClose: () => void; - onSelectProduct: (product: ProfileRechargeProduct) => void; }) { - const visibleProducts = - activeTab === 'points' - ? (center?.pointProducts ?? []) - : (center?.membershipProducts ?? []); - return ( -
-
- -
-
-
- WALLET -
-
账户充值
-
- - - {center ? `${center.walletBalance}叙世币` : '叙世币账户'} - -
-
- -
- - -
- +
+
+
+
兑换码
+ +
+
+ onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onSubmit(); + } + }} + className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal" + placeholder="输入兑换码" + autoFocus + /> + {error ? ( -
+
{error}
) : null} - - {isLoading ? ( -
- {Array.from({ length: activeTab === 'points' ? 6 : 3 }).map( - (_, index) => ( -
- ), - )} + {success ? ( +
+ {success}
- ) : activeTab === 'points' ? ( -
- {visibleProducts.map((product) => ( - - ))} -
- ) : ( - <> -
- {visibleProducts.map((product) => ( - - ))} -
-
-
- 用户等级特权 -
-
-
- {center?.benefits.map((benefit) => ( -
-
- {benefit.benefitName} -
-
- {benefit.normalValue} -
-
- {benefit.monthValue} -
-
- {benefit.seasonValue} -
-
- {benefit.yearValue} -
-
- ))} -
-
-
- - )} + ) : null}
@@ -1294,16 +1153,13 @@ export function RpgEntryHomeView({ const authUi = useAuthUi(); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); - const [isRechargeOpen, setIsRechargeOpen] = useState(false); - const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>( - 'points', + const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false); + const [rewardCodeInput, setRewardCodeInput] = useState(''); + const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false); + const [rewardCodeError, setRewardCodeError] = useState(null); + const [rewardCodeSuccess, setRewardCodeSuccess] = useState( + null, ); - const [rechargeCenter, setRechargeCenter] = - useState(null); - const [rechargeError, setRechargeError] = useState(null); - const [isLoadingRecharge, setIsLoadingRecharge] = useState(false); - const [submittingRechargeProductId, setSubmittingRechargeProductId] = - useState(null); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -1401,36 +1257,6 @@ export function RpgEntryHomeView({ } authUi?.openLoginModal(); }; - const openRechargePanel = () => { - setIsRechargeOpen(true); - setRechargeError(null); - setIsLoadingRecharge(true); - void getRpgProfileRechargeCenter() - .then(setRechargeCenter) - .catch((error: unknown) => { - setRechargeCenter(null); - setRechargeError( - error instanceof Error ? error.message : '读取账户充值失败', - ); - }) - .finally(() => setIsLoadingRecharge(false)); - }; - const submitRechargeProduct = (product: ProfileRechargeProduct) => { - if (submittingRechargeProductId) { - return; - } - setSubmittingRechargeProductId(product.productId); - setRechargeError(null); - void createRpgProfileRechargeOrder(product.productId) - .then((response) => { - setRechargeCenter(response.center); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setRechargeError(error instanceof Error ? error.message : '充值失败'); - }) - .finally(() => setSubmittingRechargeProductId(null)); - }; const openProfilePopupPanel = (panel: ProfilePopupPanel) => { setProfilePopupPanel(panel); setReferralError(null); @@ -1486,6 +1312,30 @@ export function RpgEntryHomeView({ }) .finally(() => setIsSubmittingReferral(false)); }; + const openRewardCodeModal = () => { + setIsRewardCodeOpen(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + }; + const submitRewardCode = () => { + if (isSubmittingRewardCode || !rewardCodeInput.trim()) { + return; + } + + setIsSubmittingRewardCode(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + void redeemRpgProfileRewardCode(rewardCodeInput) + .then((response: RedeemProfileRewardCodeResponse) => { + setRewardCodeInput(''); + setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`); + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); + }) + .finally(() => setIsSubmittingRewardCode(false)); + }; const submitDesktopSearch = () => { const keyword = desktopSearchKeyword.trim(); if (!keyword || !onSearchPublicCode || isSearchingPublicCode) { @@ -1833,17 +1683,13 @@ export function RpgEntryHomeView({ @@ -2291,18 +2137,6 @@ export function RpgEntryHomeView({ ))}
- {isRechargeOpen ? ( - setIsRechargeOpen(false)} - onSelectProduct={submitRechargeProduct} - /> - ) : null} {profilePopupPanel ? (
- {isRechargeOpen ? ( - setIsRechargeOpen(false)} - onSelectProduct={submitRechargeProduct} + {isRewardCodeOpen ? ( + setIsRewardCodeOpen(false)} /> ) : null} {profilePopupPanel ? ( diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 6947ff68..fbdb9e0b 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -11,6 +11,7 @@ import type { ProfileSaveArchiveResumeResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeResponse, RuntimeSettings, } from '../../../packages/shared/src/contracts/runtime'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; @@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode( ); } +export function redeemRpgProfileRewardCode( + code: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + '/profile/redeem-codes/redeem', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }, + '兑换失败', + options, + ); +} + export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/play-stats', From fb965a12075c3a99906ff6c5773975ef47eb6599 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 14:14:21 +0800 Subject: [PATCH 4/5] perf: use redeem usage index --- .../crates/spacetime-module/src/runtime/profile.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 0042254a..fd33a9c3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1852,8 +1852,8 @@ fn latest_profile_recharge_order( fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { ctx.db .profile_redeem_code_usage() - .iter() - .filter(|row| row.code == code && row.user_id == user_id) + .by_profile_redeem_code_usage_code_user_id() + .filter((code, user_id)) .count() as u32 } @@ -1863,12 +1863,7 @@ fn build_profile_redeem_code_usage_id( user_id: &str, redeemed_at_micros: i64, ) -> String { - let sequence = ctx - .db - .profile_redeem_code_usage() - .iter() - .filter(|row| row.code == code && row.user_id == user_id) - .count(); + let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id); format!( "redeem:{}:{}:{}:{}", code, user_id, redeemed_at_micros, sequence From 1d319ba9169e0b1661a58b7b3a48f7c5b10bebe8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Apr 2026 14:46:18 +0800 Subject: [PATCH 5/5] feat: add dev password auto registration --- .env.example | 2 + .../PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md | 11 ++ server-rs/crates/api-server/src/app.rs | 30 +++++ server-rs/crates/api-server/src/config.rs | 7 ++ .../crates/api-server/src/password_entry.rs | 21 ++-- server-rs/crates/module-auth/src/lib.rs | 112 ++++++++++++++++++ 6 files changed, 175 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 1f06973c..879be395 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ 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" # 手机号验证码登录配置(阿里云 PNVS)。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。 diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md index 6f45b1f1..fdfb33ea 100644 --- a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -1,6 +1,8 @@ # 密码登录入口历史落地设计 > 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 +> +> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。 日期:`2026-04-21` @@ -166,6 +168,13 @@ 2. 不创建账号。 3. 不写 `password_hash`。 +开发期例外: + +1. 当 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 时,未知手机号会创建手机号账号。 +2. 新账号立即写入本次密码的 `password_hash`,并将 `password_login_enabled` 置为 `true`。 +3. 成功响应沿用密码登录响应体,`created` 只保留在领域结果中,不额外暴露到当前 HTTP contract。 +4. 手机号格式和密码长度校验仍完全沿用正式密码入口规则。 + ### 8.2 未设置密码 当账号存在但 `password_login_enabled = false` 时: @@ -233,6 +242,8 @@ 4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`。 5. 登录成功时返回 access token。 6. 登录成功时写回 refresh cookie。 +7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。 +8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`。 ## 13. 完成定义 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8cc51fb1..3d3fc35a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1382,6 +1382,36 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() { + let config = AppConfig { + dev_password_entry_auto_register_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let first_response = + password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await; + let first_status = first_response.status(); + let first_body = first_response + .into_body() + .collect() + .await + .expect("first response body should collect") + .to_bytes(); + let first_payload: Value = + serde_json::from_slice(&first_body).expect("first response body should be valid json"); + let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await; + + assert_eq!(first_status, StatusCode::OK); + assert!(first_payload["token"].as_str().is_some()); + assert_eq!( + first_payload["user"]["loginMethod"], + Value::String("password".to_string()) + ); + assert_eq!(second_response.status(), StatusCode::OK); + } + #[tokio::test] async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e2c497d5..deb69712 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -29,6 +29,7 @@ pub struct AppConfig { 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, pub sms_endpoint: String, @@ -118,6 +119,7 @@ impl Default for AppConfig { 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(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), @@ -273,6 +275,11 @@ impl AppConfig { 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"]) + { + config.dev_password_entry_auto_register_enabled = enabled; + } if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { config.sms_auth_enabled = sms_auth_enabled; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 743adcf3..52bbd3a3 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -26,14 +26,19 @@ pub async fn password_entry( headers: HeaderMap, Json(payload): Json, ) -> Result { - let result = state - .password_entry_service() - .execute(PasswordEntryInput { - phone_number: payload.phone, - password: payload.password, - }) - .await - .map_err(map_password_entry_error)?; + let input = PasswordEntryInput { + phone_number: payload.phone, + password: payload.password, + }; + let result = if state.config.dev_password_entry_auto_register_enabled { + state + .password_entry_service() + .execute_with_dev_registration(input) + .await + } else { + state.password_entry_service().execute(input).await + } + .map_err(map_password_entry_error)?; let session_client = resolve_session_client_context(&headers); let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; state diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 1952d725..57005c54 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -486,6 +486,38 @@ impl PasswordEntryService { verify_stored_password_user(existing_user, &input.password).await } + pub async fn execute_with_dev_registration( + &self, + input: PasswordEntryInput, + ) -> Result { + validate_password(&input.password)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) + .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + if let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + { + return verify_stored_password_user(existing_user, &input.password).await; + } + + let password_hash = hash_password(&input.password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self.store.create_dev_password_phone_user( + normalized_phone.clone(), + normalized_phone.masked_national_number, + password_hash, + )?; + + Ok(PasswordEntryResult { + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..user + }, + created: true, + }) + } + pub fn get_user_by_id( &self, user_id: &str, @@ -1336,6 +1368,53 @@ impl InMemoryAuthStore { Ok(user) } + fn create_dev_password_phone_user( + &self, + phone_number: PhoneNumberSnapshot, + display_name: String, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if state.phone_to_user_id.contains_key(&phone_number.e164) { + return Err(PasswordEntryError::InvalidCredentials); + } + + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + token_version: 1, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: true, + phone_number: Some(phone_number.e164), + }, + ); + self.persist_password_state(&state)?; + + Ok(user) + } + fn create_pending_wechat_user( &self, profile: WechatIdentityProfile, @@ -2474,6 +2553,39 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidCredentials); } + #[tokio::test] + async fn password_entry_dev_registration_creates_unknown_phone_user() { + let service = build_password_service(build_store()); + + let created = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user"); + let reused = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("same password should reuse created user"); + let wrong_password = service + .execute_with_dev_registration(PasswordEntryInput { + phone_number: "13800138009".to_string(), + password: "secret999".to_string(), + }) + .await + .expect_err("existing user still requires the right password"); + + assert!(created.created); + assert_eq!(created.user.login_method, AuthLoginMethod::Password); + assert!(!reused.created); + assert_eq!(created.user.id, reused.user.id); + assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); + } + #[tokio::test] async fn phone_user_can_set_password_then_login() { let store = build_store();