From c3fbf7a30bd1b04c78ac6801e789a8b56680fdf6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 12:26:39 +0800 Subject: [PATCH 01/14] feat: tighten visual novel one-line generation flow --- ...-visual-novel-one-line-genarrative-plan.md | 343 ++++++++++++++++++ .../api-server/src/prompt/visual_novel.rs | 37 +- .../visualNovelEntryGeneration.test.ts | 100 +++++ .../visualNovelEntryGeneration.ts | 71 +++- 4 files changed, 533 insertions(+), 18 deletions(-) create mode 100644 .hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md create mode 100644 src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts diff --git a/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md b/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md new file mode 100644 index 00000000..3a5d2fe4 --- /dev/null +++ b/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md @@ -0,0 +1,343 @@ +# Genarrative 视觉小说“一句话生成”最小闭环落地计划 + +生成时间:2026-05-13 11:22 +工作区:`C:/proj/Genarrative/.worktrees/hermes-visual-novel` +参考文档:`C:/Users/DSK/Documents/Interactive-fiction/一句话生成视觉小说整体流程总结.md` + +## 1. 目标 + +把 Interactive-fiction 总结文档中的“一句话生成视觉小说”流程,映射并落地到 Genarrative 现有视觉小说能力中,优先做成一个可端到端验证的最小闭环: + +1. 用户在视觉小说入口输入一句话并选择画风。 +2. 前端进入生成过程页,展示分阶段进度。 +3. 后端创建视觉小说创作会话,并基于 seedText 生成 `VisualNovelResultDraft`。 +4. 生成完成后进入草稿结果页,可看到世界观、角色、场景、剧情阶段、开场选择。 +5. 草稿可编译/保存为作品 profile,并进入视觉小说运行态测试/正式游玩。 + +本计划只覆盖 Genarrative 内部最小闭环,不引入 Interactive-fiction 原项目的独立 TXT 播放记录、分享播放包、外部活动运营、独立账号/交易/资产系统。 + +## 2. 当前上下文与已发现实现 + +### 2.1 Interactive-fiction 总结文档提炼 + +参考文档将整体流程分为: + +- 输入侧:一句话创意、主题/风格、可选文档或素材。 +- 生成侧:理解意图、扩展世界观、角色、场景、剧情阶段、开场与选择。 +- 编辑侧:草稿页可查看和调整生成结果。 +- 运行侧:从草稿进入视觉小说游玩,支持剧情推进、玩家选择、历史与状态。 +- 资产侧:角色立绘、背景、音乐/音效可作为后续增强,最小闭环可先使用文字描述与空资产占位。 + +### 2.2 Genarrative 已有实现基础 + +已确认项目中视觉小说相关能力并非从零开始: + +- 前端入口表单: + - `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` + - 已有“一句话创作” textarea、6 个视觉画风选项、提交按钮“生成视觉小说草稿”。 +- 前端入口 payload/progress: + - `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` + - 已有 `VisualNovelEntryFormPayload`、锚点展示、一句话/画风生成进度步骤。 +- 前端平台主流程: + - `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + - 已接入 `createVisualNovelDraftFromForm`,会创建 session、stream message、进入 `visual-novel-generating`,完成后进入 `visual-novel-result`。 +- 前端 API client: + - `src/services/visual-novel-creation/visualNovelCreationClient.ts` + - 已封装 session/message/action/compile 接口。 +- 共享契约: + - `packages/shared/src/contracts/visualNovel.ts` + - 已定义 `VisualNovelResultDraft`、world/characters/scenes/storyPhases/opening/runtimeConfig/work/run/history 等结构。 +- 后端 API: + - `server-rs/crates/api-server/src/visual_novel.rs` + - 已有创建 session、发消息、流式消息、执行 action、compile、work、runtime run 等接口。 +- 后端 prompt: + - `server-rs/crates/api-server/src/prompt/visual_novel.rs` + - 已有 `VISUAL_NOVEL_CREATION_SYSTEM_PROMPT`、结构化输出契约、runtime GM prompt、repair prompt。 +- SpacetimeDB 模块: + - `server-rs/crates/spacetime-module/src/visual_novel.rs` + - 已有 session/message/work/run/history/event 表与 procedure。 +- 文档参考: + - `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` + - `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md` + - `docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md` + +### 2.3 关键实现判断 + +当前项目已经实现了视觉小说的主要骨架,本次不应大规模重写。更合理的落地方式是补齐“一句话生成”闭环中最容易断裂的点: + +- 入口输入与画风信息是否被稳定传给后端 prompt。 +- 后端生成 draft 后是否自动保存/关联可编辑 work profile。 +- 生成过程页是否能清晰展示 Interactive-fiction 文档中提到的阶段。 +- 结果页是否有足够的字段展示与继续游玩入口。 +- 运行态是否能基于 opening/choices 正常启动,而不依赖尚未生成的图片/音乐资产。 + +## 3. 拟采用方案 + +### 3.1 最小闭环范围 + +本次优先实现: + +1. “一句话 + 视觉画风”作为 `sourceMode: 'idea'` 的 seedText。 +2. 后端生成完整 `VisualNovelResultDraft`,包括: + - world + - 3-6 个角色 + - 3-8 个场景 + - 3-6 个剧情阶段 + - opening narration/firstDialogue/2-4 个 choices + - runtimeConfig +3. 若 LLM 输出失败,使用 repair 或确定性 fallback,保证可回到草稿页并显示错误/警告。 +4. 结果页支持保存/编译为 work profile。 +5. work profile 支持启动 runtime run,opening 能展示初始场景、旁白、对话和选择。 + +暂不做或仅预留: + +- 真实图片/音乐生成队列。 +- 多文档解析导入的完整链路。 +- 复杂分镜/节点图编辑器。 +- 外部 Interactive-fiction 项目的播放器、TXT 记录包、分享活动、独立账号系统。 + +### 3.2 与 Genarrative 架构的映射 + +| Interactive-fiction 概念 | Genarrative 落点 | +| --- | --- | +| 一句话创意 | `VisualNovelEntryFormPayload.ideaText` / `seedText` | +| 画风/主题 | `seedText` 中的“视觉画风/画风要求”,后续可结构化为 metadata | +| 世界观设定 | `VisualNovelResultDraft.world` | +| 角色设定 | `VisualNovelResultDraft.characters` | +| 场景设定 | `VisualNovelResultDraft.scenes` | +| 剧情阶段/章节 | `VisualNovelResultDraft.storyPhases` | +| 开场文本与选项 | `VisualNovelResultDraft.opening` | +| 运行时剧情推进 | `VisualNovelRuntimeStep[]` + run snapshot/history | +| 发布/作品库 | `VisualNovelWorkProfileRecord` / works API | + +## 4. 分步计划 + +### Step 1:补齐入口 payload 与生成过程语义 + +涉及文件: + +- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +任务: + +1. 保持现有 6 个画风选项,但确认每个 option 的 prompt 会进入 `seedText`。 +2. 将生成过程阶段从当前 3 步细化为更贴合参考文档的 4-5 步,例如: + - 理解一句话创意 + - 扩展世界观与玩家身份 + - 设计角色/场景/剧情阶段 + - 生成开场与选择 + - 准备可编辑草稿 +3. 生成过程页的 anchor 保留“一句话”和“视觉画风”,必要时增加“生成目标:视觉小说草稿”。 +4. 确认 `createVisualNovelDraftFromForm` 对失败状态会保留返回入口/重试能力。 + +验收点:提交一句话后能进入 `visual-novel-generating`,看到阶段进度;完成后进入 `visual-novel-result`。 + +### Step 2:增强后端 creation prompt 与 fallback 约束 + +涉及文件: + +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `server-rs/crates/api-server/src/visual_novel.rs` +- 如已有 domain crate:`server-rs/crates/module-visual-novel/**` 或相关 normalize/validate 文件 + +任务: + +1. 在 creation prompt 中显式吸收 Interactive-fiction 的“一句话生成”目标: + - 从 seedText 提取核心创意、视觉风格、故事类型。 + - 生成可直接运行的 opening 和 choices。 + - 图片/音乐资产先置 null,但必须有可生成图像的描述。 +2. 强化输出约束: + - `opening.sceneId` 必须指向存在且 availability 为 `opening` 的 scene。 + - `opening.initialChoices` 必须 2-4 个。 + - `storyPhases[0]` 必须包含 opening scene 和主要角色。 + - `publishReady` 的判定与 validationIssues 一致。 +3. 检查 `submit_visual_novel_message_turn` / `resolve_action_draft` / compile 相关代码: + - 如果 LLM 失败,是否已有 fallback;没有则补确定性 fallback draft。 + - 如果 draft 不完整,是否会 normalize/repair 并写入 session。 +4. 保留现有“不要输出旧 TXT 播放记录、分享播放包、外部商业字段”的约束,避免把参考项目的外部概念误并入 Genarrative。 + +验收点:后端给定 seedText 时,返回 session.draft 不为空且满足共享契约。 + +### Step 3:确认草稿结果页、保存/编译与作品库链路 + +涉及文件: + +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- `src/components/visual-novel-creation/**` +- `src/services/visual-novel-works*` 或相关 visual novel works client +- `server-rs/crates/api-server/src/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +任务: + +1. 查找并确认 `visual-novel-result` 页面组件: + - 是否显示 workTitle/workDescription/world/characters/scenes/storyPhases/opening。 + - 是否有保存/发布/开始试玩按钮。 +2. 确认 `compileVisualNovelWorkProfile` 或 `executeVisualNovelAction({kind:'compile_work_profile'})` 会生成/更新 work profile。 +3. 确认作品架上使用 `profileId` 而不是 sessionId 作为稳定作品 ID。 +4. 如果结果页缺少“一句话来源/画风”的可视化提示,可在结果页或 summary 中补轻量展示,避免用户以为画风丢失。 + +验收点:生成完成后能保存为作品;作品出现在“我的作品/创作架”;再次打开能读取同一 draft。 + +### Step 4:确认运行态 opening 闭环 + +涉及文件: + +- `src/components/visual-novel-runtime/**` +- `src/services/visual-novel-runtime*` +- `server-rs/crates/api-server/src/visual_novel.rs` +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +任务: + +1. 启动 visual novel work run 时,优先使用 `draft.opening` 生成第一轮 runtime snapshot/history。 +2. 如果没有图片/音乐,前端 runtime shell 必须可用文字 fallback,不应白屏或阻断游玩。 +3. 玩家选择 `choice` 后,后端 runtime GM prompt 生成下一轮 `VisualNovelRuntimeStep[]`。 +4. 确认正式游玩入口调用 `work_play_start`,并满足已有埋点约定: + - `scope_kind=work` + - `scope_id=稳定作品 ID` + - metadata 包含 `playType/workId/sourceRoute/userId` 等。 + +验收点:从生成出的作品进入运行态,能看到 opening 并点击至少一个选择推进一轮。 + +### Step 5:补测试与文档 + +涉及文件: + +- 前端测试:按仓库现有测试布局查找 `*.test.ts` / `*.test.tsx` +- Rust 测试:`server-rs/crates/api-server/src/**` 或 domain crate tests +- 文档:可追加到 `docs/technical/` 或 `.hermes/shared-memory/decision-log.md`(如团队约定需要) + +建议测试: + +1. TypeScript 单元测试: + - `buildVisualNovelEntryGenerationProgress` 阶段输出。 + - `buildVisualNovelEntryGenerationAnchorEntries` 能展示一句话和画风。 +2. Rust 单元测试: + - creation prompt 包含 seedText、sourceMode、输出契约。 + - draft normalize/fallback 能生成合法 opening/choices。 + - runtime opening 或 first-step 构造不依赖图片/音乐。 +3. 集成/手工测试文档: + - 访问平台视觉小说入口。 + - 输入一句话。 + - 选择画风。 + - 点击生成。 + - 查看结果页。 + - 保存作品。 + - 启动试玩并点击选择。 + +## 5. 可能改动文件清单 + +高概率改动: + +- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `server-rs/crates/api-server/src/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +中概率改动: + +- `src/components/visual-novel-runtime/**` +- `src/services/visual-novel-creation/**` +- `src/services/visual-novel-runtime/**` +- `src/services/visual-novel-works/**` +- `server-rs/crates/spacetime-module/src/visual_novel.rs` +- `server-rs/crates/spacetime-client/**` 生成/绑定文件,若 SpacetimeDB contract 需要更新 + +低概率/仅文档: + +- `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md` +- `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` +- `.hermes/shared-memory/decision-log.md` + +## 6. 验证计划 + +### 6.1 静态检查 + +在 worktree 根目录执行: + +```bash +npm run typecheck +``` + +如仓库无统一 typecheck,则按 package scripts 选择最接近的前端类型检查命令。 + +### 6.2 前端定向测试 + +优先运行与 visual novel / platform entry 相关测试,如存在: + +```bash +npm test -- visual-novel +npm test -- platform-entry +``` + +若仓库使用 vitest: + +```bash +npm run test -- visual-novel +``` + +### 6.3 Rust 定向测试 + +在 `server-rs` 下运行 visual novel 相关测试: + +```bash +cargo test -p api-server visual_novel +cargo test -p shared-contracts visual_novel +``` + +如改动 SpacetimeDB module: + +```bash +cargo test -p spacetime-module visual_novel +``` + +### 6.4 人工验收步骤 + +1. 启动本地 dev 栈。 +2. 访问 Genarrative 主站。 +3. 进入创作/视觉小说入口。 +4. 输入:`一个雨夜,失忆的高中生在旧图书馆发现一本会回应她心声的日记。` +5. 选择任一画风,例如“映画动画”。 +6. 点击“生成视觉小说草稿”。 +7. 预期:进入生成过程页,能看到分阶段进度。 +8. 预期:完成后进入草稿结果页,包含标题、简介、世界观、角色、场景、剧情阶段和 opening choices。 +9. 点击保存/编译作品。 +10. 从作品入口进入试玩。 +11. 预期:opening 文本出现,至少 2 个选择可点击;点击后剧情继续推进一轮。 + +## 7. 风险、权衡与开放问题 + +### 7.1 风险 + +- 现有视觉小说代码已较完整,贸然新增一套 parallel pipeline 会制造重复逻辑;应复用当前 `VisualNovelResultDraft` 与 creation agent flow。 +- LLM 输出不稳定可能导致草稿结构不完整;需要 normalize/repair/fallback 确保最小闭环。 +- 视觉/音乐资产生成未接入时,UI 必须接受 null asset,否则运行态可能白屏。 +- `PlatformEntryFlowShellImpl.tsx` 文件很大,改动需局部、谨慎,避免影响其他玩法入口。 +- 若改动 SpacetimeDB 表结构,可能牵涉 publish、client binding、清库/迁移;最小闭环阶段应尽量避免 schema 变更。 + +### 7.2 权衡 + +- 先让文字版视觉小说完整跑通,再补角色立绘/背景图生成。 +- 先用 `seedText` 承载画风,再考虑把 `visualStyleId/Label/Prompt` 结构化进 draft metadata。 +- 先用现有 result/work/runtime 页面闭环,不引入新编辑器。 + +### 7.3 开放问题 + +1. 用户是否要求把 Interactive-fiction 原项目中的具体 UI 样式/页面布局迁移到 Genarrative?当前计划只迁移流程语义,不迁移独立 UI。 +2. 画风是否需要成为作品可编辑字段?当前以 seedText/prompt 影响生成内容,后续可在 draft 中增加 metadata。 +3. 文档导入模式是否本期要做?当前计划聚焦一句话模式,document 模式只保留契约能力。 +4. 是否需要真实图片/音乐生成?当前计划作为后续增强,不纳入最小闭环。 + +## 8. 建议实施顺序 + +1. 先做只改 prompt/progress/少量前端展示的轻量闭环修补。 +2. 运行前后端定向测试,确认现有能力是否已足够。 +3. 如果后端没有 fallback 或 normalize,再补 Rust 层确定性兜底。 +4. 手工跑通“一句话 -> 生成 -> 结果页 -> 保存 -> 试玩”。 +5. 最后再考虑是否需要资产生成、文档导入、结构化画风 metadata。 diff --git a/server-rs/crates/api-server/src/prompt/visual_novel.rs b/server-rs/crates/api-server/src/prompt/visual_novel.rs index 24289717..8dcc8ba9 100644 --- a/server-rs/crates/api-server/src/prompt/visual_novel.rs +++ b/server-rs/crates/api-server/src/prompt/visual_novel.rs @@ -224,11 +224,23 @@ pub(crate) fn build_visual_novel_creation_user_prompt( "currentDraft": params.current_draft, "recentMessages": params.recent_messages, "nowIso": params.now_iso, + "oneLineGenerationFlow": [ + "提取一句话核心创意、故事类型、玩家身份和视觉画风", + "扩展世界观、故事前提、文学风格和默认叙事语气", + "设计 3 到 6 个角色,并为每个角色写出可生成立绘的 appearance", + "设计 3 到 8 个场景,并为 opening 场景写出可生成背景图的 description", + "组织 3 到 6 个剧情阶段,第一阶段必须能从 opening 进入", + "生成 opening.narration、可选 firstDialogue 和 2 到 4 个 initialChoices", + "图片、音乐可先为 null,但文字草稿必须可进入结果页编辑、保存并试玩" + ], "draftRequirements": { "mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色", "scenes": "3 到 8 个,至少 1 个 opening 场景", "storyPhases": "3 到 6 个,第一阶段可从 opening 进入", - "initialChoices": "2 到 4 个", + "initialChoices": "2 到 4 个 initialChoices", + "openingScene": "opening.sceneId 必须指向存在且 availability 为 opening 的 scene", + "firstPhase": "storyPhases[0] 必须包含 opening scene 和主要角色", + "assetFallback": "图片、音乐可先为 null,但 appearance 和 scene description 必须足够后续生成资产", "runtimeConfigDefaults": "沿用契约默认值,attributePanelMode 默认为 off" }, "outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT @@ -616,6 +628,29 @@ mod tests { assert!(repair_prompt.contains("scene_change")); } + #[test] + fn creation_prompt_guides_one_line_flow_into_playable_draft() { + let asset_ids = source_asset_ids(); + let prompt = build_visual_novel_creation_user_prompt(VisualNovelCreationPromptParams { + source_mode: "idea", + seed_text: Some( + "雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。", + ), + source_asset_ids: asset_ids.as_slice(), + document_summary: None, + current_draft: None, + recent_messages: &[], + now_iso: "2026-05-13T12:00:00Z", + }); + + assert!(prompt.contains("oneLineGenerationFlow")); + assert!(prompt.contains("提取一句话核心创意")); + assert!(prompt.contains("视觉画风")); + assert!(prompt.contains("opening.sceneId")); + assert!(prompt.contains("2 到 4 个 initialChoices")); + assert!(prompt.contains("图片、音乐可先为 null")); + } + #[test] fn llm_requests_use_responses_template_model() { let asset_ids = source_asset_ids(); diff --git a/src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts b/src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts new file mode 100644 index 00000000..63f64d5f --- /dev/null +++ b/src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildVisualNovelEntryGenerationAnchorEntries, + buildVisualNovelEntryGenerationProgress, + type VisualNovelEntryFormPayload, +} from './visualNovelEntryGeneration'; + +function createVisualNovelPayload( + overrides: Partial = {}, +): VisualNovelEntryFormPayload { + return { + sourceMode: 'idea', + seedText: + '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。', + sourceAssetIds: [], + ideaText: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。', + visualStyleId: 'cinematic-anime', + visualStyleLabel: '映画动画', + visualStylePrompt: '电影感动画视觉小说画风。', + ...overrides, + }; +} + +describe('visualNovelEntryGeneration', () => { + test('one-line visual novel generation exposes reference-flow stages', () => { + const progress = buildVisualNovelEntryGenerationProgress( + 1_000, + 'generating', + 1_500, + ); + + expect(progress.steps.map((step) => step.id)).toEqual([ + 'visual-novel-intent', + 'visual-novel-world', + 'visual-novel-cast-scenes', + 'visual-novel-opening', + 'visual-novel-ready', + ]); + expect(progress.phaseLabel).toBe('理解一句话创意'); + expect(progress.steps[0]?.detail).toBe( + '提取核心题材、视觉画风、玩家身份和互动叙事目标。', + ); + expect(progress.estimatedRemainingMs).toBe(44_500); + expect(progress.overallProgress).toBeGreaterThan(0); + }); + + test('one-line visual novel generation advances to opening choices before ready', () => { + const progress = buildVisualNovelEntryGenerationProgress( + 1_000, + 'generating', + 35_000, + ); + + expect(progress.phaseId).toBe('visual-novel-opening'); + expect(progress.phaseLabel).toBe('生成开场与选择'); + expect(progress.steps[2]?.status).toBe('completed'); + expect(progress.steps[3]?.status).toBe('active'); + expect(progress.overallProgress).toBeLessThan(99); + }); + + test('one-line visual novel generation ready copy points to editable draft', () => { + const progress = buildVisualNovelEntryGenerationProgress( + 1_000, + 'ready', + 46_000, + ); + + expect(progress.phaseId).toBe('ready'); + expect(progress.phaseLabel).toBe('生成完成'); + expect(progress.phaseDetail).toBe( + '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。', + ); + expect(progress.overallProgress).toBe(100); + }); + + test('one-line visual novel generation anchors include source, style and target', () => { + const entries = buildVisualNovelEntryGenerationAnchorEntries( + createVisualNovelPayload(), + ); + + expect(entries).toEqual([ + { + id: 'visual-novel-idea', + label: '一句话', + value: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。', + }, + { + id: 'visual-novel-style', + label: '视觉画风', + value: '映画动画', + }, + { + id: 'visual-novel-target', + label: '生成目标', + value: '可编辑并可试玩的视觉小说草稿', + }, + ]); + }); +}); diff --git a/src/components/visual-novel-creation/visualNovelEntryGeneration.ts b/src/components/visual-novel-creation/visualNovelEntryGeneration.ts index ad6cb4b3..8bbb8b25 100644 --- a/src/components/visual-novel-creation/visualNovelEntryGeneration.ts +++ b/src/components/visual-novel-creation/visualNovelEntryGeneration.ts @@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries( label: '视觉画风', value: payload.visualStyleLabel, }, + { + id: 'visual-novel-target', + label: '生成目标', + value: '可编辑并可试玩的视觉小说草稿', + }, ].filter((entry) => entry.value.trim()); } @@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress( weight: number; durationMs: number; }, - ] = [ { - id: 'visual-novel-session', - label: '创建创作会话', - detail: '写入一句话与视觉画风,准备生成视觉小说底稿。', - weight: 24, - durationMs: 5_000, + id: string; + label: string; + detail: string; + weight: number; + durationMs: number; }, { - id: 'visual-novel-draft', - label: '生成故事底稿', - detail: '整理世界观、角色、场景和剧情阶段。', - weight: 56, - durationMs: 22_000, + id: string; + label: string; + detail: string; + weight: number; + durationMs: number; + }, + ] = [ + { + id: 'visual-novel-intent', + label: '理解一句话创意', + detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。', + weight: 16, + durationMs: 6_000, + }, + { + id: 'visual-novel-world', + label: '扩展世界观', + detail: '生成世界背景、故事前提、文学风格和玩家角色。', + weight: 22, + durationMs: 10_000, + }, + { + id: 'visual-novel-cast-scenes', + label: '设计角色与场景', + detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。', + weight: 28, + durationMs: 16_000, + }, + { + id: 'visual-novel-opening', + label: '生成开场与选择', + detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。', + weight: 24, + durationMs: 10_000, }, { id: 'visual-novel-ready', label: '准备草稿页', - detail: '校验可编辑字段并进入草稿页。', - weight: 20, - durationMs: 4_000, + detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。', + weight: 10, + durationMs: 3_000, }, ]; let elapsedBeforeStep = 0; @@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress( : phase === 'failed' ? Math.max(1, completedWeight) : Math.min(98, completedWeight + activeStep.weight * activeRatio); + const estimatedTotalMs = timeline.reduce( + (sum, step) => sum + step.durationMs, + 0, + ); return { - phaseId: phase, + phaseId: phase === 'generating' ? activeStep.id : phase, phaseLabel: phase === 'ready' ? '生成完成' @@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress( : activeStep.label, phaseDetail: phase === 'ready' - ? '视觉小说草稿已准备完成。' + ? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。' : phase === 'failed' ? '草稿生成失败,请返回入口页调整后重试。' : activeStep.detail, @@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress( totalWeight: 100, elapsedMs, estimatedRemainingMs: - phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs), + phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs), activeStepIndex: normalizedActiveStepIndex, steps: timeline.map((step, index) => { const isCompleted = From 4fecf9c9750dbc0c9897bad18604242d3bd692cc Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 15:04:37 +0800 Subject: [PATCH 02/14] fix(auth): tighten refresh session revocation --- .hermes/shared-memory/decision-log.md | 17 +- .../AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md | 66 ++- ...KEND_TRACKING_EVENT_COVERAGE_2026-05-09.md | 1 + ...RD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md | 5 +- docs/technical/README.md | 4 +- .../RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md | 19 +- ...G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md | 4 +- ...REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md | 41 +- packages/shared/src/contracts/auth.ts | 2 + server-rs/crates/api-server/src/ai_tasks.rs | 3 +- server-rs/crates/api-server/src/app.rs | 402 +++++++++++++++-- server-rs/crates/api-server/src/auth.rs | 28 ++ .../crates/api-server/src/auth_sessions.rs | 221 ++++++++-- .../src/creation_agent_document_input.rs | 7 +- server-rs/crates/api-server/src/llm.rs | 3 +- server-rs/crates/api-server/src/logout.rs | 1 + .../api-server/src/password_management.rs | 37 +- .../api-server/src/runtime_browse_history.rs | 5 +- .../api-server/src/runtime_inventory.rs | 5 +- .../crates/api-server/src/runtime_profile.rs | 3 +- .../crates/api-server/src/runtime_save.rs | 3 +- .../crates/api-server/src/runtime_settings.rs | 5 +- server-rs/crates/api-server/src/state.rs | 48 +++ .../crates/api-server/src/story_battles.rs | 3 +- .../crates/api-server/src/story_sessions.rs | 3 +- server-rs/crates/api-server/src/tracking.rs | 3 + .../crates/module-auth/src/application.rs | 6 + server-rs/crates/module-auth/src/commands.rs | 7 + server-rs/crates/module-auth/src/lib.rs | 406 +++++++++++++++++- server-rs/crates/shared-contracts/src/auth.rs | 7 + src/components/auth/AccountModal.test.tsx | 111 ++++- src/components/auth/AccountModal.tsx | 86 ++-- src/components/auth/AuthGate.test.tsx | 140 +++++- src/components/auth/AuthGate.tsx | 33 +- src/services/authService.test.ts | 88 ++++ src/services/authService.ts | 11 + 36 files changed, 1664 insertions(+), 170 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index e4985c17..42531e86 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-13 修改密码后全设备强制下线 + +- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。 +- 决策:`POST /api/auth/password/change` 成功后必须在同一认证真相更新中撤销该用户全部 active `refresh_session`,继续递增 `token_version`,响应清除当前 refresh cookie;前端 `changePassword` 成功后清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。 +- 影响范围:`module-auth` 修改密码用例、`api-server` password management route、`AuthGate`、`authService`、密码登录/重置技术文档。 +- 验证方式:执行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml password_change_allows_login_with_new_password_only -- --nocapture`、`npm run test -- AuthGate.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`、`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`。 + +## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线 + +- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。 +- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。 +- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。 +- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 + ## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格 - 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。 @@ -55,7 +71,6 @@ - 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。 - 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。 - 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。 - ## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 - 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 diff --git a/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md b/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md index 9e860fdf..fd437e06 100644 --- a/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md +++ b/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md @@ -10,6 +10,7 @@ 2. 当前设备识别方式与 `isCurrent` 语义 3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO 4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界 +5. `2026-05-13` 会话组合并展示与远端踢下线闭环修复口径 ## 2. 当前基线 @@ -46,11 +47,16 @@ 3. 登录创建 session 时落库结构化客户端身份字段 4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel` -本阶段明确不包含: +`2026-05-13` 起,本接口同时承担账号安全页的会话组读模型: -1. `/api/auth/sessions/:sessionId/revoke` -2. 前端完整消费全部新增字段 -3. SpacetimeDB reducer / view 正式读表 +1. 后端按“同设备 + 同 IP”聚合活跃 `refresh_session` +2. 前端只消费后端聚合结果,不自行推断合并 +3. `POST /api/auth/sessions/{sessionId}/revoke` 已纳入 Rust 实现,用于踢下线非当前会话 + +本阶段仍明确不包含: + +1. SpacetimeDB reducer / view 正式读表 +2. 登录方式、refresh token 轮换策略或账号安全页整体重设计 ## 5. 请求与响应 contract @@ -70,6 +76,8 @@ "sessions": [ { "sessionId": "usess_xxx", + "sessionIds": ["usess_xxx", "usess_yyy"], + "sessionCount": 2, "clientType": "web_browser", "clientRuntime": "chrome", "clientPlatform": "windows", @@ -90,9 +98,12 @@ 字段说明: -1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致 -2. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段 -3. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv` +1. `sessionId` 是聚合组代表会话 ID;若组内包含当前 `sid`,代表 ID 必须使用当前会话 ID +2. `sessionIds` 是该聚合组内全部活跃 session ID,前端批量踢下线时逐个调用 revoke +3. `sessionCount` 是聚合组内 session 数量 +4. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致 +5. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段 +6. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv` ### 5.3 失败响应 @@ -110,12 +121,25 @@ 1. 从 refresh cookie 读取当前原始 refresh token 2. 在 Axum 侧计算 `sha256(refresh_token)` 3. 与会话列表中的 `refresh_token_hash` 比较 -4. 命中则 `isCurrent = true` +4. 同时读取 Bearer access token claims 中的 `sid` +5. 聚合组内任意 session 命中当前 refresh hash 或当前 `sid`,则整组 `isCurrent = true` 说明: 1. 如果请求没有携带 refresh cookie,本接口仍可返回会话列表 -2. 此时全部会话的 `isCurrent` 都为 `false` +2. 此时仍可通过 Bearer `sid` 标记当前组 +3. 当前组不允许在前端显示“踢下线”,当前设备退出必须走 `/api/auth/logout` + +## 6.1 会话组合并规则 + +同设备同 IP 的 active refresh sessions 在后端合并为一条 DTO: + +1. 优先使用 `device_fingerprint + ip` 作为聚合 key +2. 无 `device_fingerprint` 时退化为 `client_type + client_runtime + client_platform + device_display_name + user_agent + ip` +3. `createdAt` 取组内最早 `created_at` +4. `lastSeenAt` 取组内最新 `last_seen_at` +5. `expiresAt` 取组内最新 `expires_at` +6. `ipMasked` 仍只返回脱敏 IP ## 7. 多端标识派生规则 @@ -161,8 +185,21 @@ 负责: 1. 读取 Bearer JWT 与 refresh cookie -2. 把活跃会话映射成旧接口兼容 DTO -3. 派生 `ipMasked` 与 `isCurrent` +2. 按同设备同 IP 聚合活跃会话 +3. 把活跃会话组映射成旧接口兼容 DTO +4. 派生 `ipMasked` 与 `isCurrent` +5. 暴露 `POST /api/auth/sessions/{sessionId}/revoke` + +## 8.3 指定会话吊销接口 + +`POST /api/auth/sessions/{sessionId}/revoke` 固定规则: + +1. Bearer JWT 必填 +2. 仅允许吊销当前用户自己的非当前会话 +3. 当前会话自吊销返回业务错误,提示使用退出登录 +4. 只撤销目标 `refresh_session`,不递增 `token_version` +5. 撤销后同步 auth store 到 SpacetimeDB +6. 认证中间件会校验 access token `sid` 对应 active `refresh_session`,因此被踢设备已有 access token 会立即失效 ## 9. 测试策略 @@ -172,6 +209,9 @@ 2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser` 3. 显式小程序头优先于 `User-Agent` 判断 4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true` +5. 同设备同 IP 会话会合并,并返回 `sessionIds/sessionCount` +6. 合并组包含当前 `sid` 或当前 refresh hash 时,整组 `isCurrent = true` +7. 指定远端会话吊销后,被踢设备 access token 立即无法通过认证 ## 10. 完成定义 @@ -181,4 +221,6 @@ 2. 会话列表可区分普通浏览器、微信内 H5、小程序来源 3. 同设备不同浏览器可在会话列表中清晰区分 4. `clientLabel` 与新增多端字段都已稳定返回 -5. 文档、任务清单与测试已同步更新 +5. 同设备同 IP 的重复 active refresh sessions 已合并展示 +6. 非当前会话可通过真实 revoke 接口踢下线 +7. 文档、任务清单与测试已同步更新 diff --git a/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md b/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md index 14cb7ec2..2a8fd4ed 100644 --- a/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md +++ b/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md @@ -94,6 +94,7 @@ API Server 新增统一 helper: | `auth_phone_login_success` | `POST /api/auth/phone/login` | | `auth_me_view` | `GET /api/auth/me` | | `auth_sessions_view` | `GET /api/auth/sessions` | +| `auth_revoke_session` | `POST /api/auth/sessions/{session_id}/revoke` | | `auth_refresh_success` | `POST /api/auth/refresh` | | `auth_logout` | `POST /api/auth/logout` | | `auth_logout_all` | `POST /api/auth/logout-all` | diff --git a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md index 72833e8a..226ba63d 100644 --- a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md +++ b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md @@ -32,7 +32,8 @@ 2. 请求字段:`currentPassword`、`newPassword`。 3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。 4. 若账号已有密码,必须校验 `currentPassword` 后才能写入 `newPassword`。 -5. 修改成功后递增用户 `token_version`,使旧 access token 失效;前端沿用当前 refresh 会话刷新登录态。 +5. 修改成功后递增用户 `token_version`,使旧 access token 失效。 +6. `2026-05-13` 起,修改密码成功后必须撤销该用户全部 active `refresh_session`,并在响应中清除当前 refresh cookie;前端清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。 ### 2.3 重置密码 @@ -79,7 +80,7 @@ ## 5. 2026-05-12 快照同步修复 -重置密码和修改密码都会改变认证真相:`password_hash`、`password_login_enabled`、`token_version`,重置密码还会立即创建新的 refresh session。因此 API 层在 `POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`。 +重置密码和修改密码都会改变认证真相:`password_hash`、`password_login_enabled`、`token_version`,重置密码还会立即创建新的 refresh session,修改密码还会撤销全部旧 refresh session。因此 API 层在 `POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`。 若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB,避免旧远端状态覆盖刚重设的密码。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 39f070fb..3ac46d88 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -175,7 +175,7 @@ - [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md):`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。 - [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。 - [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md):`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。 -- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。 +- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、同设备同 IP 会话组合并、`clientLabel` 兼容策略与 Rust 接口边界。 - [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。 - [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429` 与 `Retry-After` contract。 - [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。 @@ -207,7 +207,7 @@ - [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md):`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。 - [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md):`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。 - [SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md):`M2` 第四张鉴权审计表 `auth_audit_log` 的事件范围、追加写规则、索引与对外 DTO 派生约束。 -- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换与吊销语义、索引与迁移规则。 +- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换、logout fallback、指定会话吊销语义、索引与迁移规则。 - [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md):`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。 - [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。 - [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于旧 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 diff --git a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md index 4a8df71a..7e3a8322 100644 --- a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md +++ b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md @@ -1,6 +1,6 @@ # Rust API Server 路由索引(2026-04-23) -更新时间:`2026-05-01` +更新时间:`2026-05-13` > 2026-04-29 补充:本文件保留为迁移期路由快照。DDD G1 后续并行工作的契约冻结口径以 [`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准,尤其是新增的 Big Fish、Puzzle、profile、runtime chat、story facade 和兼容路由删除计划。 > @@ -20,7 +20,7 @@ 2. 内部鉴权调试接口:`2` 条。 3. AI task 接口:`9` 条。 4. assets / OSS 接口:`15` 条。 -5. auth 接口:`12` 条。 +5. auth 接口:`13` 条。 6. custom world / agent 接口:`23` 条。 7. match3d creation / runtime 接口:`14` 条。 8. llm proxy 接口:`1` 条。 @@ -84,13 +84,14 @@ 3. `POST /api/auth/logout` 4. `POST /api/auth/logout-all` 5. `GET /api/auth/sessions` -6. `POST /api/auth/refresh` -7. `POST /api/auth/phone/send-code` -8. `POST /api/auth/phone/login` -9. `GET /api/auth/wechat/start` -10. `GET /api/auth/wechat/callback` -11. `POST /api/auth/wechat/bind-phone` -12. `POST /api/auth/entry` +6. `POST /api/auth/sessions/{session_id}/revoke` +7. `POST /api/auth/refresh` +8. `POST /api/auth/phone/send-code` +9. `POST /api/auth/phone/login` +10. `GET /api/auth/wechat/start` +11. `GET /api/auth/wechat/callback` +12. `POST /api/auth/wechat/bind-phone` +13. `POST /api/auth/entry` ### 3.6 Custom World / Agent diff --git a/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md index 652f92aa..3bd7d917 100644 --- a/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md +++ b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md @@ -31,7 +31,7 @@ G1 单 owner 文件范围: | 管理兑换码 | `POST /admin/api/profile/redeem-codes`、`POST /admin/api/profile/redeem-codes/disable` | 收敛 | 继续走 admin 路由,DTO 归入 profile/runtime 管理命令组 | WP-RT、WP-API | | 内部鉴权调试 | `GET /_internal/auth/claims`、`GET /_internal/auth/refresh-cookie` | 删除 | 只允许本地诊断脚本或 admin debug 能力使用,不作为前端契约 | WP-DEL | | 鉴权公开查询 | `GET /api/auth/login-options`、`GET /api/auth/public-users/by-code/{code}`、`GET /api/auth/public-users/by-id/{user_id}` | 保留 | `AuthLoginOptionsResponse`、`PublicUserSearchResponse` | WP-A | -| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A | +| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/sessions/{session_id}/revoke`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RevokeAuthSessionResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A | | 鉴权登录 | `POST /api/auth/phone/send-code`、`POST /api/auth/phone/login`、`GET /api/auth/wechat/start`、`GET /api/auth/wechat/callback`、`POST /api/auth/wechat/bind-phone`、`POST /api/auth/entry`、`POST /api/auth/password/change`、`POST /api/auth/password/reset` | 保留 | TS 命名统一使用 `Auth*` 前缀,Rust 命名维持领域语义 | WP-A | | 旧本地生成资产代理 | `GET /generated-character-drafts/{*path}`、`/generated-characters/{*path}`、`/generated-animations/{*path}`、`/generated-big-fish-assets/{*path}`、`/generated-puzzle-assets/{*path}`、`/generated-custom-world-scenes/{*path}`、`/generated-custom-world-covers/{*path}`、`/generated-qwen-sprites/{*path}` | 已删除 | 正式读取统一走 `GET /api/assets/read-url` 或 asset object projection;`/generated-*` 仅允许作为 legacyPublicPath/object key 标识,不再作为可裸读路由 | WP-AS、WP-FE、WP-DEL | | LLM 代理 | `POST /api/llm/chat/completions` | 收敛 | 仅作为平台能力代理;玩法 prompt 不允许由前端直接传入 | WP-PF、WP-API | @@ -59,7 +59,7 @@ G1 单 owner 文件范围: | --- | --- | | `shared-contracts/src/api.rs` | `ApiResponseMeta`、`ApiErrorPayload`、`ApiSuccessEnvelope`、`ApiErrorEnvelope` | | `shared-contracts/src/admin.rs` | `AdminLoginRequest/Response`、`AdminSessionPayload`、`AdminMeResponse`、`AdminOverviewResponse`、`AdminDebugHttpRequest/Response` | -| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` | +| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RevokeAuthSessionResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` | | `shared-contracts/src/ai.rs` | `CreateAiTaskRequest`、`AppendAiTextChunkRequest`、`CompleteAiStageRequest`、`AttachAiResultReferenceRequest`、`FailAiTaskRequest`、`AiTask*Payload`、`AiTaskMutationResponse`、`AiTaskAcceptedResponse` | | `shared-contracts/src/assets.rs` | Direct upload、read url、asset object、asset binding、asset history、character visual/animation、workflow cache、role asset workflow 相关 DTO | | `shared-contracts/src/creation_agent_document_input.rs` | `ParseCreationAgentDocumentInputRequest/Response`、`CreationAgentDocumentInputPayload` | diff --git a/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md index a65a9465..bcce634e 100644 --- a/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md @@ -115,6 +115,8 @@ 1. 从 cookie 读出原始 refresh token 2. 计算 hash 3. 与 `refresh_session.refresh_token_hash` 比较 +4. 若 refresh cookie 缺失或不可用,再使用 Bearer access token claims 中的 `sid` 与 `refresh_session.session_id` 比较 +5. 会话列表按“同设备 + 同 IP”聚合时,组内任一 session 命中当前 hash 或当前 `sid`,整组都视为当前设备组 ## 5. 表访问级别 @@ -228,9 +230,10 @@ 写入规则: 1. 按当前 cookie 找 session -2. 写 `revoked_at = now` -3. 写 `revoked_reason_code = logout` -4. 同时提升 `user_account.token_version` +2. 如果 refresh cookie 缺失,则回退用 Bearer access token claims 中的 `sid` 找当前 session +3. 写 `revoked_at = now` +4. 写 `revoked_reason_code = logout` +5. 同时提升 `user_account.token_version` ### 8.4 吊销全部会话 @@ -248,7 +251,7 @@ 触发点: -1. `POST /api/auth/sessions/:sessionId/revoke` +1. `POST /api/auth/sessions/{sessionId}/revoke` 写入规则: @@ -257,6 +260,13 @@ 3. 只改目标 `refresh_session` 4. `revoked_reason_code = session_revoke` 5. 不提升 `token_version` +6. 撤销后必须同步 auth store 到 SpacetimeDB + +读取约束: + +1. Bearer JWT 中的 `sid` 必须对应 active `refresh_session` +2. 被该接口撤销的设备即使 access token 未过期,后续请求也必须立刻返回未授权 +3. 该接口不承担当前设备退出语义;当前设备退出固定走 `/api/auth/logout` ### 8.6 账号被禁用或并入 @@ -315,13 +325,18 @@ 1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。 2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。 +3. 同设备同 IP 的 active sessions 由 Axum 聚合后返回一条记录。 +4. `sessionId` 是代表 ID;当前组代表 ID 使用当前 `sid` 对应 session。 +5. `sessionIds` 返回组内全部 active session ID,`sessionCount` 返回组内数量。 +6. 聚合组时间语义:`createdAt` 取最早创建时间,`lastSeenAt` 与 `expiresAt` 取最新值。 ### 10.3 `POST /api/auth/logout` 依赖: 1. 当前 cookie 命中的 `refresh_session` -2. `user_account.token_version` +2. cookie 缺失时 Bearer `sid` 命中的 `refresh_session` +3. `user_account.token_version` ### 10.4 `POST /api/auth/logout-all` @@ -330,6 +345,22 @@ 1. 当前 `user_id` 下全部活跃 `refresh_session` 2. `user_account.token_version` +### 10.5 `POST /api/auth/sessions/{sessionId}/revoke` + +依赖: + +1. 当前 Bearer JWT 的 `user_id` +2. 当前 Bearer JWT 的 `sid` +3. 目标 `refresh_session.session_id` +4. `refresh_session.revoked_at` +5. `refresh_session.expires_at` + +固定行为: + +1. 目标 session 必须属于当前用户 +2. 目标 session 不能是当前 `sid` +3. 成功只撤销目标 session,不递增 `token_version` + ## 11. 与当前 Node `user_sessions` 的映射关系 | Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 | diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 2fffab28..a6c38a51 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -150,6 +150,8 @@ export type AuthRefreshResponse = { export type AuthSessionSummary = { sessionId: string; + sessionIds: string[]; + sessionCount: number; clientType: string; clientRuntime: string; clientPlatform: string; diff --git a/server-rs/crates/api-server/src/ai_tasks.rs b/server-rs/crates/api-server/src/ai_tasks.rs index d90c0be7..42a446df 100644 --- a/server-rs/crates/api-server/src/ai_tasks.rs +++ b/server-rs/crates/api-server/src/ai_tasks.rs @@ -776,7 +776,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_ai_tasks".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6df6090f..15052774 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -33,7 +33,7 @@ use crate::{ }, auth_me::auth_me, auth_public_user::{get_public_user_by_code, get_public_user_by_id}, - auth_sessions::auth_sessions, + auth_sessions::{auth_sessions, revoke_auth_session}, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, get_big_fish_session, get_big_fish_works, list_big_fish_gallery, @@ -331,6 +331,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/auth/sessions/{session_id}/revoke", + post(revoke_auth_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/me", axum::routing::patch(update_profile_identity).route_layer( @@ -1921,10 +1928,12 @@ mod tests { user: &module_auth::AuthUser, session_id: &str, ) -> String { + let now = OffsetDateTime::now_utc(); + let active_session_id = state.seed_test_refresh_session_for_user(user, session_id); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user.id.clone(), - session_id: session_id.to_string(), + session_id: active_session_id, provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, @@ -1933,13 +1942,22 @@ mod tests { display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), - OffsetDateTime::now_utc(), + now, ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + fn read_access_token(response_body: &[u8]) -> String { + let payload: Value = + serde_json::from_slice(response_body).expect("login payload should be json"); + payload["token"] + .as_str() + .expect("access token should exist") + .to_string() + } + async fn password_login_request( app: Router, phone_number: &str, @@ -1963,6 +1981,37 @@ mod tests { .expect("password login request should succeed") } + async fn password_login_request_with_client( + app: Router, + phone_number: &str, + password: &str, + client_instance_id: &str, + forwarded_for: &str, + ) -> axum::response::Response { + app.oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("x-client-instance-id", client_instance_id) + .header("x-forwarded-for", forwarded_for) + .body(Body::from( + serde_json::json!({ + "phone": phone_number, + "password": password + }) + .to_string(), + )) + .expect("password login request should build"), + ) + .await + .expect("password login request should succeed") + } + fn build_internal_creative_agent_app() -> Router { let mut config = AppConfig::default(); config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string()); @@ -2536,10 +2585,11 @@ mod tests { let config = AppConfig::default(); let state = AppState::new(config.clone()).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await; + let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: seed_user.id.clone(), - session_id: "sess_auth_debug".to_string(), + session_id: session_id.clone(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: seed_user.token_version, @@ -2577,10 +2627,7 @@ mod tests { serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id)); - assert_eq!( - payload["claims"]["sid"], - Value::String("sess_auth_debug".to_string()) - ); + assert_eq!(payload["claims"]["sid"], Value::String(session_id)); assert_eq!( payload["claims"]["ver"], Value::Number(serde_json::Number::from(seed_user.token_version)) @@ -4238,12 +4285,17 @@ mod tests { session["clientType"] == Value::String("web_browser".to_string()) && session["clientRuntime"] == Value::String("chrome".to_string()) && session["clientPlatform"] == Value::String("windows".to_string()) + && session["sessionCount"] == Value::Number(1.into()) + && session["sessionIds"] + .as_array() + .is_some_and(|ids| ids.len() == 1) && session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string()) && session["isCurrent"] == Value::Bool(true) })); assert!(sessions.iter().any(|session| { session["clientType"] == Value::String("mini_program".to_string()) && session["clientRuntime"] == Value::String("wechat_mini_program".to_string()) + && session["sessionCount"] == Value::Number(1.into()) && session["miniProgramAppId"] == Value::String("wx-session-test".to_string()) && session["miniProgramEnv"] == Value::String("release".to_string()) && session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string()) @@ -4251,6 +4303,108 @@ mod tests { })); } + #[tokio::test] + async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; + let app = build_router(state); + let login_body = serde_json::json!({ + "phone": "13800138028", + "password": TEST_PASSWORD + }) + .to_string(); + + let first_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("x-client-instance-id", "same-device") + .header("x-forwarded-for", "203.0.113.10") + .body(Body::from(login_body.clone())) + .expect("first login request should build"), + ) + .await + .expect("first login should succeed"); + let first_cookie = first_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("first cookie should exist") + .to_string(); + let first_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let access_token = read_access_token(&first_body); + + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("x-client-instance-id", "same-device") + .header("x-forwarded-for", "203.0.113.10") + .body(Body::from(login_body)) + .expect("second login request should build"), + ) + .await + .expect("second login should succeed"); + + let sessions_response = app + .oneshot( + Request::builder() + .uri("/api/auth/sessions") + .header("authorization", format!("Bearer {access_token}")) + .header("cookie", first_cookie) + .body(Body::empty()) + .expect("sessions request should build"), + ) + .await + .expect("sessions request should succeed"); + + assert_eq!(sessions_response.status(), StatusCode::OK); + let sessions_body = sessions_response + .into_body() + .collect() + .await + .expect("sessions body should collect") + .to_bytes(); + let sessions_payload: Value = + serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); + let sessions = sessions_payload["sessions"] + .as_array() + .expect("sessions should be array"); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into())); + assert_eq!(sessions[0]["isCurrent"], Value::Bool(true)); + assert_eq!( + sessions[0]["ipMasked"], + Value::String("203.0.*.*".to_string()) + ); + assert_eq!( + sessions[0]["sessionIds"] + .as_array() + .expect("session ids should exist") + .len(), + 2 + ); + } + #[tokio::test] async fn password_entry_reuses_same_user_for_same_phone() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -4362,9 +4516,23 @@ mod tests { #[tokio::test] async fn password_change_allows_login_with_new_password_only() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; - let token = sign_test_user_token(&state, &seed_user, "sess_password_change"); + seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; let app = build_router(state); + let login_response = + password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await; + let refresh_cookie = login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("refresh cookie should exist") + .to_string(); + let login_body = login_response + .into_body() + .collect() + .await + .expect("login body should collect") + .to_bytes(); + let token = read_access_token(&login_body); let change_response = app .clone() @@ -4386,6 +4554,40 @@ mod tests { .await .expect("change password request should succeed"); assert_eq!(change_response.status(), StatusCode::OK); + assert!( + change_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("Max-Age=0")) + ); + + let old_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .expect("me request should build"), + ) + .await + .expect("me request should succeed"); + assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED); + + let old_refresh_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", refresh_cookie) + .body(Body::empty()) + .expect("refresh request should build"), + ) + .await + .expect("refresh request should succeed"); + assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED); let old_password_response = password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await; @@ -4429,23 +4631,16 @@ mod tests { }; let state = AppState::new(config).expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await; - let claims = AccessTokenClaims::from_input( - AccessTokenClaimsInput { - user_id: seed_user.id.clone(), - session_id: "sess_me_query".to_string(), - provider: AuthProvider::Password, - roles: vec!["user".to_string()], - token_version: seed_user.token_version, - phone_verified: false, - binding_status: BindingStatus::Active, - display_name: Some(seed_user.display_name.clone()), - }, - state.auth_jwt_config(), - OffsetDateTime::now_utc(), - ) - .expect("claims should build"); - let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); let app = build_router(state); + let login_response = + password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await; + let login_body = login_response + .into_body() + .collect() + .await + .expect("login body should collect") + .to_bytes(); + let token = read_access_token(&login_body); let response = app .oneshot( @@ -4606,6 +4801,141 @@ mod tests { ); } + #[tokio::test] + async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await; + let app = build_router(state); + + let first_login_response = password_login_request_with_client( + app.clone(), + "13800138030", + TEST_PASSWORD, + "revoke-current-device", + "203.0.113.30", + ) + .await; + let first_cookie = first_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("first cookie should exist") + .to_string(); + let first_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let first_access_token = read_access_token(&first_body); + + let second_login_response = password_login_request_with_client( + app.clone(), + "13800138030", + TEST_PASSWORD, + "revoke-remote-device", + "203.0.113.31", + ) + .await; + let second_cookie = second_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("second cookie should exist") + .to_string(); + let second_body = second_login_response + .into_body() + .collect() + .await + .expect("second login body should collect") + .to_bytes(); + let second_access_token = read_access_token(&second_body); + + let remote_sessions_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/sessions") + .header("authorization", format!("Bearer {first_access_token}")) + .header("cookie", first_cookie.clone()) + .body(Body::empty()) + .expect("sessions request should build"), + ) + .await + .expect("sessions request should succeed"); + assert_eq!(remote_sessions_response.status(), StatusCode::OK); + let remote_sessions_body = remote_sessions_response + .into_body() + .collect() + .await + .expect("sessions body should collect") + .to_bytes(); + let remote_sessions_payload: Value = + serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json"); + let remote_session_id = remote_sessions_payload["sessions"] + .as_array() + .expect("sessions should be array") + .iter() + .find(|session| session["isCurrent"] == Value::Bool(false)) + .and_then(|session| session["sessionId"].as_str()) + .expect("remote session id should exist") + .to_string(); + + let revoke_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/auth/sessions/{remote_session_id}/revoke")) + .header("authorization", format!("Bearer {first_access_token}")) + .header("cookie", first_cookie) + .body(Body::empty()) + .expect("revoke request should build"), + ) + .await + .expect("revoke request should succeed"); + assert_eq!(revoke_response.status(), StatusCode::OK); + + let current_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {first_access_token}")) + .body(Body::empty()) + .expect("current me request should build"), + ) + .await + .expect("current me request should succeed"); + assert_eq!(current_me_response.status(), StatusCode::OK); + + let remote_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {second_access_token}")) + .body(Body::empty()) + .expect("remote me request should build"), + ) + .await + .expect("remote me request should succeed"); + assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED); + + let remote_refresh_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", second_cookie) + .body(Body::empty()) + .expect("remote refresh request should build"), + ) + .await + .expect("remote refresh request should succeed"); + assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn logout_clears_cookie_and_invalidates_current_access_token() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -4688,6 +5018,12 @@ mod tests { let login_response = password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await; + let refresh_cookie = login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("refresh cookie should exist") + .to_string(); let login_body = login_response .into_body() .collect() @@ -4702,6 +5038,7 @@ mod tests { .to_string(); let logout_response = app + .clone() .oneshot( Request::builder() .method("POST") @@ -4721,6 +5058,19 @@ mod tests { .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("Max-Age=0")) ); + + let refresh_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", refresh_cookie) + .body(Body::empty()) + .expect("refresh request should build"), + ) + .await + .expect("refresh request should succeed"); + assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 78c2e815..c6a9e789 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -117,6 +117,34 @@ pub async fn require_bearer_auth( .with_message("当前登录态已失效,请重新登录")); } + let session_is_active = state + .refresh_session_service() + .is_session_active_for_user( + claims.user_id(), + claims.session_id(), + OffsetDateTime::now_utc(), + ) + .map_err(|error| { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + error = %error, + "Bearer JWT refresh session 状态读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + if !session_is_active { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + "Bearer JWT 对应 refresh session 已失效" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录")); + } + request .extensions_mut() .insert(AuthenticatedAccessToken::new(claims.clone())); diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index b9c0b716..de9c70e9 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -1,10 +1,15 @@ +use std::collections::HashMap; + use axum::{ Json, - extract::{Extension, State}, + extract::{Extension, Path, State}, http::StatusCode, }; +use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput}; use platform_auth::hash_refresh_session_token; -use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse}; +use shared_contracts::auth::{ + AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse, +}; use time::OffsetDateTime; use crate::{ @@ -37,41 +42,189 @@ pub async fn auth_sessions( .refresh_session_service() .list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc()) .map_err(map_refresh_session_list_error)?; + let current_session_id = authenticated.claims().session_id().to_string(); + let session_groups = group_sessions_by_device_and_ip(sessions.sessions); Ok(json_success_body( Some(&request_context), AuthSessionsResponse { - sessions: sessions - .sessions + sessions: session_groups .into_iter() - .map(|session| { - let is_current = current_refresh_token_hash - .as_ref() - .is_some_and(|hash| session.refresh_token_hash == *hash); - let client_label = session.client_info.device_display_name.clone(); - - AuthSessionSummaryPayload { - session_id: session.session_id, - client_type: session.client_info.client_type, - client_runtime: session.client_info.client_runtime, - client_platform: session.client_info.client_platform, - client_label, - device_display_name: session.client_info.device_display_name, - mini_program_app_id: session.client_info.mini_program_app_id, - mini_program_env: session.client_info.mini_program_env, - user_agent: session.client_info.user_agent, - ip_masked: mask_ip(session.client_info.ip.as_deref()), - is_current, - created_at: session.created_at, - last_seen_at: session.last_seen_at, - expires_at: session.expires_at, - } + .map(|group| { + build_session_summary( + group, + current_refresh_token_hash.as_deref(), + ¤t_session_id, + ) }) .collect(), }, )) } +pub async fn revoke_auth_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(session_id): Path, +) -> Result, AppError> { + let session_id = session_id.trim().to_string(); + if session_id.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少会话 ID")); + } + if session_id == authenticated.claims().session_id() { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_message("当前设备请使用退出登录") + ); + } + + let revoke_result = state + .refresh_session_service() + .revoke_session_by_user_and_session( + RevokeRefreshSessionByUserInput { + user_id: authenticated.claims().user_id().to_string(), + session_id, + }, + OffsetDateTime::now_utc(), + ) + .map_err(map_refresh_session_revoke_error)?; + if !revoke_result.revoked { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("会话不存在或已失效") + ); + } + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; + + Ok(json_success_body( + Some(&request_context), + RevokeAuthSessionResponse { ok: true }, + )) +} + +fn group_sessions_by_device_and_ip( + sessions: Vec, +) -> Vec> { + let mut grouped = HashMap::>::new(); + for session in sessions { + grouped + .entry(build_session_group_key(&session)) + .or_default() + .push(session); + } + + let mut groups = grouped.into_values().collect::>(); + for group in &mut groups { + group.sort_by(|left, right| { + right + .last_seen_at + .cmp(&left.last_seen_at) + .then_with(|| right.created_at.cmp(&left.created_at)) + }); + } + groups.sort_by(|left, right| { + group_latest_last_seen(right) + .cmp(group_latest_last_seen(left)) + .then_with(|| group_earliest_created(left).cmp(group_earliest_created(right))) + }); + + groups +} + +fn build_session_group_key(session: &RefreshSessionRecord) -> String { + let client_info = &session.client_info; + let device_key = client_info.device_fingerprint.as_deref().unwrap_or(""); + if !device_key.is_empty() { + return format!("{}|{}", device_key, client_info.ip.as_deref().unwrap_or("")); + } + + format!( + "{}|{}|{}|{}|{}|{}", + client_info.client_type, + client_info.client_runtime, + client_info.client_platform, + client_info.device_display_name, + client_info.user_agent.as_deref().unwrap_or(""), + client_info.ip.as_deref().unwrap_or("") + ) +} + +fn build_session_summary( + group: Vec, + current_refresh_token_hash: Option<&str>, + current_session_id: &str, +) -> AuthSessionSummaryPayload { + let is_current = group.iter().any(|session| { + session.session_id == current_session_id + || current_refresh_token_hash.is_some_and(|hash| session.refresh_token_hash == hash) + }); + let representative = group + .iter() + .find(|session| is_current && session.session_id == current_session_id) + .or_else(|| { + group.iter().find(|session| { + is_current + && current_refresh_token_hash + .is_some_and(|hash| session.refresh_token_hash == hash) + }) + }) + .unwrap_or_else(|| group.first().expect("session group should not be empty")); + let client_label = representative.client_info.device_display_name.clone(); + let session_ids = group + .iter() + .map(|session| session.session_id.clone()) + .collect::>(); + let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX); + + AuthSessionSummaryPayload { + session_id: representative.session_id.clone(), + session_ids, + session_count, + client_type: representative.client_info.client_type.clone(), + client_runtime: representative.client_info.client_runtime.clone(), + client_platform: representative.client_info.client_platform.clone(), + client_label, + device_display_name: representative.client_info.device_display_name.clone(), + mini_program_app_id: representative.client_info.mini_program_app_id.clone(), + mini_program_env: representative.client_info.mini_program_env.clone(), + user_agent: representative.client_info.user_agent.clone(), + ip_masked: mask_ip(representative.client_info.ip.as_deref()), + is_current, + created_at: group_earliest_created(&group).to_string(), + last_seen_at: group_latest_last_seen(&group).to_string(), + expires_at: group_latest_expires_at(&group).to_string(), + } +} + +fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str { + group + .iter() + .map(|session| session.last_seen_at.as_str()) + .max() + .unwrap_or("") +} + +fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str { + group + .iter() + .map(|session| session.created_at.as_str()) + .min() + .unwrap_or("") +} + +fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str { + group + .iter() + .map(|session| session.expires_at.as_str()) + .max() + .unwrap_or("") +} + fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError { match error { module_auth::RefreshSessionError::UserNotFound => { @@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap } } } + +fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError { + match error { + module_auth::RefreshSessionError::MissingToken + | module_auth::RefreshSessionError::SessionNotFound => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + module_auth::RefreshSessionError::SessionExpired + | module_auth::RefreshSessionError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) + } + module_auth::RefreshSessionError::Store(message) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message) + } + } +} diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs index 43ccc5d9..46bf0976 100644 --- a/server-rs/crates/api-server/src/creation_agent_document_input.rs +++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs @@ -375,14 +375,15 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: user.id, - session_id: "sess_creation_doc_input".to_string(), + user_id: user.id.clone(), + session_id: state + .seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, phone_verified: true, binding_status: BindingStatus::Active, - display_name: Some(user.display_name), + display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index c4944c6b..072c7fad 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -333,7 +333,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_llm_proxy".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index 98866782..2e4a4f05 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -40,6 +40,7 @@ pub async fn logout( LogoutCurrentSessionInput { user_id: authenticated.claims().user_id().to_string(), refresh_token_hash, + session_id: Some(authenticated.claims().session_id().to_string()), }, OffsetDateTime::now_utc(), ) diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 635a517d..9d305c68 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -15,7 +15,8 @@ use crate::{ auth::AuthenticatedAccessToken, auth_payload::map_auth_user_payload, auth_session::{ - attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, + attach_set_cookie_header, build_clear_refresh_session_cookie_header, + build_refresh_session_cookie_header, create_auth_session, record_daily_login_tracking_event_after_auth_success, }, http_error::AppError, @@ -30,14 +31,17 @@ pub async fn change_password( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, -) -> Result, AppError> { +) -> Result { let result = state .password_entry_service() - .change_password(ChangePasswordInput { - user_id: authenticated.claims().user_id().to_string(), - current_password: payload.current_password, - new_password: payload.new_password, - }) + .change_password_and_revoke_all_sessions( + ChangePasswordInput { + user_id: authenticated.claims().user_id().to_string(), + current_password: payload.current_password, + new_password: payload.new_password, + }, + OffsetDateTime::now_utc(), + ) .await .map_err(map_password_management_error)?; state @@ -48,11 +52,20 @@ pub async fn change_password( .with_message(format!("同步认证快照失败:{error}")) })?; - Ok(json_success_body( - Some(&request_context), - PasswordChangeResponse { - user: map_auth_user_payload(result.user), - }, + let mut headers = HeaderMap::new(); + attach_set_cookie_header( + &mut headers, + build_clear_refresh_session_cookie_header(&state)?, + ); + + Ok(( + headers, + json_success_body( + Some(&request_context), + PasswordChangeResponse { + user: map_auth_user_payload(result.user), + }, + ), )) } diff --git a/server-rs/crates/api-server/src/runtime_browse_history.rs b/server-rs/crates/api-server/src/runtime_browse_history.rs index 1bf5dc0b..7981ad82 100644 --- a/server-rs/crates/api-server/src/runtime_browse_history.rs +++ b/server-rs/crates/api-server/src/runtime_browse_history.rs @@ -374,7 +374,10 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_browse_history".to_string(), + session_id: state.seed_test_refresh_session_for_user_id( + "user_00000001", + "sess_runtime_browse_history", + ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_inventory.rs b/server-rs/crates/api-server/src/runtime_inventory.rs index 456ed427..53c0e29d 100644 --- a/server-rs/crates/api-server/src/runtime_inventory.rs +++ b/server-rs/crates/api-server/src/runtime_inventory.rs @@ -174,7 +174,10 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_inventory".to_string(), + session_id: state.seed_test_refresh_session_for_user_id( + "user_00000001", + "sess_runtime_inventory", + ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8c1434de..8d0afcd9 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1568,7 +1568,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_profile".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 85a50621..3b02de8f 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -575,7 +575,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_save".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_settings.rs b/server-rs/crates/api-server/src/runtime_settings.rs index bb9337b3..8535f692 100644 --- a/server-rs/crates/api-server/src/runtime_settings.rs +++ b/server-rs/crates/api-server/src/runtime_settings.rs @@ -350,7 +350,10 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_settings".to_string(), + session_id: state.seed_test_refresh_session_for_user_id( + "user_00000001", + "sess_runtime_settings", + ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 60ada394..8b5079a3 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -600,6 +600,54 @@ impl AppState { #[cfg(test)] impl AppState { + pub(crate) fn seed_test_refresh_session_for_user( + &self, + user: &module_auth::AuthUser, + seed: &str, + ) -> String { + let session = self + .refresh_session_service() + .create_session( + module_auth::CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: platform_auth::hash_refresh_session_token(&format!( + "test-refresh-token-{seed}" + )), + issued_by_provider: module_auth::AuthLoginMethod::Password, + client_info: module_auth::RefreshSessionClientInfo { + client_type: "web_browser".to_string(), + client_runtime: "test".to_string(), + client_platform: "test".to_string(), + client_instance_id: Some(seed.to_string()), + device_fingerprint: Some(format!("test-device-{seed}")), + device_display_name: "Test Browser".to_string(), + mini_program_app_id: None, + mini_program_env: None, + user_agent: Some("GenarrativeApiServerTest/1.0".to_string()), + ip: Some("127.0.0.1".to_string()), + }, + }, + OffsetDateTime::now_utc(), + ) + .expect("test refresh session should create"); + + session.session.session_id + } + + pub(crate) fn seed_test_refresh_session_for_user_id( + &self, + user_id: &str, + seed: &str, + ) -> String { + let user = self + .auth_user_service() + .get_user_by_id(user_id) + .expect("test user lookup should succeed") + .expect("test user should exist"); + + self.seed_test_refresh_session_for_user(&user, seed) + } + fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) { *self .test_creation_entry_config diff --git a/server-rs/crates/api-server/src/story_battles.rs b/server-rs/crates/api-server/src/story_battles.rs index 35ea4c8e..2ac27318 100644 --- a/server-rs/crates/api-server/src/story_battles.rs +++ b/server-rs/crates/api-server/src/story_battles.rs @@ -959,7 +959,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_story_battles".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_story_battles"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/story_sessions.rs b/server-rs/crates/api-server/src/story_sessions.rs index 3caeb625..0188301d 100644 --- a/server-rs/crates/api-server/src/story_sessions.rs +++ b/server-rs/crates/api-server/src/story_sessions.rs @@ -1132,7 +1132,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_story_sessions".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_story_sessions"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 65bc6888..0f3aad21 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -121,6 +121,9 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { Some(route_spec("auth_sessions_view", "auth", User, "anonymous")) } + ("POST", "/api/auth/sessions/{id}/revoke") => { + Some(route_spec("auth_revoke_session", "auth", User, "anonymous")) + } ("POST", "/api/auth/refresh") => { Some(route_spec("auth_refresh_success", "auth", Site, "site")) } diff --git a/server-rs/crates/module-auth/src/application.rs b/server-rs/crates/module-auth/src/application.rs index 032fb9c6..132b9995 100644 --- a/server-rs/crates/module-auth/src/application.rs +++ b/server-rs/crates/module-auth/src/application.rs @@ -100,6 +100,12 @@ pub struct ListActiveRefreshSessionsResult { pub sessions: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RevokeRefreshSessionResult { + pub session_id: String, + pub revoked: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutCurrentSessionResult { pub user: AuthUser, diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index da48cffb..d84ce3cf 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -87,10 +87,17 @@ pub struct RotateRefreshSessionInput { pub next_refresh_token_hash: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RevokeRefreshSessionByUserInput { + pub user_id: String, + pub session_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutCurrentSessionInput { pub user_id: String, pub refresh_token_hash: Option, + pub session_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 6b1ac1e4..a855ab96 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -230,6 +230,22 @@ impl PasswordEntryService { pub async fn change_password( &self, input: ChangePasswordInput, + ) -> Result { + self.change_password_internal(input, None).await + } + + pub async fn change_password_and_revoke_all_sessions( + &self, + input: ChangePasswordInput, + now: OffsetDateTime, + ) -> Result { + self.change_password_internal(input, Some(now)).await + } + + async fn change_password_internal( + &self, + input: ChangePasswordInput, + revoke_all_sessions_at: Option, ) -> Result { validate_password(&input.new_password)?; let stored_user = self @@ -257,7 +273,7 @@ impl PasswordEntryService { .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; let user = self .store - .set_user_password_hash(&input.user_id, password_hash)? + .set_user_password_hash(&input.user_id, password_hash, revoke_all_sessions_at)? .ok_or(PasswordEntryError::UserNotFound)?; Ok(ChangePasswordResult { user }) @@ -375,6 +391,39 @@ impl RefreshSessionService { let sessions = self.store.list_active_sessions_by_user(user_id, now)?; Ok(ListActiveRefreshSessionsResult { sessions }) } + + pub fn revoke_session_by_user_and_session( + &self, + input: RevokeRefreshSessionByUserInput, + now: OffsetDateTime, + ) -> Result { + self.store + .find_by_user_id(&input.user_id) + .map_err(map_password_store_error)? + .ok_or(RefreshSessionError::UserNotFound)?; + + let Some(session_id) = normalize_required_string(&input.session_id) else { + return Err(RefreshSessionError::SessionNotFound); + }; + let revoked = + self.store + .revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?; + + Ok(RevokeRefreshSessionResult { + session_id, + revoked, + }) + } + + pub fn is_session_active_for_user( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + self.store + .is_session_active_for_user(user_id, session_id.trim(), now) + } } impl PhoneAuthService { @@ -779,7 +828,7 @@ impl AuthUserService { input: LogoutCurrentSessionInput, now: OffsetDateTime, ) -> Result { - if let Some(refresh_token_hash) = input + let revoked_by_hash = if let Some(refresh_token_hash) = input .refresh_token_hash .as_ref() .map(|value| value.trim()) @@ -788,6 +837,21 @@ impl AuthUserService { self.store .revoke_session_by_refresh_token_hash(refresh_token_hash, now) .map_err(map_refresh_error_to_logout_error)?; + true + } else { + false + }; + + if !revoked_by_hash + && let Some(session_id) = input + .session_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + self.store + .revoke_session_by_user_and_session_id(&input.user_id, session_id, now) + .map_err(map_refresh_error_to_logout_error)?; } let user = self @@ -1685,6 +1749,36 @@ impl InMemoryAuthStore { Ok(sessions) } + fn is_session_active_for_user( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + if session_id.trim().is_empty() { + return Ok(false); + } + + let state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(stored) = state.sessions_by_id.get(session_id) else { + return Ok(false); + }; + if stored.session.user_id != user_id || stored.session.revoked_at.is_some() { + return Ok(false); + } + + let expires_at = OffsetDateTime::parse( + &stored.session.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?; + + Ok(expires_at > now) + } + fn rotate_session( &self, session_id: &str, @@ -1774,6 +1868,37 @@ impl InMemoryAuthStore { Ok(()) } + fn revoke_session_by_user_and_session_id( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(stored) = state.sessions_by_id.get_mut(session_id) else { + return Ok(false); + }; + if stored.session.user_id != user_id { + return Ok(false); + } + if stored.session.revoked_at.is_some() { + return Ok(false); + } + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) + })?; + stored.session.revoked_at = Some(now_iso.clone()); + stored.session.updated_at = now_iso; + self.persist_refresh_state(&state)?; + + Ok(true) + } + fn revoke_all_sessions_by_user_id( &self, user_id: &str, @@ -1832,11 +1957,21 @@ impl InMemoryAuthStore { &self, user_id: &str, password_hash: String, + revoke_all_sessions_at: Option, ) -> Result, PasswordEntryError> { let mut state = self .inner .lock() .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + let revoke_all_sessions_at = match revoke_all_sessions_at { + Some(now) => Some( + now.format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}")) + })?, + ), + None => None, + }; for stored_user in state.users_by_username.values_mut() { if stored_user.user.id != user_id { @@ -1847,6 +1982,18 @@ impl InMemoryAuthStore { stored_user.password_login_enabled = true; stored_user.user.token_version += 1; let next_user = stored_user.user.clone(); + if let Some(now_iso) = revoke_all_sessions_at.as_ref() { + for stored_session in state.sessions_by_id.values_mut() { + if stored_session.session.user_id != user_id + || stored_session.session.revoked_at.is_some() + { + continue; + } + + stored_session.session.revoked_at = Some(now_iso.clone()); + stored_session.session.updated_at = now_iso.clone(); + } + } self.persist_password_state(&state)?; return Ok(Some(next_user)); } @@ -2177,6 +2324,118 @@ mod tests { assert_eq!(result.user.login_method, AuthLoginMethod::Password); } + #[tokio::test] + async fn change_password_and_revoke_all_sessions_revokes_every_refresh_session() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138030").await; + let password_service = build_password_service(store.clone()); + let refresh_service = build_refresh_service(store.clone()); + let now = OffsetDateTime::now_utc(); + + let first_password_user = password_service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("first password should set") + .user; + let first_token_hash = hash_refresh_session_token("change-password-token-01"); + let second_token_hash = hash_refresh_session_token("change-password-token-02"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("first session should create"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: second_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "safari".to_string(), + device_display_name: "iOS / Safari".to_string(), + ..build_client_info() + }, + }, + now + Duration::seconds(1), + ) + .expect("second session should create"); + + let changed_user = password_service + .change_password_and_revoke_all_sessions( + ChangePasswordInput { + user_id: user.id.clone(), + current_password: Some("secret123".to_string()), + new_password: "secret456".to_string(), + }, + now + Duration::minutes(1), + ) + .await + .expect("password change should revoke all sessions") + .user; + + assert_eq!( + changed_user.token_version, + first_password_user.token_version + 1 + ); + assert!( + refresh_service + .list_active_sessions_by_user(&user.id, now + Duration::minutes(2)) + .expect("active sessions should list") + .sessions + .is_empty() + ); + for (token_hash, next_hash) in [ + ( + first_token_hash, + hash_refresh_session_token("change-password-token-01-next"), + ), + ( + second_token_hash, + hash_refresh_session_token("change-password-token-02-next"), + ), + ] { + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: token_hash, + next_refresh_token_hash: next_hash, + }, + now + Duration::minutes(2), + ) + .expect_err("revoked session should not rotate"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + } + + assert_eq!( + password_service + .execute(PasswordEntryInput { + phone_number: "13800138030".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("old password should fail"), + PasswordEntryError::InvalidCredentials + ); + let login = password_service + .execute(PasswordEntryInput { + phone_number: "13800138030".to_string(), + password: "secret456".to_string(), + }) + .await + .expect("new password should login"); + assert_eq!(login.user.id, user.id); + } + #[tokio::test] async fn password_entry_rejects_wrong_password_after_set() { let store = build_store(); @@ -2524,6 +2783,7 @@ mod tests { LogoutCurrentSessionInput { user_id: user.id.clone(), refresh_token_hash: Some(refresh_token_hash.clone()), + session_id: None, }, OffsetDateTime::now_utc(), ) @@ -2543,6 +2803,148 @@ mod tests { assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); } + #[tokio::test] + async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138028").await; + let refresh_service = build_refresh_service(store.clone()); + let now = OffsetDateTime::now_utc(); + let first_token_hash = hash_refresh_session_token("revoke-target-token"); + let second_token_hash = hash_refresh_session_token("revoke-current-token"); + + let target = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("target session should create"); + let current = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: second_token_hash, + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + now + Duration::seconds(1), + ) + .expect("current session should create"); + + let revoke = refresh_service + .revoke_session_by_user_and_session( + RevokeRefreshSessionByUserInput { + user_id: user.id.clone(), + session_id: target.session.session_id.clone(), + }, + now + Duration::minutes(1), + ) + .expect("target session should revoke"); + + assert!(revoke.revoked); + assert_eq!(revoke.session_id, target.session.session_id); + assert!( + !refresh_service + .is_session_active_for_user( + &user.id, + &target.session.session_id, + now + Duration::minutes(2) + ) + .expect("target active check should succeed") + ); + assert!( + refresh_service + .is_session_active_for_user( + &user.id, + ¤t.session.session_id, + now + Duration::minutes(2) + ) + .expect("current active check should succeed") + ); + assert_eq!( + store + .find_by_user_id(&user.id) + .expect("user lookup should succeed") + .expect("user should exist") + .user + .token_version, + user.token_version + ); + + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: first_token_hash, + next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"), + }, + now + Duration::minutes(2), + ) + .expect_err("revoked target should not rotate"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + } + + #[tokio::test] + async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138029").await; + let refresh_service = build_refresh_service(store.clone()); + let user_service = build_user_service(store); + let now = OffsetDateTime::now_utc(); + let refresh_token_hash = hash_refresh_session_token("logout-sid-token"); + let session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("session should create"); + + let result = user_service + .logout_current_session( + LogoutCurrentSessionInput { + user_id: user.id.clone(), + refresh_token_hash: None, + session_id: Some(session.session.session_id.clone()), + }, + now + Duration::minutes(1), + ) + .expect("logout should succeed"); + + assert_eq!(result.user.token_version, user.token_version + 1); + assert!( + !refresh_service + .is_session_active_for_user( + &user.id, + &session.session.session_id, + now + Duration::minutes(2) + ) + .expect("session active check should succeed") + ); + + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"), + }, + now + Duration::minutes(2), + ) + .expect_err("sid-revoked session should fail"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + } + #[tokio::test] async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() { let store = build_store(); diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 038133a4..4bc26c85 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -114,6 +114,8 @@ pub struct AuthSessionsResponse { #[serde(rename_all = "camelCase")] pub struct AuthSessionSummaryPayload { pub session_id: String, + pub session_ids: Vec, + pub session_count: u32, pub client_type: String, pub client_runtime: String, pub client_platform: String, @@ -144,6 +146,11 @@ pub struct LogoutAllResponse { pub ok: bool, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RevokeAuthSessionResponse { + pub ok: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PhoneSendCodeRequest { diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index a505a005..3b7b2181 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -31,6 +31,8 @@ function renderAccountModal(overrides?: { riskBlocks?: AuthRiskBlockSummary[]; sessions?: AuthSessionSummary[]; auditLogs?: AuthAuditLogEntry[]; + onRevokeSession?: (session: AuthSessionSummary) => Promise; + revokingSessionIds?: string[]; initialSection?: | 'appearance' | 'account' @@ -63,7 +65,10 @@ function renderAccountModal(overrides?: { onRefreshSessions={vi.fn().mockResolvedValue(undefined)} onLogoutAll={vi.fn().mockResolvedValue(undefined)} onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)} - onRevokeSession={vi.fn().mockResolvedValue(undefined)} + onRevokeSession={ + overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined) + } + revokingSessionIds={overrides?.revokingSessionIds ?? []} changePhoneCaptchaChallenge={null} onSendChangePhoneCode={vi.fn().mockResolvedValue({ cooldownSeconds: 60, @@ -75,6 +80,30 @@ function renderAccountModal(overrides?: { ); } +function buildSession( + overrides: Partial = {}, +): AuthSessionSummary { + return { + sessionId: 'usess_1', + sessionIds: ['usess_1'], + sessionCount: 1, + clientType: 'web_browser', + clientRuntime: 'chrome', + clientPlatform: 'windows', + clientLabel: 'Windows / Chrome', + deviceDisplayName: 'Windows / Chrome', + miniProgramAppId: null, + miniProgramEnv: null, + userAgent: 'Mozilla/5.0', + ipMasked: '203.0.*.*', + isCurrent: false, + createdAt: '2026-05-01T10:00:00.000Z', + lastSeenAt: '2026-05-01T10:30:00.000Z', + expiresAt: '2026-06-01T10:30:00.000Z', + ...overrides, + }; +} + test('settings header uses a generic title instead of the phone number', () => { renderAccountModal(); @@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async }, ], sessions: [ - { + buildSession({ sessionId: 'session-1', + sessionIds: ['session-1'], + sessionCount: 1, clientType: 'mobile', clientRuntime: 'ios', clientPlatform: 'wechat', @@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async lastSeenAt: '2026-04-20T09:00:00.000Z', expiresAt: '2026-04-27T09:00:00.000Z', ipMasked: '10.0.*.*', - }, + }), ], auditLogs: [ { @@ -294,3 +325,77 @@ test('legacy nested section requests now open the merged account panel', () => { expect(within(accountDialog).getByText('登录设备')).toBeTruthy(); expect(within(accountDialog).getByText('操作记录')).toBeTruthy(); }); + +test('current merged session group hides kick action and shows count', async () => { + const user = userEvent.setup(); + + renderAccountModal({ + sessions: [ + buildSession({ + sessionId: 'usess_current', + sessionIds: ['usess_current', 'usess_rotated'], + sessionCount: 2, + isCurrent: true, + }), + ], + }); + + await user.click(screen.getByRole('button', { name: /账号信息/ })); + + const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); + expect(within(accountDialog).getByText('2 个会话')).toBeTruthy(); + expect( + within(accountDialog).queryByRole('button', { name: '踢下线' }), + ).toBeNull(); +}); + +test('remote merged session group can be revoked with loading state', async () => { + const user = userEvent.setup(); + const onRevokeSession = vi.fn().mockResolvedValue(undefined); + const remoteSession = buildSession({ + sessionId: 'usess_remote', + sessionIds: ['usess_remote', 'usess_remote_rotated'], + sessionCount: 2, + }); + + renderAccountModal({ + sessions: [remoteSession], + onRevokeSession, + revokingSessionIds: ['usess_remote'], + }); + + await user.click(screen.getByRole('button', { name: /账号信息/ })); + + const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); + const revokeButton = within(accountDialog).getByRole('button', { + name: '处理中...', + }) as HTMLButtonElement; + expect(revokeButton.disabled).toBe(true); + expect(within(accountDialog).getByText('2 个会话')).toBeTruthy(); + expect(onRevokeSession).not.toHaveBeenCalled(); +}); + +test('remote session revoke passes the grouped session payload', async () => { + const user = userEvent.setup(); + const onRevokeSession = vi.fn().mockResolvedValue(undefined); + const remoteSession = buildSession({ + sessionId: 'usess_remote', + sessionIds: ['usess_remote', 'usess_remote_rotated'], + sessionCount: 2, + }); + + renderAccountModal({ + sessions: [remoteSession], + onRevokeSession, + }); + + await user.click(screen.getByRole('button', { name: /账号信息/ })); + await user.click( + within(screen.getByRole('dialog', { name: '账号信息' })).getByRole( + 'button', + { name: '踢下线' }, + ), + ); + + expect(onRevokeSession).toHaveBeenCalledWith(remoteSession); +}); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 5864c53b..6c346beb 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -40,7 +40,8 @@ type AccountModalProps = { onRefreshSessions: () => Promise; onLogoutAll: () => Promise; onRefreshAuditLogs: () => Promise; - onRevokeSession: (sessionId: string) => Promise; + onRevokeSession: (session: AuthSessionSummary) => Promise; + revokingSessionIds: string[]; changePhoneCaptchaChallenge: AuthCaptchaChallenge | null; onSendChangePhoneCode: ( phone: string, @@ -298,6 +299,7 @@ export function AccountModal({ onLogoutAll, onRefreshAuditLogs, onRevokeSession, + revokingSessionIds, changePhoneCaptchaChallenge, onSendChangePhoneCode, onChangePhone, @@ -759,41 +761,55 @@ export function AccountModal({ 正在读取当前登录设备... ) : sessions.length > 0 ? ( - sessions.map((session) => ( -
-
- {session.clientLabel} - - {session.isCurrent ? '当前设备' : '已登录'} - -
-
- 最近活跃:{formatSessionTime(session.lastSeenAt)} -
-
- 到期时间:{formatSessionTime(session.expiresAt)} -
- {session.ipMasked ? ( -
- IP:{session.ipMasked} + sessions.map((session) => { + const isRevoking = revokingSessionIds.includes( + session.sessionId, + ); + + return ( +
+
+ {session.clientLabel} +
+ {session.sessionCount > 1 ? ( + + {session.sessionCount} 个会话 + + ) : null} + + {session.isCurrent ? '当前设备' : '已登录'} + +
- ) : null} - {!session.isCurrent ? ( - - ) : null} -
- )) +
+ 最近活跃:{formatSessionTime(session.lastSeenAt)} +
+
+ 到期时间:{formatSessionTime(session.expiresAt)} +
+ {session.ipMasked ? ( +
+ IP:{session.ipMasked} +
+ ) : null} + {!session.isCurrent ? ( + + ) : null} +
+ ); + }) ) : (
暂无可展示的登录设备。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 10e8acb9..21db5870 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, expect, test, vi } from 'vitest'; -import type { AuthUser } from '../../services/authService'; +import type { AuthSessionSummary, AuthUser } from '../../services/authService'; import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments'; import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; @@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({ logoutAuthUser: vi.fn(), redeemRegistrationInviteCode: vi.fn(), resetPassword: vi.fn(), + getAuthAuditLogs: vi.fn(), + getAuthRiskBlocks: vi.fn(), + getAuthSessions: vi.fn(), + revokeAuthSessions: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), consumeAuthCallbackResult: vi.fn(), @@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({ changePhoneNumber: vi.fn(), consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, getStoredLastLoginPhone: vi.fn(() => ''), - getAuthAuditLogs: vi.fn(), + getAuthAuditLogs: authMocks.getAuthAuditLogs, getAuthLoginOptions: authMocks.getAuthLoginOptions, - getAuthRiskBlocks: vi.fn(), + getAuthRiskBlocks: authMocks.getAuthRiskBlocks, getCurrentAuthUser: authMocks.getCurrentAuthUser, - getAuthSessions: vi.fn(), + getAuthSessions: authMocks.getAuthSessions, getCaptchaChallengeFromError: vi.fn(() => null), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, @@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({ logoutAuthUser: authMocks.logoutAuthUser, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, - revokeAuthSession: vi.fn(), + revokeAuthSessions: authMocks.revokeAuthSessions, sendPhoneLoginCode: authMocks.sendPhoneLoginCode, setStoredLastLoginPhone: vi.fn(), startWechatLogin: authMocks.startWechatLogin, @@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({ }), })); -vi.mock('./AccountModal', () => ({ - AccountModal: () => null, -})); +vi.mock('./AccountModal', async () => { + const actual = + await vi.importActual('./AccountModal'); + + return actual; +}); vi.mock('./BindPhoneScreen', () => ({ BindPhoneScreen: () =>
绑定手机号
, @@ -116,6 +123,10 @@ beforeEach(() => { authMocks.changePassword.mockResolvedValue(mockUser); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); authMocks.logoutAuthUser.mockResolvedValue(undefined); + authMocks.getAuthAuditLogs.mockResolvedValue([]); + authMocks.getAuthRiskBlocks.mockResolvedValue([]); + authMocks.getAuthSessions.mockResolvedValue([]); + authMocks.revokeAuthSessions.mockResolvedValue(undefined); authMocks.redeemRegistrationInviteCode.mockResolvedValue({ center: { inviteCode: 'SY12345678', @@ -205,6 +216,21 @@ function LogoutStateProbe() { ); } +function AccountPanelProbe() { + const authUi = useAuthUi(); + + return ( + + ); +} + test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => { expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd'); }); }); + +test('auth gate revokes merged session group and refreshes sessions', async () => { + const user = userEvent.setup(); + const initialSessions: AuthSessionSummary[] = [ + { + sessionId: 'usess_remote', + sessionIds: ['usess_remote', 'usess_remote_rotated'], + sessionCount: 2, + clientType: 'web_browser', + clientRuntime: 'chrome', + clientPlatform: 'windows', + clientLabel: 'Windows / Chrome', + deviceDisplayName: 'Windows / Chrome', + miniProgramAppId: null, + miniProgramEnv: null, + userAgent: 'Mozilla/5.0', + ipMasked: '203.0.*.*', + isCurrent: false, + createdAt: '2026-05-01T10:00:00.000Z', + lastSeenAt: '2026-05-01T10:30:00.000Z', + expiresAt: '2026-06-01T10:30:00.000Z', + }, + ]; + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + authMocks.getAuthSessions + .mockResolvedValueOnce(initialSessions) + .mockResolvedValueOnce([]); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '打开账号面板' })); + const accountDialog = await screen.findByRole('dialog', { + name: '账号信息', + }); + await user.click(within(accountDialog).getByRole('button', { name: '踢下线' })); + + await waitFor(() => { + expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([ + 'usess_remote', + 'usess_remote_rotated', + ]); + expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2); + }); +}); + +test('auth gate clears account state after password change', async () => { + const user = userEvent.setup(); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + authMocks.getAuthSessions.mockResolvedValue([]); + authMocks.changePassword.mockResolvedValue(mockUser); + + render( + +
+ + +
+
, + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '打开账号面板' })); + + const accountDialog = await screen.findByRole('dialog', { + name: '账号信息', + }); + await user.click( + within(accountDialog).getByRole('button', { name: '修改密码' }), + ); + + const passwordDialog = await screen.findByRole('dialog', { + name: '修改登录密码', + }); + await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1'); + await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1'); + await user.click( + within(passwordDialog).getByRole('button', { name: '确认修改密码' }), + ); + + await waitFor(() => { + expect(authMocks.changePassword).toHaveBeenCalledWith( + 'oldpass1', + 'newpass1', + ); + expect(screen.getByText('当前用户:未登录')).toBeTruthy(); + }); + expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull(); +}); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index a7db8e38..e4e12a61 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -38,7 +38,7 @@ import { logoutAuthUser, redeemRegistrationInviteCode, resetPassword, - revokeAuthSession, + revokeAuthSessions, sendPhoneLoginCode, setStoredLastLoginPhone, startWechatLogin, @@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) { useState(null); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); + const [revokingSessionIds, setRevokingSessionIds] = useState([]); const [auditLogs, setAuditLogs] = useState([]); const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); const [riskBlocks, setRiskBlocks] = useState([]); @@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) { setSettingsEntryMode('settings'); setInitialSettingsSection(null); setSessions([]); + setRevokingSessionIds([]); setAuditLogs([]); setRiskBlocks([]); setLoginCaptchaChallenge(null); @@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) { loadingRiskBlocks={loadingRiskBlocks} loadingSessions={loadingSessions} loadingAuditLogs={loadingAuditLogs} + revokingSessionIds={revokingSessionIds} isHydratingSettings={settings.isHydratingSettings} isPersistingSettings={settings.isPersistingSettings} settingsError={settings.settingsError} @@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) { setLoadingAuditLogs(false); } }} - onRevokeSession={async (sessionId) => { + onRevokeSession={async (session) => { + const sessionIds = + session.sessionIds.length > 0 + ? session.sessionIds + : [session.sessionId]; + setRevokingSessionIds((current) => + Array.from(new Set([...current, session.sessionId])), + ); try { - await revokeAuthSession(sessionId); - setSessions((current) => - current.filter( - (session) => session.sessionId !== sessionId, - ), - ); + await revokeAuthSessions(sessionIds); + setSessions(await getAuthSessions()); setAuditLogs(await getAuthAuditLogs()); } catch (revokeError) { setError( @@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) { ? revokeError.message : '移除登录设备失败,请稍后再试。', ); + } finally { + setRevokingSessionIds((current) => + current.filter((id) => id !== session.sessionId), + ); } }} onLogoutAll={logoutAllSessions} @@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) { setUser(nextUser); }} onChangePassword={async (currentPassword, newPassword) => { - const nextUser = await changePassword( - currentPassword, - newPassword, - ); - setUser(nextUser); + await changePassword(currentPassword, newPassword); + clearLocalAuthenticatedState(); }} /> ) : null} diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 11252950..2b181345 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -21,6 +21,7 @@ import { authEntry, bindWechatPhone, changePhoneNumber, + changePassword, consumeAuthCallbackResult, getAuthAuditLogs, getAuthLoginOptions, @@ -33,6 +34,8 @@ import { loginWithPhoneCode, logoutAllAuthSessions, redeemRegistrationInviteCode, + revokeAuthSession, + revokeAuthSessions, sendPhoneLoginCode, startWechatLogin, updateAuthProfile, @@ -154,6 +157,44 @@ describe('authService', () => { ); }); + it('change password clears local auth session after backend success', async () => { + window.localStorage.setItem( + 'genarrative:access-token', + 'jwt-before-password-change', + ); + apiClientMocks.requestJson.mockResolvedValue({ + user: { + id: 'user_1', + publicUserCode: 'SY-00000001', + username: 'phone_00000001', + displayName: '旅人甲', + avatarUrl: null, + phoneNumberMasked: '138****8000', + loginMethod: 'password', + bindingStatus: 'active', + wechatBound: false, + createdAt: '2026-05-01T00:00:00.000Z', + }, + }); + + const user = await changePassword(' old-password ', ' new-password '); + + expect(user.id).toBe('user_1'); + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/password/change', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + currentPassword: 'old-password', + newPassword: 'new-password', + }), + }), + '修改密码失败', + ); + expect(getStoredAccessToken()).toBe(''); + expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1); + }); + it('sends phone login code through the auth endpoint', async () => { apiClientMocks.requestJson.mockResolvedValue({ ok: true, @@ -475,8 +516,15 @@ describe('authService', () => { sessions: [ { sessionId: 'usess_1', + sessionIds: ['usess_1', 'usess_2'], + sessionCount: 2, clientType: 'browser', + clientRuntime: 'chrome', + clientPlatform: 'windows', clientLabel: '网页端浏览器', + deviceDisplayName: 'Windows / Chrome', + miniProgramAppId: null, + miniProgramEnv: null, userAgent: 'Mozilla/5.0', ipMasked: '127.0.*.*', isCurrent: true, @@ -490,6 +538,46 @@ describe('authService', () => { const sessions = await getAuthSessions(); expect(sessions).toHaveLength(1); + expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']); + expect(sessions[0].sessionCount).toBe(2); + }); + + it('revokes a single auth session by backend route', async () => { + apiClientMocks.requestJson.mockResolvedValue({ ok: true }); + + await revokeAuthSession('usess_1'); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/sessions/usess_1/revoke', + expect.objectContaining({ + method: 'POST', + }), + '移除登录设备失败', + ); + }); + + it('revokes grouped auth sessions once per unique session id', async () => { + apiClientMocks.requestJson.mockResolvedValue({ ok: true }); + + await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']); + + expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2); + expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith( + 1, + '/api/auth/sessions/usess_1/revoke', + expect.objectContaining({ + method: 'POST', + }), + '移除登录设备失败', + ); + expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith( + 2, + '/api/auth/sessions/usess_2/revoke', + expect.objectContaining({ + method: 'POST', + }), + '移除登录设备失败', + ); }); it('loads recent auth audit logs', async () => { diff --git a/src/services/authService.ts b/src/services/authService.ts index 407287d2..0ea3c820 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -289,6 +289,7 @@ export async function changePassword( '修改密码失败', ); + clearAuthSession(); return response.user; } @@ -441,6 +442,16 @@ export async function revokeAuthSession(sessionId: string) { ); } +export async function revokeAuthSessions(sessionIds: string[]) { + const uniqueSessionIds = Array.from( + new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)), + ); + + await Promise.all( + uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)), + ); +} + export async function getAuthAuditLogs() { const response = await requestJson( '/api/auth/audit-logs', From a92dc2b7b048b10a0b826e72ab9bab440ac463dd Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 16:07:54 +0800 Subject: [PATCH 03/14] fix(jenkins): add git fallback and nginx aliases --- .hermes/shared-memory/pitfalls.md | 8 +++ .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 13 ++--- jenkins/Jenkinsfile.production-api-deploy | 30 ++++++++--- .../Jenkinsfile.production-database-export | 30 ++++++++--- .../Jenkinsfile.production-database-import | 30 ++++++++--- .../Jenkinsfile.production-server-provision | 44 +++++++++++---- ...Jenkinsfile.production-stdb-module-publish | 30 ++++++++--- jenkins/Jenkinsfile.production-web-deploy | 30 ++++++++--- scripts/jenkins-checkout-source.sh | 53 +++++++++++++++++-- scripts/jenkins-server-provision.sh | 33 +++++++++++- 10 files changed, 239 insertions(+), 62 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index cbd9c0b1..0c11427d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -438,6 +438,14 @@ - 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。 - 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +## Jenkins 生产流水线拉 Git 先本机再内网备用 + +- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败。 +- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。 +- 处理:需要在运行于内网 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再尝试 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git`;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不要接入这个 fallback。 +- 验证:扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。 +- 关联:`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 + ## Jenkins 可选参数在 set -u 下不能裸读 - 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index a1375337..ca5e6ed3 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -132,8 +132,8 @@ SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露 Nginx 配置文件分为两类: -- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//fullchain.pem` 与 `privkey.pem` 已存在。 -- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。 +- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//fullchain.pem` 与 `privkey.pem` 已存在。`SERVER_NAME` 只填证书主目录名对应的单个域名;`www` 等额外域名通过 `SERVER_ALIASES` 写入 Nginx `server_name`,不参与证书目录拼接。 +- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名;如有多个入口,额外域名或 IP 填 `SERVER_ALIASES`。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。 ## 维护模式 @@ -273,12 +273,13 @@ journalctl -u 'jenkins-agent@*.service' -f Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置: - Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。 -- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 +- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 +- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、备用地址顺序重试,并在日志中输出最终使用的远端。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不使用该 fallback。 - 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。 -因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], ...])`。后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为 `GIT_REMOTE_URL`,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 +因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`;后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 -`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,必须把对应 Jenkinsfile 的 `GIT_REMOTE_URL` 改成 release agent 可访问的内网地址,不能让 release 发布阶段回退到 controller 公网拉取。 +`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,应优先确认 `10.2.0.10` 这类内网备用地址是否可达,并按实际网络拓扑更新对应 Jenkinsfile 的 `GIT_REMOTE_FALLBACK_URL`。release 发布阶段不能回退到 controller 公网拉取。 ### SSH PEM 凭证 @@ -462,7 +463,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 -首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。 +首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成证书主域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。如果同一张证书同时覆盖根域名和 `www` 域名,`SERVER_NAME` 仍只填证书目录名,例如 `genarrative.world`,`SERVER_ALIASES` 填 `www.genarrative.world`。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。 若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。 diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index ff48f52b..91449547 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -66,13 +67,25 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } script { if (params.COMMIT_HASH?.trim()) { echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。" @@ -84,7 +97,8 @@ pipeline { chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index e19af792..c261e669 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -82,20 +83,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } sh ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 8013cddb..f30f61e8 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -140,20 +141,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } sh ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index b809254f..157f4f0a 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -19,7 +20,8 @@ pipeline { booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit') - string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: 'Nginx server_name 与证书域名') + string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名') + string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world') string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径') string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') @@ -47,6 +49,17 @@ pipeline { if (!params.SERVER_NAME?.trim()) { error('SERVER_NAME 不能为空。') } + if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { + error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}") + } + def serverAliases = params.SERVER_ALIASES?.trim() + if (serverAliases) { + serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName -> + if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { + error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}") + } + } + } if (!params.SPACETIME_BIN_SOURCE?.trim()) { error('SPACETIME_BIN_SOURCE 不能为空。') } @@ -69,20 +82,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } sh ''' bash <<'BASH' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh BASH diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 5aee3862..2f178f1f 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -78,20 +79,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } sh ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index d2350236..e6b5a563 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts' } @@ -54,20 +55,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } sh ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh index 509eb6d8..38e8c2d6 100644 --- a/scripts/jenkins-checkout-source.sh +++ b/scripts/jenkins-checkout-source.sh @@ -5,11 +5,14 @@ set -euo pipefail SOURCE_BRANCH="${SOURCE_BRANCH:-master}" COMMIT_HASH="${COMMIT_HASH:-}" GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" +GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}" # Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM;下游参数校验前先剥离不可见字节。 SOURCE_BRANCH="$(printf "%s" "${SOURCE_BRANCH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" COMMIT_HASH="$(printf "%s" "${COMMIT_HASH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" +GIT_REMOTE_URL="$(printf "%s" "${GIT_REMOTE_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" +GIT_REMOTE_FALLBACK_URL="$(printf "%s" "${GIT_REMOTE_FALLBACK_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2 @@ -26,12 +29,52 @@ if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then exit 1 fi -if [[ -n "${GIT_REMOTE_URL}" ]]; then - git remote set-url origin "${GIT_REMOTE_URL}" -fi +GIT_REMOTE_CANDIDATES=() +add_git_remote_candidate() { + local candidate="$1" + local existing + if [[ -z "${candidate}" ]]; then + return + fi + for existing in "${GIT_REMOTE_CANDIDATES[@]}"; do + if [[ "${existing}" == "${candidate}" ]]; then + return + fi + done + GIT_REMOTE_CANDIDATES+=("${candidate}") +} + +fetch_source_branch() { + local remote_url="$1" + if [[ -n "${remote_url}" ]]; then + git remote set-url origin "${remote_url}" + fi + + echo "[jenkins-checkout-source] 尝试 Git 远端: ${remote_url:-origin}" + git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" +} + +add_git_remote_candidate "${GIT_REMOTE_URL}" +add_git_remote_candidate "${GIT_REMOTE_FALLBACK_URL}" git reset --hard HEAD -git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" +if [[ "${#GIT_REMOTE_CANDIDATES[@]}" -eq 0 ]]; then + fetch_source_branch "" +else + fetch_ok=0 + for git_remote_candidate in "${GIT_REMOTE_CANDIDATES[@]}"; do + if fetch_source_branch "${git_remote_candidate}"; then + GIT_REMOTE_URL="${git_remote_candidate}" + fetch_ok=1 + break + fi + echo "[jenkins-checkout-source] Git 远端拉取失败: ${git_remote_candidate}" >&2 + done + if [[ "${fetch_ok}" -ne 1 ]]; then + echo "[jenkins-checkout-source] 所有 Git 远端均拉取失败。" >&2 + exit 1 + fi +fi if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then git fetch --unshallow --tags || true @@ -55,4 +98,4 @@ git reset --hard HEAD git clean -fd printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}" -echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT}" +echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT} remote=${GIT_REMOTE_URL:-origin}" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 8551a738..203518d4 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -9,6 +9,28 @@ require_path() { fi } +normalize_server_aliases() { + printf "%s" "${SERVER_ALIASES:-}" | tr ',' ' ' | xargs +} + +validate_server_names() { + local alias_name + if [[ -z "${SERVER_NAME:-}" ]]; then + echo "[server-provision] SERVER_NAME 不能为空。" >&2 + exit 1 + fi + if [[ ! "${SERVER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then + echo "[server-provision] SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${SERVER_NAME}" >&2 + exit 1 + fi + for alias_name in $(normalize_server_aliases); do + if [[ ! "${alias_name}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then + echo "[server-provision] SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${alias_name}" >&2 + exit 1 + fi + done +} + run_cmd() { echo "+ $*" if [[ "${DRY_RUN}" != "true" ]]; then @@ -336,10 +358,15 @@ EOF render_nginx_template() { local template="$1" - local rendered_brotli + local rendered_brotli server_names rendered_brotli="$(render_nginx_brotli_directives)" + server_names="${SERVER_NAME}" + if [[ -n "${SERVER_ALIASES:-}" ]]; then + server_names="${server_names} $(normalize_server_aliases)" + fi sed \ - -e "s/genarrative.example.com/${SERVER_NAME}/g" \ + -e "s/server_name genarrative.example.com;/server_name ${server_names};/g" \ + -e "s|/etc/letsencrypt/live/genarrative.example.com/|/etc/letsencrypt/live/${SERVER_NAME}/|g" \ -e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \ -e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \ "${template}" <<<"${rendered_brotli}" @@ -504,6 +531,8 @@ require_path scripts/deploy/maintenance-on.sh require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh +validate_server_names + echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" run_cmd id From 49468441bc7892adca64de2e916d13fb288c9890 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 17:17:55 +0800 Subject: [PATCH 04/14] fix(jenkins): use git domain for scm remotes --- .hermes/shared-memory/pitfalls.md | 4 ++-- docs/agents/issue-tracker.md | 2 +- docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 2 +- jenkins/Jenkinsfile.production-api-build | 2 +- jenkins/Jenkinsfile.production-full-build-and-deploy | 2 +- jenkins/Jenkinsfile.production-stdb-module-build | 4 ++-- jenkins/Jenkinsfile.production-web-build | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0c11427d..05746fa2 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -442,8 +442,8 @@ - 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败。 - 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。 -- 处理:需要在运行于内网 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再尝试 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git`;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不要接入这个 fallback。 -- 验证:扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。 +- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不要接入 `10.2.0.10` 内网 fallback。运行于内网 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层才先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再尝试 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git`;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。 +- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。 - 关联:`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## Jenkins 可选参数在 set -u 下不能裸读 diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index f4b84310..55138fcd 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -2,7 +2,7 @@ Issues and PRDs for this repo live as issues in the self-hosted Gitea remote: -- Remote: `http://82.157.175.59:3000/GenarrativeAI/Genarrative.git` +- Remote: `https://git.genarrative.world/GenarrativeAI/Genarrative.git` - Tracker type: Gitea Issues ## Conventions diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index ca5e6ed3..5c959b55 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -272,7 +272,7 @@ journalctl -u 'jenkins-agent@*.service' -f Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置: -- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。 +- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网域名:`https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 - Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 - 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、备用地址顺序重试,并在日志中输出最终使用的远端。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不使用该 fallback。 - 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 120b29ce..bf90ed1a 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -10,7 +10,7 @@ pipeline { } environment { - GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' CARGO_HOME = '/home/dsk/.cache/genarrative-jenkins/api-server/cargo-home' CARGO_TARGET_DIR = '/home/dsk/.cache/genarrative-jenkins/api-server/cargo-target/prod-release' CARGO_INCREMENTAL = '0' diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index c7a061eb..a2b5fb34 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -12,7 +12,7 @@ pipeline { } environment { - GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } parameters { diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index a4946418..43603a1a 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -10,7 +10,7 @@ pipeline { } environment { - GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' CARGO_HOME = '${env.WORKSPACE_TMP}/cargo-home' CARGO_TARGET_DIR = '${env.WORKSPACE_TMP}/cargo-target/prod-release' CARGO_INCREMENTAL = '0' @@ -49,7 +49,7 @@ pipeline { $ErrorActionPreference = 'Stop' $sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' } $commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' } - $gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git' } + $gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } git fetch --no-tags --prune --depth=1 $gitRemoteUrl "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}" if ($commitHash) { git checkout --force $commitHash diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 1302e380..f3df2b33 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -10,7 +10,7 @@ pipeline { } environment { - GIT_REMOTE_URL = 'http://82.157.175.59:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts' } From bcd7617fb74ea7f2fdfe655e3c83093156ee6a5a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 19:35:50 +0800 Subject: [PATCH 05/14] Use domain fallback for Jenkins git checkout --- .hermes/shared-memory/pitfalls.md | 4 ++-- docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 4 ++-- jenkins/Jenkinsfile.production-api-deploy | 2 +- jenkins/Jenkinsfile.production-database-export | 2 +- jenkins/Jenkinsfile.production-database-import | 2 +- jenkins/Jenkinsfile.production-server-provision | 2 +- jenkins/Jenkinsfile.production-stdb-module-publish | 2 +- jenkins/Jenkinsfile.production-web-deploy | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 05746fa2..242a1d01 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -438,11 +438,11 @@ - 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。 - 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 -## Jenkins 生产流水线拉 Git 先本机再内网备用 +## Jenkins 生产流水线拉 Git 先本机再域名备用 - 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败。 - 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。 -- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不要接入 `10.2.0.10` 内网 fallback。运行于内网 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层才先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再尝试 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git`;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。 +- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。 - 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。 - 关联:`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 5c959b55..ca80a38a 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -274,12 +274,12 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 - Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网域名:`https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 - Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 -- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、备用地址顺序重试,并在日志中输出最终使用的远端。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不使用该 fallback。 +- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、域名备用地址顺序重试,并在日志中输出最终使用的远端。 - 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。 因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`;后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 -`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,应优先确认 `10.2.0.10` 这类内网备用地址是否可达,并按实际网络拓扑更新对应 Jenkinsfile 的 `GIT_REMOTE_FALLBACK_URL`。release 发布阶段不能回退到 controller 公网拉取。 +`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,`GIT_REMOTE_FALLBACK_URL` 统一使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不要再配置内网 IP 备用地址。 ### SSH PEM 凭证 diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index 91449547..bc1bae2d 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -9,7 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } parameters { diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index c261e669..af4a7fee 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -9,7 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } parameters { diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index f30f61e8..212d5232 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -9,7 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } parameters { diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 157f4f0a..16428120 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -9,7 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } parameters { diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 2f178f1f..decad34c 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -9,7 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } parameters { diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index e6b5a563..6e61cd88 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -9,7 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts' } From be53a90f7708f67402a2c45f2e5a9e3242d8bcab Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 19:40:33 +0800 Subject: [PATCH 06/14] remove github ci --- .github/workflows/ci.yml | 44 ---------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 52963332..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - - master - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20.19.0 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Check encoding - run: npm run check:encoding - - - name: Lint - run: npm run lint:eslint - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm run test - - - name: Build - run: npm run build - - - name: Validate content - run: npm run check:content From 2277b378887eb69eac30adad8a5be54bac3f797b Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 20:24:58 +0800 Subject: [PATCH 07/14] Limit Jenkins fallback git checkouts --- .hermes/shared-memory/pitfalls.md | 4 ++-- .../technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 8 ++++---- jenkins/Jenkinsfile.production-api-deploy | 7 +++++-- jenkins/Jenkinsfile.production-database-export | 7 +++++-- jenkins/Jenkinsfile.production-database-import | 7 +++++-- jenkins/Jenkinsfile.production-server-provision | 7 +++++-- jenkins/Jenkinsfile.production-stdb-module-publish | 7 +++++-- jenkins/Jenkinsfile.production-web-deploy | 7 +++++-- scripts/jenkins-checkout-source.sh | 10 +++++++--- 9 files changed, 43 insertions(+), 21 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 242a1d01..642dcd70 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -440,9 +440,9 @@ ## Jenkins 生产流水线拉 Git 先本机再域名备用 -- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败。 +- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败;若 fallback 到公网 Git 时没有限制 refspec、浅克隆和 tags,还可能在约 10 分钟后出现 `git-remote-https died of signal 15`、`early EOF`、`invalid index-pack output`。 - 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。 -- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。 +- 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;首次 checkout 必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。 - 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。 - 关联:`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index ca80a38a..2b69a829 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -274,10 +274,10 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 - Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网域名:`https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 - Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 -- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、域名备用地址顺序重试,并在日志中输出最终使用的远端。 +- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git` 重新 checkout。该首次 checkout 只拉 `SOURCE_BRANCH` 单分支、`depth=1` 且不拉 tags,避免 release agent 通过公网备用地址拉取全仓库历史时被 Jenkins Git checkout timeout 杀掉;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、域名备用地址顺序重试,并在日志中输出最终使用的远端。 - 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。 -因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`;后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 +因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/:refs/remotes/origin/"]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`,两次都必须保持单分支浅克隆和 `noTags=true`;后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 `127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,`GIT_REMOTE_FALLBACK_URL` 统一使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不要再配置内网 IP 备用地址。 @@ -425,8 +425,8 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 执行规则: -- 流水线先按 Jenkins SCM 配置 checkout 仓库,再执行 `git fetch --tags --prune origin "+refs/heads/:refs/remotes/origin/"`。 -- 如果工作区是浅克隆,流水线必须尝试 `git fetch --unshallow --tags`,确保能验证目标 commit 与分支关系。 +- 流水线先按 Jenkins SCM 配置 checkout 仓库,再执行单分支 `git fetch --no-tags --prune origin "+refs/heads/:refs/remotes/origin/"`;`COMMIT_HASH` 为空时追加 `--depth=1`。 +- 如果工作区是浅克隆,只有在 `COMMIT_HASH` 非空、需要验证指定提交属于目标分支时,流水线才尝试 `git fetch --unshallow --no-tags`。`COMMIT_HASH` 为空时只需要目标分支 HEAD,必须保持 `--depth=1 --no-tags`,避免普通发布或服务器配置任务拉取全仓库历史。 - `COMMIT_HASH` 为空时,detached checkout 到 `refs/remotes/origin/` 当前最新 commit。 - `COMMIT_HASH` 非空时,先解析到完整 commit,再用 `git merge-base --is-ancestor refs/remotes/origin/` 校验该提交属于指定分支,校验通过后 detached checkout。 - 流水线日志必须输出最终 `SOURCE_BRANCH` 与实际 `SOURCE_COMMIT`。 diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index bc1bae2d..af0d71a3 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -73,8 +73,11 @@ pipeline { $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: remoteUrl]], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } try { diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index af4a7fee..260a0135 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -89,8 +89,11 @@ pipeline { $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: remoteUrl]], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } try { diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 212d5232..a937630a 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -147,8 +147,11 @@ pipeline { $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: remoteUrl]], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } try { diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 16428120..ee5d13d5 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -88,8 +88,11 @@ pipeline { $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: remoteUrl]], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } try { diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index decad34c..13d29880 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -85,8 +85,11 @@ pipeline { $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: remoteUrl]], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } try { diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 6e61cd88..107dfd71 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -61,8 +61,11 @@ pipeline { $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: remoteUrl]], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } try { diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh index 38e8c2d6..5ae89465 100644 --- a/scripts/jenkins-checkout-source.sh +++ b/scripts/jenkins-checkout-source.sh @@ -51,7 +51,11 @@ fetch_source_branch() { fi echo "[jenkins-checkout-source] 尝试 Git 远端: ${remote_url:-origin}" - git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + if [[ -z "${COMMIT_HASH}" ]]; then + git fetch --no-tags --prune --depth=1 origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + else + git fetch --no-tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + fi } add_git_remote_candidate "${GIT_REMOTE_URL}" @@ -76,8 +80,8 @@ else fi fi -if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then - git fetch --unshallow --tags || true +if [[ -n "${COMMIT_HASH}" && "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then + git fetch --unshallow --no-tags origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" || true fi git cat-file -e "refs/remotes/origin/${SOURCE_BRANCH}^{commit}" From 5b96265c5029280c0db35b0289be3e2df73678d5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 20:39:01 +0800 Subject: [PATCH 08/14] fix wechat mini program phone parsing --- ...GIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md | 2 ++ ...T_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md | 2 +- server-rs/crates/platform-auth/src/lib.rs | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md index 6dd75c74..451bcf92 100644 --- a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md +++ b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md @@ -290,6 +290,8 @@ POST /api/auth/wechat/miniprogram-login 4. 若 `auth_binding_status=pending_bind_phone`,页面必须进入绑定手机号界面 5. 绑定成功后,应切回正常已登录状态 +小程序原生手机号授权链路中,请求体应携带 `wechatPhoneCode`。后端调用微信 `getuserphonenumber` 后,需要按微信原始响应字段 `phoneNumber` / `purePhoneNumber` / `countryCode` 解析手机号;如果误按 Rust 字段名 `phone_number` / `pure_phone_number` / `country_code` 解析,会出现已传 `wechatPhoneCode` 但返回“微信手机号授权失败:缺少手机号”的假失败。 + ## 10. 后端验收点 当前后端至少应满足以下检查: diff --git a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md index 3584ec12..d68a146d 100644 --- a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md +++ b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md @@ -132,7 +132,7 @@ Content-Type: application/json } ``` -9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。成功后重新签发 `active` 系统 token。 +9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。微信返回的手机号字段使用 `phoneNumber` / `purePhoneNumber` / `countryCode`,后端解析时必须兼容这些原始 camelCase 字段;否则会在已收到 `wechatPhoneCode` 的情况下误报“微信手机号授权失败:缺少手机号”。成功后重新签发 `active` 系统 token。 10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。 补充:H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。 diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 1d7be11b..ffa21519 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -358,10 +358,13 @@ struct WechatPhoneNumberResponse { #[derive(Debug, Deserialize)] struct WechatPhoneNumberInfo { #[serde(default)] + #[serde(alias = "phoneNumber")] phone_number: Option, #[serde(default)] + #[serde(alias = "purePhoneNumber")] pure_phone_number: Option, #[serde(default)] + #[serde(alias = "countryCode")] country_code: Option, } @@ -2109,6 +2112,30 @@ mod tests { ); } + #[test] + fn wechat_phone_number_response_accepts_wechat_camel_case_fields() { + let payload = serde_json::from_str::( + r#"{ + "errcode": 0, + "errmsg": "ok", + "phone_info": { + "phoneNumber": "+8613800138000", + "purePhoneNumber": "13800138000", + "countryCode": "86" + } + }"#, + ) + .expect("wechat phone number response should parse"); + let phone_info = payload.phone_info.expect("phone info should exist"); + + assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000")); + assert_eq!( + phone_info.pure_phone_number.as_deref(), + Some("13800138000") + ); + assert_eq!(phone_info.country_code.as_deref(), Some("86")); + } + #[test] fn mock_wechat_provider_builds_callback_authorization_url() { let provider = WechatProvider::new(WechatAuthConfig::new( From 2a75a19ece8d801963c9d43f9e84a19dc7bc0089 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 20:44:22 +0800 Subject: [PATCH 09/14] fix: handle visual novel typed SSE events --- .../spacetime-module/src/runtime/profile.rs | 19 +++ .../creationAgentClientFactory.ts | 24 ++- .../creation-agent/creationAgentSse.test.ts | 55 +++++- .../creation-agent/creationAgentSse.ts | 161 +++++++++++------- .../rpg-creation/rpgCreationAgentClient.ts | 2 +- .../visualNovelCreationClient.ts | 10 +- 6 files changed, 206 insertions(+), 65 deletions(-) diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index e0fcfcd5..2d27ca06 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1409,6 +1409,12 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) mod tests { use super::*; + #[test] + fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { + assert!(should_skip_existing_tracking_event_id(true)); + assert!(!should_skip_existing_tracking_event_id(false)); + } + #[test] fn recent_public_work_play_counts_group_requested_profiles_in_window() { let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; @@ -3223,6 +3229,10 @@ fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Res ) } +fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool { + event_exists +} + fn record_tracking_event( ctx: &ReducerContext, input: RuntimeTrackingEventInput, @@ -3242,6 +3252,15 @@ fn record_tracking_event( .map_err(|error| error.to_string())?; let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros); let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros); + if should_skip_existing_tracking_event_id( + ctx.db + .tracking_event() + .event_id() + .find(&validated_input.event_id) + .is_some(), + ) { + return Ok(()); + } // 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。 ensure_analytics_date_dimension_row(ctx, day_key)?; ctx.db.tracking_event().insert(TrackingEvent { diff --git a/src/services/creation-agent/creationAgentClientFactory.ts b/src/services/creation-agent/creationAgentClientFactory.ts index 688cffec..208108da 100644 --- a/src/services/creation-agent/creationAgentClientFactory.ts +++ b/src/services/creation-agent/creationAgentClientFactory.ts @@ -68,6 +68,28 @@ async function openCreationAgentSsePost( return response; } +type CreationAgentNormalizedStreamEvent = + | { + kind: 'reply_delta'; + text: string; + } + | { + kind: 'session'; + session: unknown; + } + | { + kind: 'error'; + message: string; + } + | null; + +type CreationAgentStreamOptions = TextStreamOptions & { + normalizeEvent?: ( + eventName: string, + parsed: Record, + ) => CreationAgentNormalizedStreamEvent; +}; + /** * 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。 * 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。 @@ -128,7 +150,7 @@ export function createCreationAgentClient< const streamMessage = async ( sessionId: string, payload: TSendMessagePayload, - options: TextStreamOptions = {}, + options: CreationAgentStreamOptions = {}, ): Promise => { const response = await openCreationAgentSsePost( `${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`, diff --git a/src/services/creation-agent/creationAgentSse.test.ts b/src/services/creation-agent/creationAgentSse.test.ts index 70314f3b..daaca683 100644 --- a/src/services/creation-agent/creationAgentSse.test.ts +++ b/src/services/creation-agent/creationAgentSse.test.ts @@ -1,6 +1,9 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; -import { readCreationAgentSessionFromSse } from './creationAgentSse'; +import { + normalizeVisualNovelAgentStreamEvent, + readCreationAgentSessionFromSse, +} from './creationAgentSse'; function createChunkedStreamResponse(chunks: Uint8Array[]) { const stream = new ReadableStream({ @@ -76,3 +79,51 @@ test('readCreationAgentSessionFromSse keeps streamed updates before error event' expect(updates).toEqual(['先把方洞万能的反差定住。']); }); + +test('readCreationAgentSessionFromSse can normalize typed visual novel stream events', async () => { + const encoder = new TextEncoder(); + const session = { + sessionId: 'vn-session-1', + ownerUserId: 'user-1', + progressPercent: 100, + stage: 'draft_ready', + }; + const onUpdate = vi.fn(); + + const response = createChunkedStreamResponse([ + encoder.encode( + 'data: {"type":"start","sessionId":"vn-session-1"}\n\n' + + 'data: {"type":"phase","phase":"synthesis"}\n\n' + + 'data: {"type":"text_delta","text":"视觉小说底稿已生成。"}\n\n' + + `data: ${JSON.stringify({ type: 'complete', session })}\n\n` + + 'data: {"type":"done"}\n\n', + ), + ]); + + await expect( + readCreationAgentSessionFromSse(response, { + fallbackMessage: '发送失败', + incompleteMessage: '结果不完整', + normalizeEvent: normalizeVisualNovelAgentStreamEvent, + onUpdate, + }), + ).resolves.toEqual(session); + expect(onUpdate).toHaveBeenCalledWith('视觉小说底稿已生成。'); +}); + +test('readCreationAgentSessionFromSse surfaces typed visual novel error events', async () => { + const encoder = new TextEncoder(); + const response = createChunkedStreamResponse([ + encoder.encode( + 'data: {"type":"error","message":"视觉小说流式创作失败","retryable":true}\n\n', + ), + ]); + + await expect( + readCreationAgentSessionFromSse(response, { + fallbackMessage: '发送失败', + incompleteMessage: '结果不完整', + normalizeEvent: normalizeVisualNovelAgentStreamEvent, + }), + ).rejects.toThrow('视觉小说流式创作失败'); +}); diff --git a/src/services/creation-agent/creationAgentSse.ts b/src/services/creation-agent/creationAgentSse.ts index 219f9219..f8ed2f8a 100644 --- a/src/services/creation-agent/creationAgentSse.ts +++ b/src/services/creation-agent/creationAgentSse.ts @@ -1,9 +1,27 @@ +import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel'; import type { TextStreamOptions } from '../aiTypes'; type CreationAgentSseOptions = TextStreamOptions & { fallbackMessage: string; incompleteMessage: string; resolveSession?: (rawSession: unknown) => TSession | null; + normalizeEvent?: ( + eventName: string, + parsed: Record, + ) => + | { + kind: 'reply_delta'; + text: string; + } + | { + kind: 'session'; + session: unknown; + } + | { + kind: 'error'; + message: string; + } + | null; }; function findSseEventBoundary(buffer: string) { @@ -65,6 +83,66 @@ function parseJsonObject(data: string) { } } +type NormalizedCreationAgentSseEvent = NonNullable< + CreationAgentSseOptions['normalizeEvent'] +> extends (eventName: string, parsed: Record) => infer TResult + ? TResult + : never; + +function normalizeDefaultCreationAgentEvent( + eventName: string, + parsed: Record, +): NormalizedCreationAgentSseEvent { + if (eventName === 'reply_delta') { + const text = parsed.text; + return typeof text === 'string' ? { kind: 'reply_delta', text } : null; + } + + if (eventName === 'session' && parsed.session) { + return { kind: 'session', session: parsed.session }; + } + + if (eventName === 'error') { + const message = + typeof parsed.message === 'string' && parsed.message.trim() + ? parsed.message.trim() + : ''; + return { kind: 'error', message }; + } + + return null; +} + +export function normalizeVisualNovelAgentStreamEvent( + eventName: string, + parsed: Record, +): NormalizedCreationAgentSseEvent { + const typedEventName = + eventName === 'message' && typeof parsed.type === 'string' + ? parsed.type + : eventName; + const event = { + ...parsed, + type: typedEventName, + } as VisualNovelAgentStreamEvent; + + switch (event.type) { + case 'text_delta': + return typeof event.text === 'string' + ? { kind: 'reply_delta', text: event.text } + : null; + case 'complete': + return event.session ? { kind: 'session', session: event.session } : null; + case 'error': + return { + kind: 'error', + message: event.message.trim(), + }; + default: + return normalizeDefaultCreationAgentEvent(eventName, parsed); + } +} + export async function readCreationAgentSessionFromSse( response: Response, options: CreationAgentSseOptions, @@ -81,15 +159,10 @@ export async function readCreationAgentSessionFromSse( ((rawSession: unknown) => (rawSession as TSession | null) ?? null); let buffer = ''; let finalSession: TSession | null = null; + const normalizeEvent = + options.normalizeEvent ?? normalizeDefaultCreationAgentEvent; - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - + const consumeBuffer = () => { for (;;) { const boundary = findSseEventBoundary(buffer); if (!boundary) { @@ -105,70 +178,40 @@ export async function readCreationAgentSessionFromSse( } const parsed = parseJsonObject(data); + if (!parsed) { + continue; + } + const normalized = normalizeEvent(eventName, parsed); - if (eventName === 'reply_delta' && parsed) { - const text = parsed.text; - if (typeof text === 'string') { - options.onUpdate?.(text); - } + if (normalized?.kind === 'reply_delta') { + options.onUpdate?.(normalized.text); continue; } - if (eventName === 'session' && parsed?.session) { - finalSession = resolveSession(parsed.session); + if (normalized?.kind === 'session') { + finalSession = resolveSession(normalized.session); continue; } - if (eventName === 'error' && parsed) { - const message = - typeof parsed.message === 'string' && parsed.message.trim() - ? parsed.message.trim() - : options.fallbackMessage; - throw new Error(message); + if (normalized?.kind === 'error') { + throw new Error(normalized.message || options.fallbackMessage); } } + }; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + consumeBuffer(); } // 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。 buffer += decoder.decode(); - - for (;;) { - const boundary = findSseEventBoundary(buffer); - if (!boundary) { - break; - } - - const eventBlock = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const { eventName, data } = parseSseEventBlock(eventBlock); - - if (!data) { - continue; - } - - const parsed = parseJsonObject(data); - - if (eventName === 'reply_delta' && parsed) { - const text = parsed.text; - if (typeof text === 'string') { - options.onUpdate?.(text); - } - continue; - } - - if (eventName === 'session' && parsed?.session) { - finalSession = resolveSession(parsed.session); - continue; - } - - if (eventName === 'error' && parsed) { - const message = - typeof parsed.message === 'string' && parsed.message.trim() - ? parsed.message.trim() - : options.fallbackMessage; - throw new Error(message); - } - } + consumeBuffer(); if (!finalSession) { throw new Error(options.incompleteMessage); diff --git a/src/services/rpg-creation/rpgCreationAgentClient.ts b/src/services/rpg-creation/rpgCreationAgentClient.ts index 593465a7..0a909009 100644 --- a/src/services/rpg-creation/rpgCreationAgentClient.ts +++ b/src/services/rpg-creation/rpgCreationAgentClient.ts @@ -72,7 +72,7 @@ export async function streamRpgCreationMessage( sessionId: string, payload: SendRpgAgentMessageRequest, options: TextStreamOptions = {}, -) { +): Promise { const response = await openRpgCreationSsePost( `/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`, payload, diff --git a/src/services/visual-novel-creation/visualNovelCreationClient.ts b/src/services/visual-novel-creation/visualNovelCreationClient.ts index c755531b..d72ee86c 100644 --- a/src/services/visual-novel-creation/visualNovelCreationClient.ts +++ b/src/services/visual-novel-creation/visualNovelCreationClient.ts @@ -9,7 +9,10 @@ import type { } from '../../../packages/shared/src/contracts/visualNovel'; import type { TextStreamOptions } from '../aiTypes'; import { type ApiRetryOptions, requestJson } from '../apiClient'; -import { createCreationAgentClient } from '../creation-agent'; +import { + createCreationAgentClient, + normalizeVisualNovelAgentStreamEvent, +} from '../creation-agent'; const VISUAL_NOVEL_AGENT_API_BASE = '/api/creation/visual-novel/sessions'; const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = { @@ -61,7 +64,10 @@ export function streamVisualNovelMessage( payload: SendVisualNovelMessageRequest, options: TextStreamOptions = {}, ) { - return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options); + return visualNovelAgentHttpClient.streamMessage(sessionId, payload, { + ...options, + normalizeEvent: normalizeVisualNovelAgentStreamEvent, + }); } export function executeVisualNovelAction( From c1131e6f55591a3f1f823d540cd5d3389760f955 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 21:14:13 +0800 Subject: [PATCH 10/14] feat: add visual novel AI image entry points --- .../VisualNovelResultView.test.tsx | 57 +++++++ .../VisualNovelResultView.tsx | 99 ++++++++++- src/services/visual-novel-creation/index.ts | 3 +- .../visualNovelImageGenerationClient.ts | 158 ++++++++++++++++++ 4 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 src/services/visual-novel-creation/visualNovelImageGenerationClient.ts diff --git a/src/components/visual-novel-result/VisualNovelResultView.test.tsx b/src/components/visual-novel-result/VisualNovelResultView.test.tsx index 949d70f6..dfea00eb 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.test.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.test.tsx @@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView'; vi.mock('../../services/visual-novel-creation', () => ({ createVisualNovelBackgroundMusicTask: vi.fn(), createVisualNovelSoundEffectTask: vi.fn(), + generateVisualNovelImageAsset: vi.fn(), + buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'), listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]), publishVisualNovelBackgroundMusicAsset: vi.fn(), publishVisualNovelSoundEffectAsset: vi.fn(), @@ -134,3 +136,58 @@ test('visual novel result uploads scene and character assets into platform refer onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc, ).toContain('/generated-custom-world-scenes/'); }); + +test('visual novel result generates scene background from asset picker', async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + const visualNovelCreation = await import('../../services/visual-novel-creation'); + const generateImageMock = vi.mocked( + visualNovelCreation.generateVisualNovelImageAsset, + ); + + generateImageMock.mockResolvedValue({ + imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp', + assetId: 'asset-scene-ai', + model: 'test-image-model', + size: '1280*720', + taskId: 'task-scene-ai', + prompt: '默认图片提示词', + }); + + render( + {}} + onSaveDraft={onSaveDraft} + />, + ); + + await user.click(screen.getByRole('button', { name: '场景' })); + await user.click(screen.getByRole('button', { name: /风雪站台/u })); + + const editorDialog = screen.getByRole('dialog', { name: '风雪站台' }); + await user.click( + within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!, + ); + await user.click( + within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', { + name: 'AI生成', + }), + ); + + await user.click(within(editorDialog).getByRole('button', { name: '关闭' })); + await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!); + + expect(generateImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'scene_background', + scene: expect.objectContaining({ + sceneId: mockVisualNovelDraft.scenes[0]?.sceneId, + }), + prompt: '默认图片提示词', + }), + ); + expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe( + '/generated-custom-world-scenes/vn-profile/scene-ai.webp', + ); +}); diff --git a/src/components/visual-novel-result/VisualNovelResultView.tsx b/src/components/visual-novel-result/VisualNovelResultView.tsx index 893188d2..90ad043e 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.tsx @@ -4,16 +4,16 @@ import { ImagePlus, Images, Loader2, + type LucideIcon, Music, - Save, PenLine, Play, + Save, Settings, Sparkles, Upload, Waves, X, - type LucideIcon, } from 'lucide-react'; import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -27,9 +27,12 @@ import type { VisualNovelStoryPhaseDraft, VisualNovelValidationIssue, } from '../../../packages/shared/src/contracts/visualNovel'; +import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; import { + buildVisualNovelImageGenerationPrompt, createVisualNovelBackgroundMusicTask, createVisualNovelSoundEffectTask, + generateVisualNovelImageAsset, listVisualNovelHistoryAssets, publishVisualNovelBackgroundMusicAsset, publishVisualNovelSoundEffectAsset, @@ -38,7 +41,6 @@ import { type VisualNovelHistoryAssetKind, type VisualNovelUploadAssetKind, } from '../../services/visual-novel-creation'; -import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData'; @@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = { profileId?: string | null; entityId?: string | null; previewTone: 'image' | 'audio'; + imageGeneratorConfig?: VisualNovelImageGeneratorConfig; }; type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect'; +type VisualNovelImageGeneratorKind = + | 'cover' + | 'scene_background' + | 'character_standee'; + +type VisualNovelImageGeneratorConfig = { + kind: VisualNovelImageGeneratorKind; + draft: VisualNovelResultDraft; + scene?: VisualNovelSceneDraft | null; + character?: VisualNovelCharacterDraft | null; +}; + type VisualNovelAudioGeneratorConfig = { kind: VisualNovelAudioGeneratorKind; scene: VisualNovelSceneDraft; @@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({ Boolean(config.historyKind), ); const [isUploading, setIsUploading] = useState(false); + const [isGeneratingImage, setIsGeneratingImage] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({ }; }, [config.historyKind]); + const handleGenerateImage = async () => { + if (!config.imageGeneratorConfig || config.previewTone !== 'image') { + return; + } + + setIsGeneratingImage(true); + setError(null); + try { + const result = await generateVisualNovelImageAsset({ + ...config.imageGeneratorConfig, + prompt: buildVisualNovelImageGenerationPrompt(config.imageGeneratorConfig), + }); + onSelect({ + assetObjectId: result.assetId || result.taskId, + assetKind: + config.uploadKind === 'character_standee' + ? 'character_visual' + : config.uploadKind === 'cover' + ? 'visual_novel_cover_image' + : 'scene_image', + objectKey: '', + imageSrc: result.imageSrc, + profileId: config.profileId ?? null, + entityId: config.entityId ?? null, + }); + } catch (generationError) { + setError( + generationError instanceof Error + ? generationError.message + : 'AI 图片生成失败。', + ); + } finally { + setIsGeneratingImage(false); + } + }; + const handleUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.currentTarget.value = ''; @@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
+ {config.imageGeneratorConfig && config.previewTone === 'image' ? ( + + ) : null} { void handleUpload(event); }} @@ -609,6 +678,7 @@ function VisualNovelAssetField({ entityId, historyKind, icon: Icon, + imageGeneratorConfig, label, onSelect, previewTone, @@ -621,6 +691,7 @@ function VisualNovelAssetField({ entityId?: string | null; historyKind?: VisualNovelHistoryAssetKind; icon: LucideIcon; + imageGeneratorConfig?: VisualNovelImageGeneratorConfig; label: string; onSelect: (asset: VisualNovelAssetReference) => void; previewTone: 'image' | 'audio'; @@ -710,6 +781,7 @@ function VisualNovelAssetField({ profileId, entityId, previewTone, + imageGeneratorConfig, }} disabled={disabled} onClose={() => setIsPickerOpen(false)} @@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({ accept="image/png,image/jpeg,image/webp" profileId={draft.profileId} previewTone="image" + imageGeneratorConfig={{ kind: 'cover', draft }} onSelect={(asset) => onChange({ ...draft, coverImageSrc: asset.imageSrc }) } @@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({ function VisualNovelCharacterEditor({ item, disabled, + draft, onChange, }: { item: VisualNovelCharacterDraft; disabled: boolean; + draft: VisualNovelResultDraft; onChange: (item: VisualNovelCharacterDraft) => void; }) { return ( @@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({ profileId={null} entityId={item.characterId} previewTone="image" + imageGeneratorConfig={{ + kind: 'character_standee', + draft, + character: item, + }} onSelect={(asset) => onChange({ ...item, @@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({ item, disabled, profileId, + draft, onChange, }: { item: VisualNovelSceneDraft; disabled: boolean; profileId?: string | null; + draft: VisualNovelResultDraft; onChange: (item: VisualNovelSceneDraft) => void; }) { return ( @@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({ profileId={profileId ?? null} entityId={item.sceneId} previewTone="image" + imageGeneratorConfig={{ + kind: 'scene_background', + draft, + scene: item, + }} onSelect={(asset) => onChange({ ...item, backgroundImageSrc: asset.imageSrc }) } @@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({ ) : null} @@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({ item={target.item} disabled={disabled} profileId={draft.profileId} + draft={draft} onChange={updateScene} /> ) : null} diff --git a/src/services/visual-novel-creation/index.ts b/src/services/visual-novel-creation/index.ts index a8ab7acc..fd1bca9b 100644 --- a/src/services/visual-novel-creation/index.ts +++ b/src/services/visual-novel-creation/index.ts @@ -1,3 +1,4 @@ -export * from './visualNovelCreationClient'; export * from './visualNovelAssetClient'; export * from './visualNovelAudioGenerationClient'; +export * from './visualNovelCreationClient'; +export * from './visualNovelImageGenerationClient'; diff --git a/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts b/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts new file mode 100644 index 00000000..79323ed4 --- /dev/null +++ b/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts @@ -0,0 +1,158 @@ +import type { + VisualNovelCharacterDraft, + VisualNovelResultDraft, + VisualNovelSceneDraft, +} from '../../../packages/shared/src/contracts/visualNovel'; +import type { + CustomWorldSceneImageRequest, + CustomWorldSceneImageResult, +} from '../aiTypes'; +import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient'; + +export type VisualNovelImageGenerationKind = + | 'cover' + | 'scene_background' + | 'character_standee'; + +export type VisualNovelImageGenerationRequest = { + kind: VisualNovelImageGenerationKind; + draft: VisualNovelResultDraft; + scene?: VisualNovelSceneDraft | null; + character?: VisualNovelCharacterDraft | null; + prompt?: string; + referenceImageSrc?: string; +}; + +function buildVisualNovelProfile( + draft: VisualNovelResultDraft, +): CustomWorldSceneImageRequest['profile'] { + return { + id: draft.profileId?.trim() || 'visual-novel-draft', + name: draft.workTitle.trim() || draft.world.title.trim() || '视觉小说作品', + subtitle: draft.world.title.trim() || draft.workTitle.trim() || '视觉小说', + summary: draft.workDescription.trim() || draft.world.summary.trim(), + tone: + draft.world.defaultTone.trim() || draft.world.literaryStyle.trim() || '视觉小说', + playerGoal: draft.world.playerRole.trim() || '推进剧情并完成关键选择', + settingText: [ + draft.world.premise, + draft.world.background, + draft.world.literaryStyle, + ] + .map((part) => part.trim()) + .filter(Boolean) + .join('\n'), + }; +} + +function buildVisualNovelLandmark( + payload: VisualNovelImageGenerationRequest, +): CustomWorldSceneImageRequest['landmark'] { + if (payload.kind === 'scene_background' && payload.scene) { + return { + id: payload.scene.sceneId, + name: payload.scene.name.trim() || '视觉小说场景', + description: payload.scene.description.trim() || payload.draft.world.summary, + }; + } + + if (payload.kind === 'character_standee' && payload.character) { + return { + id: payload.character.characterId, + name: `${payload.character.name.trim() || '视觉小说角色'}立绘`, + description: [ + payload.character.appearance, + payload.character.personality, + payload.character.role, + payload.character.relationshipToPlayer, + ] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(';'), + }; + } + + return { + id: payload.draft.profileId?.trim() || 'visual-novel-cover', + name: `${payload.draft.workTitle.trim() || '视觉小说'}封面`, + description: + payload.draft.workDescription.trim() || + payload.draft.world.summary.trim() || + payload.draft.world.premise.trim(), + }; +} + +function buildDefaultVisualNovelImagePrompt( + payload: VisualNovelImageGenerationRequest, +) { + const draft = payload.draft; + if (payload.kind === 'scene_background' && payload.scene) { + return [ + `视觉小说场景背景:${payload.scene.name}`, + payload.scene.description, + draft.world.defaultTone, + '16:9 横版背景图,无文字,无 UI,无人物特写', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + } + + if (payload.kind === 'character_standee' && payload.character) { + return [ + `视觉小说角色立绘:${payload.character.name}`, + payload.character.appearance, + payload.character.personality, + payload.character.tone, + '透明感二次元全身或半身立绘,干净背景,无文字,无 UI', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + } + + return [ + `视觉小说作品封面:${draft.workTitle}`, + draft.workDescription, + draft.world.summary, + draft.world.defaultTone, + '精致视觉小说封面构图,无文字,无 UI,适合 4:3/16:9 裁切', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); +} + +function resolveVisualNovelImageSize(kind: VisualNovelImageGenerationKind) { + if (kind === 'character_standee') { + return '768*1024'; + } + return '1280*720'; +} + +export async function generateVisualNovelImageAsset( + payload: VisualNovelImageGenerationRequest, +): Promise { + const userPrompt = + payload.prompt?.trim() || buildDefaultVisualNovelImagePrompt(payload); + + if (!userPrompt.trim()) { + throw new Error('请先补充图片生成提示词。'); + } + + return generateRpgWorldSceneImage({ + profile: buildVisualNovelProfile(payload.draft), + landmark: buildVisualNovelLandmark(payload), + userPrompt, + size: resolveVisualNovelImageSize(payload.kind), + ...(payload.referenceImageSrc?.trim() + ? { referenceImageSrc: payload.referenceImageSrc.trim() } + : {}), + }); +} + +export function buildVisualNovelImageGenerationPrompt( + payload: VisualNovelImageGenerationRequest, +) { + return buildDefaultVisualNovelImagePrompt(payload); +} From bf4423e53b81e33815e0d8a56eb9b5e7112926dd Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 21:20:11 +0800 Subject: [PATCH 11/14] Add release web artifact rsync fallback --- .hermes/shared-memory/pitfalls.md | 8 +++++ .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 3 +- jenkins/Jenkinsfile.production-web-deploy | 32 ++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 642dcd70..d1a4d84b 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -438,6 +438,14 @@ - 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。 - 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +## Release Web 产物通过内网 rsync 拉取 + +- 现象:`Genarrative-Web-Deploy` 发布到 `release` 目标时,release agent 本地没有 `/var/cache/genarrative-build/web-artifacts////web.tar.gz`,但 Jenkins controller 又只归档轻量元数据,导致发布阶段找不到 Web 大包。 +- 原因:Web 大包为了避免从 Linux 构建机拉回 Jenkins controller,默认留在构建机稳定缓存目录;development 目标与构建机同机可直接读取,release 目标是独立机器,需要内网同步。 +- 处理:release 服务器的 Jenkins 运行用户配置 SSH Host `genarrative-build-internal` 指向构建机内网地址,`Genarrative-Web-Deploy` 在 `DEPLOY_TARGET=release` 且本地缺少大包时默认执行 `rsync` 拉取同一路径内容;真实内网 IP、用户和私钥路径只放在服务器本机 SSH config,不写入 Jenkinsfile。 +- 验证:在 release 服务器上先手工跑通 `rsync -av --progress "genarrative-build-internal:${SRC}/" "${DST}/"`,再运行 Web Deploy;流水线会继续执行 `web.tar.gz.sha256` 校验。 +- 关联:`jenkins/Jenkinsfile.production-web-deploy`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 + ## Jenkins 生产流水线拉 Git 先本机再域名备用 - 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败;若 fallback 到公网 Git 时没有限制 refspec、浅克隆和 tags,还可能在约 10 分钟后出现 `git-remote-https died of signal 15`、`early EOF`、`invalid index-pack output`。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 2b69a829..71c69ba9 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -324,7 +324,7 @@ Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆 构建流水线运行在当前 Linux agent 的脱敏 label expression `linux && genarrative-build`。发布、导入导出和服务器配置流水线通过 `DEPLOY_TARGET` 映射到 Linux-only 脱敏部署表达式;其中 `development` 映射到当前 Linux 开发/构建/开发部署 agent 的 `linux && genarrative-build`,`release` 映射到独立 Linux 生产部署 agent 的 `linux && genarrative-release-deploy`,不能复用当前开发/构建/开发部署 agent。真实机器名、IP 和带 IP 的 Jenkins label 只允许留在 Jenkins 节点连接配置中,不能暴露为 Job 参数默认值、调度标签或文档推荐值。 -发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller,`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/`,`Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机;release 目标若不是同一台机器,必须先把该目录通过共享存储、rsync 或其它内网同步方式提供给 release 部署 agent。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` step,Jenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 +发布流水线通过 Jenkins `copyArtifacts(...)` 从对应构建 Job 获取归档产物,因此 Jenkins 需要安装并启用 Copy Artifact 插件。Web 大包例外:`Genarrative-Web-Build` 只把轻量元数据归档到 Jenkins controller,`web.tar.gz` 保留在 Linux 构建机稳定目录 `/var/cache/genarrative-build/web-artifacts/`,`Genarrative-Web-Deploy` 在部署目标机器上按构建 Job、构建号和版本号读取该目录。development 目标天然共享当前 Linux 开发/构建/开发部署机;release 目标若不是同一台机器,发布流水线默认在本地缓存缺少 `web.tar.gz` 时通过 `rsync` 从 SSH Host `genarrative-build-internal` 拉取同一路径内容。该 Host 必须配置在 release 服务器上 Jenkins 运行用户的 SSH config 中,真实内网 IP、用户和私钥路径只保存在服务器本机;如需改名或指定 config,可通过 `WEB_ARTIFACT_SYNC_HOST` / `WEB_ARTIFACT_SYNC_SSH_CONFIG` 参数覆盖。也可以提前通过共享存储或其它内网同步方式提供该目录。数据库导入流水线的手动上传模式使用 `stashedFile` 文件参数,因此 Jenkins 还需要安装并启用 File Parameter 插件。所有生产 Pipeline 日志必须带时间戳以便审计,Jenkins 需要安装 Timestamper 插件,并在全局配置中启用 `Enabled for all Pipeline builds`。邮件通知流水线使用 Jenkins Pipeline `mail` step,Jenkins 需要安装/启用 Mailer 能力,并在系统配置中配置 SMTP。生产发布不能退回到读取构建 workspace 本地目录的旧模式。 邮件通知的持久收件人不写入 Git,由 Jenkins `Secret text` 凭据 `genarrative-notification-emails` 保存,凭据内容为逗号分隔邮箱。所有生产流水线仍提供 `NOTIFICATION_EMAILS` 参数作为本次运行的追加收件人;通知 Job 会把持久收件人凭据与本次 `NOTIFICATION_EMAILS` 合并去重后发送,参数留空时只发送给持久收件人。流水线结束时在 `post { always { ... } }` 中异步触发 `Genarrative-Notify-Email`,把来源 Job、构建号、构建 URL、结果、源码分支、源码 commit、发布版本、部署目标和数据库名传给通知 Job。通知 Job 失败不能反向改变业务流水线结果,只在来源流水线日志中记录触发失败。 @@ -483,6 +483,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - 先按 `SOURCE_BRANCH` / `COMMIT_HASH` 解析并 checkout 部署脚本源码,默认使用 `origin/master` 最新 commit;上游构建触发时使用上游传入的实际构建 commit。 - 通过 Jenkins 归档获取 `web.tar.gz.sha256`、`release-manifest.json` 和 `web-artifact-pointer.txt`,再从 `/var/cache/genarrative-build/web-artifacts////` 读取 `web.tar.gz`;先校验 checksum,再解压到 `/opt/genarrative/releases//web`。 +- 当 `DEPLOY_TARGET=release` 且 release 服务器本地缓存缺少 `web.tar.gz` 时,默认先执行 `rsync -av --progress :/var/cache/genarrative-build/web-artifacts//// /var/cache/genarrative-build/web-artifacts////`,再继续校验 checksum;默认 Host 为 `genarrative-build-internal`,由 release 服务器本机 SSH config 解析。 - 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。 - 执行 Nginx 配置测试和静态页面 smoke test。 - 不进入维护模式。 diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index 107dfd71..943ad5c4 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -25,6 +25,9 @@ pipeline { string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录') string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点软链接') + booleanParam(name: 'SYNC_WEB_ARTIFACT_FROM_BUILD_HOST', defaultValue: true, description: 'release 目标本地缺少 Web 大包时,是否通过 rsync 从构建机内网拉取') + string(name: 'WEB_ARTIFACT_SYNC_HOST', defaultValue: 'genarrative-build-internal', description: 'rsync 源 SSH Host,通常来自 release 服务器上 Jenkins 运行用户的 ~/.ssh/config') + string(name: 'WEB_ARTIFACT_SYNC_SSH_CONFIG', defaultValue: '', description: '可选,rsync 使用的 ssh config 绝对路径;留空使用当前用户默认 ~/.ssh/config') } stages { @@ -109,9 +112,36 @@ pipeline { set -euo pipefail artifact_dir="${WEB_ARTIFACT_ROOT}/${BUILD_JOB_NAME}/${BUILD_NUMBER_TO_DEPLOY}/${BUILD_VERSION}" + if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then + sync_enabled="${SYNC_WEB_ARTIFACT_FROM_BUILD_HOST:-true}" + sync_host="${WEB_ARTIFACT_SYNC_HOST:-genarrative-build-internal}" + sync_ssh_config="${WEB_ARTIFACT_SYNC_SSH_CONFIG:-}" + + if [[ "${DEPLOY_TARGET:-development}" == "release" && "${sync_enabled}" == "true" ]]; then + if [[ -z "${sync_host}" ]]; then + echo "[web-deploy] release 目标需要同步 Web 大包,但 WEB_ARTIFACT_SYNC_HOST 为空。" >&2 + exit 1 + fi + + echo "[web-deploy] release 目标本地缓存缺少 Web 大包,尝试从 ${sync_host} 同步: ${artifact_dir}" + if ! command -v rsync >/dev/null 2>&1; then + echo "[web-deploy] 当前 release agent 缺少 rsync,请先安装 rsync 或预先挂载 Web 产物目录。" >&2 + exit 1 + fi + mkdir -p "${artifact_dir}" + + rsync_args=(-av --progress) + if [[ -n "${sync_ssh_config}" ]]; then + rsync_args+=(-e "ssh -F ${sync_ssh_config}") + fi + + rsync "${rsync_args[@]}" "${sync_host}:${artifact_dir}/" "${artifact_dir}/" + fi + fi + if [[ ! -f "${artifact_dir}/web.tar.gz" ]]; then echo "[web-deploy] 未找到构建机本地 Web 大包: ${artifact_dir}/web.tar.gz" >&2 - echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机;release 目标需要预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2 + echo "[web-deploy] development 目标要求 Web 构建与发布共享同一 Linux 构建/开发部署机;release 目标会默认通过 rsync 从 WEB_ARTIFACT_SYNC_HOST 拉取,也可预先同步或挂载 ${WEB_ARTIFACT_ROOT}。" >&2 exit 1 fi From ae58a443a3162e7d464f834e88de52f96ce8a01d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 00:16:17 +0800 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 + ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 77 +- docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 3 +- miniprogram/app.json | 4 +- miniprogram/pages/web-view/index.js | 2 +- miniprogram/pages/wechat-pay/index.js | 83 ++ miniprogram/pages/wechat-pay/index.json | 3 + miniprogram/pages/wechat-pay/index.wxml | 11 + miniprogram/pages/wechat-pay/index.wxss | 48 ++ packages/shared/src/contracts/runtime.ts | 19 +- server-rs/Cargo.lock | 2 + server-rs/Cargo.toml | 1 + server-rs/crates/api-server/Cargo.toml | 2 + server-rs/crates/api-server/src/app.rs | 5 + server-rs/crates/api-server/src/config.rs | 120 +++ server-rs/crates/api-server/src/main.rs | 1 + .../crates/api-server/src/runtime_profile.rs | 80 +- server-rs/crates/api-server/src/state.rs | 12 +- server-rs/crates/api-server/src/wechat_pay.rs | 780 ++++++++++++++++++ server-rs/crates/module-auth/src/domain.rs | 8 + server-rs/crates/module-auth/src/lib.rs | 30 + .../crates/module-runtime/src/application.rs | 3 +- .../crates/module-runtime/src/commands.rs | 14 + server-rs/crates/module-runtime/src/domain.rs | 25 +- server-rs/crates/module-runtime/src/errors.rs | 2 + .../crates/shared-contracts/src/runtime.rs | 15 +- .../crates/spacetime-client/src/mapper.rs | 25 + ...echarge_order_paid_and_return_procedure.rs | 62 ++ .../src/module_bindings/mod.rs | 4 + .../profile_recharge_order_type.rs | 10 +- ..._profile_recharge_order_paid_input_type.rs | 17 + ...me_profile_recharge_order_snapshot_type.rs | 3 +- ...time_profile_recharge_order_status_type.rs | 8 + .../crates/spacetime-client/src/runtime.rs | 36 + .../crates/spacetime-module/src/migration.rs | 8 + .../spacetime-module/src/runtime/profile.rs | 184 ++++- .../RpgEntryHomeView.recharge.test.tsx | 313 ++++--- src/components/rpg-entry/RpgEntryHomeView.tsx | 400 ++++++++- src/services/authService.test.ts | 5 +- src/services/rpg-entry/rpgProfileClient.ts | 3 +- src/vite-env.d.ts | 12 + 42 files changed, 2265 insertions(+), 191 deletions(-) create mode 100644 miniprogram/pages/wechat-pay/index.js create mode 100644 miniprogram/pages/wechat-pay/index.json create mode 100644 miniprogram/pages/wechat-pay/index.wxml create mode 100644 miniprogram/pages/wechat-pay/index.wxss create mode 100644 server-rs/crates/api-server/src/wechat_pay.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_paid_input_type.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 42531e86..b912bfb4 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-13 微信小程序支付以后端通知为唯一入账事实 + +- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。 +- 决策:`paymentChannel = "mock"` 继续创建即 paid 订单并立即入账;`paymentChannel = "wechat_mp"` 先在 `profile_recharge_order` 写入 `pending` 订单,再由 `api-server` 调微信支付 JSAPI 下单并返回小程序 `wx.requestPayment` 参数。小程序或 H5 的支付成功回调只触发刷新,不直接发放光点或会员;最终入账只由 `/api/profile/recharge/wechat/notify` 验签、解密并确认 `trade_state = SUCCESS` 后完成。`provider_transaction_id` 保存微信支付平台交易号,用于对账、查单、退款和客服排障。 +- 影响范围:`profile_recharge_order` 表、SpacetimeDB 充值 procedure、`api-server` 微信支付客户端、小程序 native 支付页、H5 充值弹窗与共享 contract。 +- 验证方式:执行 `npm run typecheck`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`,后端联调仍用 `npm run api-server` 和 `/healthz`。 +- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 + ## 2026-05-13 修改密码后全设备强制下线 - 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index d1a4d84b..20724a65 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -541,6 +541,14 @@ - 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 覆盖抓大鹅和拼图生成后自动试玩 / 返回结果页。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +## 微信支付回调验签不要用商户私钥 + +- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。 +- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。 +- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id` 到 `profile_recharge_order.provider_transaction_id`。 +- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。 +- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 + ## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布 - 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。 diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 0a330d2d..555ba04d 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -9,30 +9,30 @@ 1. `光点充值` 2. `会员卡充值` -前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。 +前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。普通 H5 / 本地联调继续使用 `mock` 渠道:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。微信小程序 web-view 使用 `wechat_mp` 渠道:创建订单时只写入 `pending` 订单并返回小程序 `wx.requestPayment` 参数,真实到账以后端微信支付通知为准。 ## 2. 产品规则 ### 2.1 光点充值套餐 -| productId | 光点 | 金额分 | 徽标 | 说明 | -| --- | ---: | ---: | --- | --- | -| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 | -| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 | -| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 | -| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 | -| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 | -| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 | +| productId | 光点 | 金额分 | 徽标 | 说明 | +| ------------- | ---: | -----: | -------- | -------------- | +| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 | +| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 | +| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 | +| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 | +| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 | +| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 | 光点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账光点为基础光点与等额赠送光点之和;已有充值流水后只到账基础光点。实际到账光点写入交易流水,余额以 SpacetimeDB projection 为准。 ### 2.2 会员卡套餐 -| productId | 类型 | 天数 | 金额分 | 权益 | -| --- | --- | ---: | ---: | --- | -| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100,每日签到加成0% | -| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100,每日签到加成100% | -| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100,每日签到加成210% | +| productId | 类型 | 天数 | 金额分 | 权益 | +| --------------- | ---- | ---: | -----: | --------------------------------- | +| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100,每日签到加成0% | +| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100,每日签到加成100% | +| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100,每日签到加成210% | 购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。 @@ -63,19 +63,58 @@ 行为: 1. 校验 `productId` -2. 后端创建已支付订单 -3. 光点套餐写入钱包余额与流水 -4. 会员套餐写入会员状态 -5. 返回最新账户中心快照与订单摘要 +2. `paymentChannel = "mock"` 时后端创建已支付订单 +3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数 +4. mock 光点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态 +5. wechat_mp 订单不提前发光点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` 兼容路径:`POST /api/runtime/profile/recharge/orders` +响应里的 `wechatMiniProgramPayParams` 只在微信小程序支付渠道返回,字段直接对应 `wx.requestPayment`: + +```json +{ + "wechatMiniProgramPayParams": { + "timeStamp": "1777110165", + "nonceStr": "nonce", + "package": "prepay_id=wx201410272009395522657a690389285100", + "signType": "RSA", + "paySign": "..." + } +} +``` + +### 3.3 `POST /api/profile/recharge/wechat/notify` + +微信支付通知地址,无需 Bearer JWT。行为: + +1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。 +2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`。 +3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。 +4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。 +5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。 +6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。 + +关键环境变量: + +| 变量 | 说明 | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `WECHAT_PAY_ENABLED` | 是否启用微信支付客户端 | +| `WECHAT_PAY_PROVIDER` | `mock` 或 `real` | +| `WECHAT_PAY_MCH_ID` | 微信支付商户号 | +| `WECHAT_PAY_MERCHANT_SERIAL_NO` | 商户 API 证书序列号,用于请求微信支付签名头 | +| `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PRIVATE_KEY_PATH` | 商户 API 私钥 | +| `WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM` / `WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH` | 微信支付平台公钥或平台证书公钥,用于回调验签 | +| `WECHAT_PAY_PLATFORM_SERIAL_NO` | 微信支付通知头里的平台证书/公钥序列号 | +| `WECHAT_PAY_API_V3_KEY` | 32 字节 API v3 密钥,用于解密通知资源 | +| `WECHAT_PAY_NOTIFY_URL` | 公网 HTTPS 通知地址,通常为 `/api/profile/recharge/wechat/notify` | + ## 4. 前端交互 1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。 2. 弹窗顶部标题为 `账户充值`,右上角关闭。 3. 默认打开 `光点充值`,可切换到 `会员卡充值`。 -4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`。 +4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 5. 弹窗内不写大段说明文案,只保留必要金额、光点、会员权益和状态反馈。 6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index 3b33ced9..bf472de7 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -328,7 +328,8 @@ SELECT * FROM profile_membership WHERE user_id = ''; ### `profile_recharge_order` - 作用:充值订单表,记录用户购买光点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。 -- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Timestamp`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option`。 +- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Option`, `provider_transaction_id: Option`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option`。 +- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid`,`provider_transaction_id` 保存微信支付平台订单号。 - 索引:`user_id`, `(user_id, created_at)`。 ```sql diff --git a/miniprogram/app.json b/miniprogram/app.json index b83a1148..fa9834ee 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -1,7 +1,5 @@ { - "pages": [ - "pages/web-view/index" - ], + "pages": ["pages/web-view/index", "pages/wechat-pay/index"], "window": { "navigationBarTitleText": "百梦", "navigationBarBackgroundColor": "#0b0f14", diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 40065664..5cbc3925 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -343,7 +343,7 @@ Page({ }, handleWebViewMessage(event) { - // 中文注释:H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息。 + // 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, }); diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js new file mode 100644 index 00000000..ab0e0041 --- /dev/null +++ b/miniprogram/pages/wechat-pay/index.js @@ -0,0 +1,83 @@ +function parsePayParams(rawValue) { + try { + const params = JSON.parse(decodeURIComponent(String(rawValue || ''))); + if (!params || typeof params !== 'object') { + return null; + } + return params; + } catch (error) { + console.error('[wechat-pay] parse params failed', error); + return null; + } +} + +function requestPayment(payParams) { + return new Promise((resolve) => { + wx.requestPayment({ + timeStamp: String(payParams.timeStamp || ''), + nonceStr: String(payParams.nonceStr || ''), + package: String(payParams.package || ''), + signType: payParams.signType || 'RSA', + paySign: String(payParams.paySign || ''), + success() { + resolve('success'); + }, + fail(error) { + const errMsg = error && error.errMsg ? error.errMsg : ''; + resolve(/cancel/i.test(errMsg) ? 'cancel' : 'fail'); + }, + }); + }); +} + +function appendPayResult(url, requestId, status) { + const value = `${requestId}:${status}`; + const hashIndex = String(url || '').indexOf('#'); + const baseUrl = + hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || ''); + const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : ''; + const params = new URLSearchParams(rawHash); + params.set('wx_pay_result', value); + return `${baseUrl}#${params.toString()}`; +} + +function notifyPreviousWebView(requestId, status) { + const pages = getCurrentPages(); + const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null; + if (previousPage && typeof previousPage.setData === 'function') { + previousPage.setData({ + webViewUrl: appendPayResult( + previousPage.data.webViewUrl, + requestId, + status, + ), + }); + } +} + +Page({ + data: { + title: '正在拉起支付', + errorMessage: '', + }, + + async onLoad(query) { + const requestId = String(query.requestId || ''); + const payParams = parsePayParams(query.payParams); + if (!requestId || !payParams) { + this.setData({ + title: '支付失败', + errorMessage: '缺少支付参数。', + }); + return; + } + + const status = await requestPayment(payParams); + notifyPreviousWebView(requestId, status); + wx.navigateBack(); + }, + + handleBack() { + wx.navigateBack(); + }, +}); diff --git a/miniprogram/pages/wechat-pay/index.json b/miniprogram/pages/wechat-pay/index.json new file mode 100644 index 00000000..18f10355 --- /dev/null +++ b/miniprogram/pages/wechat-pay/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "微信支付" +} diff --git a/miniprogram/pages/wechat-pay/index.wxml b/miniprogram/pages/wechat-pay/index.wxml new file mode 100644 index 00000000..aaba9491 --- /dev/null +++ b/miniprogram/pages/wechat-pay/index.wxml @@ -0,0 +1,11 @@ + + + {{title}} + + {{errorMessage}} + + + + diff --git a/miniprogram/pages/wechat-pay/index.wxss b/miniprogram/pages/wechat-pay/index.wxss new file mode 100644 index 00000000..37092ed5 --- /dev/null +++ b/miniprogram/pages/wechat-pay/index.wxss @@ -0,0 +1,48 @@ +.pay-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + background: #0b0f14; + box-sizing: border-box; +} + +.pay-card { + width: 100%; + max-width: 560rpx; + padding: 36rpx; + border: 1rpx solid rgba(255, 255, 255, 0.14); + border-radius: 12rpx; + background: rgba(255, 255, 255, 0.06); + box-sizing: border-box; +} + +.pay-title { + font-size: 34rpx; + font-weight: 600; + line-height: 1.35; + color: #f5f7fb; +} + +.pay-text { + margin-top: 16rpx; + font-size: 26rpx; + line-height: 1.55; + color: rgba(245, 247, 251, 0.72); +} + +.pay-text--danger { + color: #ffb4a9; +} + +.ghost-button { + margin-top: 28rpx; + width: 100%; + border-radius: 8rpx; + border: 1rpx solid rgba(255, 255, 255, 0.24); + background: transparent; + color: rgba(245, 247, 251, 0.86); + font-size: 26rpx; + line-height: 2.6; +} diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 18bc892c..eab877b8 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -78,7 +78,12 @@ export type ProfileWalletLedgerResponse = { export type ProfileRechargeProductKind = 'points' | 'membership'; export type ProfileMembershipStatus = 'normal' | 'active'; export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year'; -export type ProfileRechargeOrderStatus = 'paid'; +export type ProfileRechargeOrderStatus = + | 'pending' + | 'paid' + | 'failed' + | 'closed' + | 'refunded'; export type ProfileRechargeProduct = { productId: string; @@ -117,7 +122,8 @@ export type ProfileRechargeOrder = { amountCents: number; status: ProfileRechargeOrderStatus; paymentChannel: string; - paidAt: string; + paidAt: string | null; + providerTransactionId: string | null; createdAt: string; pointsDelta: number; membershipExpiresAt: string | null; @@ -133,6 +139,14 @@ export type ProfileRechargeCenterResponse = { hasPointsRecharged: boolean; }; +export type WechatMiniProgramPayParams = { + timeStamp: string; + nonceStr: string; + package: string; + signType: 'RSA'; + paySign: string; +}; + export type CreateProfileRechargeOrderRequest = { productId: string; paymentChannel?: string; @@ -141,6 +155,7 @@ export type CreateProfileRechargeOrderRequest = { export type CreateProfileRechargeOrderResponse = { order: ProfileRechargeOrder; center: ProfileRechargeCenterResponse; + wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null; }; export type ProfileFeedbackStatus = 'open'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index c8cc837b..b99e6a20 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -81,6 +81,7 @@ dependencies = [ "async-stream", "axum", "base64 0.22.1", + "bytes", "dotenvy", "futures-util", "hmac", @@ -109,6 +110,7 @@ dependencies = [ "platform-oss", "platform-speech", "reqwest 0.12.28", + "ring", "serde", "serde_json", "sha2", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index dc9c8b92..10c0e280 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -93,6 +93,7 @@ langchainrust = "0.2.18" log = "0.4" rand_core = "0.6" reqwest = { version = "0.12", default-features = false } +ring = "0.17" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 9dec401d..06e24092 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true async-stream = { workspace = true } axum = { workspace = true, features = ["ws"] } base64 = { workspace = true } +bytes = { workspace = true } dotenvy = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } @@ -34,6 +35,7 @@ platform-auth = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } +ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } shared-contracts = { workspace = true, features = ["oss-contracts"] } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 15052774..10249e7a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -179,6 +179,7 @@ use crate::{ wechat_auth::{ bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login, }, + wechat_pay::handle_wechat_pay_notify, }; const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; @@ -1410,6 +1411,10 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/recharge/wechat/notify", + post(handle_wechat_pay_notify), + ) .route( "/api/profile/feedback", post(submit_profile_feedback) diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6f6a2d47..93d70e44 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -71,6 +71,18 @@ pub struct AppConfig { pub wechat_mock_union_id: Option, pub wechat_mock_display_name: String, pub wechat_mock_avatar_url: Option, + pub wechat_pay_enabled: bool, + pub wechat_pay_provider: String, + pub wechat_pay_mch_id: Option, + pub wechat_pay_merchant_serial_no: Option, + pub wechat_pay_private_key_pem: Option, + pub wechat_pay_private_key_path: Option, + pub wechat_pay_platform_public_key_pem: Option, + pub wechat_pay_platform_public_key_path: Option, + pub wechat_pay_platform_serial_no: Option, + pub wechat_pay_api_v3_key: Option, + pub wechat_pay_notify_url: Option, + pub wechat_pay_jsapi_endpoint: String, pub oss_bucket: Option, pub oss_endpoint: Option, pub oss_access_key_id: Option, @@ -189,6 +201,19 @@ impl Default for AppConfig { wechat_mock_union_id: Some("wx-mock-union".to_string()), wechat_mock_display_name: "微信旅人".to_string(), wechat_mock_avatar_url: None, + wechat_pay_enabled: false, + wechat_pay_provider: "mock".to_string(), + wechat_pay_mch_id: None, + wechat_pay_merchant_serial_no: None, + wechat_pay_private_key_pem: None, + wechat_pay_private_key_path: None, + wechat_pay_platform_public_key_pem: None, + wechat_pay_platform_public_key_path: None, + wechat_pay_platform_serial_no: None, + wechat_pay_api_v3_key: None, + wechat_pay_notify_url: None, + wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi" + .to_string(), oss_bucket: None, oss_endpoint: None, oss_access_key_id: None, @@ -458,6 +483,33 @@ impl AppConfig { } config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]); + if let Some(wechat_pay_enabled) = read_first_bool_env(&["WECHAT_PAY_ENABLED"]) { + config.wechat_pay_enabled = wechat_pay_enabled; + } + if let Some(wechat_pay_provider) = read_first_non_empty_env(&["WECHAT_PAY_PROVIDER"]) { + config.wechat_pay_provider = wechat_pay_provider; + } + config.wechat_pay_mch_id = read_first_non_empty_env(&["WECHAT_PAY_MCH_ID"]); + config.wechat_pay_merchant_serial_no = + read_first_non_empty_env(&["WECHAT_PAY_MERCHANT_SERIAL_NO"]); + config.wechat_pay_private_key_pem = + read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PEM"]); + config.wechat_pay_private_key_path = + read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PATH"]).map(PathBuf::from); + config.wechat_pay_platform_public_key_pem = + read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM"]); + config.wechat_pay_platform_public_key_path = + read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"]).map(PathBuf::from); + config.wechat_pay_platform_serial_no = + read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_SERIAL_NO"]); + config.wechat_pay_api_v3_key = read_first_non_empty_env(&["WECHAT_PAY_API_V3_KEY"]); + config.wechat_pay_notify_url = read_first_non_empty_env(&["WECHAT_PAY_NOTIFY_URL"]); + if let Some(wechat_pay_jsapi_endpoint) = + read_first_non_empty_env(&["WECHAT_PAY_JSAPI_ENDPOINT"]) + { + config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint; + } + config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]); config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]); config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]); @@ -1081,6 +1133,74 @@ mod tests { } } + #[test] + fn from_env_reads_wechat_pay_settings() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("WECHAT_PAY_ENABLED"); + std::env::remove_var("WECHAT_PAY_PROVIDER"); + std::env::remove_var("WECHAT_PAY_MCH_ID"); + std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO"); + std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH"); + std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"); + std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO"); + std::env::remove_var("WECHAT_PAY_API_V3_KEY"); + std::env::remove_var("WECHAT_PAY_NOTIFY_URL"); + std::env::set_var("WECHAT_PAY_ENABLED", "true"); + std::env::set_var("WECHAT_PAY_PROVIDER", "real"); + std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109"); + std::env::set_var("WECHAT_PAY_MERCHANT_SERIAL_NO", "serial-001"); + std::env::set_var("WECHAT_PAY_PRIVATE_KEY_PATH", "certs/apiclient_key.pem"); + std::env::set_var( + "WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH", + "certs/wechatpay_platform.pem", + ); + std::env::set_var("WECHAT_PAY_PLATFORM_SERIAL_NO", "platform-serial-001"); + std::env::set_var("WECHAT_PAY_API_V3_KEY", "12345678901234567890123456789012"); + std::env::set_var( + "WECHAT_PAY_NOTIFY_URL", + "https://api.example.com/api/profile/recharge/wechat/notify", + ); + } + + let config = AppConfig::from_env(); + assert!(config.wechat_pay_enabled); + assert_eq!(config.wechat_pay_provider, "real"); + assert_eq!(config.wechat_pay_mch_id.as_deref(), Some("1900000109")); + assert_eq!( + config.wechat_pay_private_key_path.as_deref(), + Some(std::path::Path::new("certs/apiclient_key.pem")) + ); + assert_eq!( + config.wechat_pay_notify_url.as_deref(), + Some("https://api.example.com/api/profile/recharge/wechat/notify") + ); + assert_eq!( + config.wechat_pay_platform_public_key_path.as_deref(), + Some(std::path::Path::new("certs/wechatpay_platform.pem")) + ); + assert_eq!( + config.wechat_pay_platform_serial_no.as_deref(), + Some("platform-serial-001") + ); + + unsafe { + std::env::remove_var("WECHAT_PAY_ENABLED"); + std::env::remove_var("WECHAT_PAY_PROVIDER"); + std::env::remove_var("WECHAT_PAY_MCH_ID"); + std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO"); + std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH"); + std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"); + std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO"); + std::env::remove_var("WECHAT_PAY_API_V3_KEY"); + std::env::remove_var("WECHAT_PAY_NOTIFY_URL"); + } + } + #[test] fn from_env_ignores_zero_spacetime_pool_size() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 5f777f7b..ac324753 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -75,6 +75,7 @@ mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; mod wechat_auth; +mod wechat_pay; mod wechat_provider; mod work_author; mod work_play_tracking; diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8d0afcd9..f58a829e 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -6,15 +6,16 @@ use axum::{ }; use module_runtime::{ AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, - RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, - RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, - RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, - RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, - RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, - RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, - RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord, + RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, + RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, + RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, + RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, + RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, + RuntimeTrackingScopeKind, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -56,8 +57,13 @@ use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ - admin::AuthenticatedAdmin, 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, + wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error}, }; pub async fn get_profile_dashboard( @@ -186,14 +192,15 @@ pub async fn create_profile_recharge_order( let payment_channel = payload .payment_channel .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); - let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let payment_channel = payment_channel.trim().to_string(); + let created_at_micros = current_unix_micros(); let (center, order) = state .spacetime_client() .create_profile_recharge_order( user_id, payload.product_id, - payment_channel, - created_at_micros as i64, + payment_channel.clone(), + created_at_micros, ) .await .map_err(|error| { @@ -203,11 +210,36 @@ pub async fn create_profile_recharge_order( ) })?; + let wechat_mini_program_pay_params = if payment_channel + == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + { + let identity = resolve_wechat_identity_for_payment(&state, &order.user_id) + .await + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + Some( + state + .wechat_pay_client() + .create_mini_program_order(build_wechat_payment_request( + order.order_id.clone(), + order.product_title.clone(), + order.amount_cents, + identity, + )) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?, + ) + } else { + None + }; + Ok(json_success_body( Some(&request_context), CreateProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), + wechat_mini_program_pay_params, }, )) } @@ -750,6 +782,25 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr error.into_response_with_context(Some(request_context)) } +async fn resolve_wechat_identity_for_payment( + state: &AppState, + user_id: &str, +) -> Result { + if let Some(identity) = state + .wechat_auth_service() + .get_identity_by_user_id(user_id) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("读取微信身份失败:{error}")) + })? + { + return Ok(identity.provider_uid); + } + + Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")) +} + fn build_profile_recharge_center_response( record: RuntimeProfileRechargeCenterRecord, ) -> ProfileRechargeCenterResponse { @@ -825,6 +876,7 @@ fn build_profile_recharge_order_response( status: record.status.as_str().to_string(), payment_channel: record.payment_channel, paid_at: record.paid_at, + provider_transaction_id: record.provider_transaction_id, created_at: record.created_at, points_delta: record.points_delta, membership_expires_at: record.membership_expires_at, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 8b5079a3..ad730042 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -30,6 +30,7 @@ use time::OffsetDateTime; use tracing::{info, warn}; use crate::config::AppConfig; +use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error}; use crate::wechat_provider::build_wechat_provider; const ADMIN_ROLE: &str = "admin"; @@ -55,6 +56,7 @@ pub struct AppState { wechat_auth_state_service: WechatAuthStateService, wechat_auth_service: WechatAuthService, wechat_provider: WechatProvider, + wechat_pay_client: WechatPayClient, #[cfg_attr(not(test), allow(dead_code))] ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, @@ -110,6 +112,7 @@ pub enum AppStateInitError { RefreshCookie(RefreshCookieError), AuthStore(String), SmsProvider(SmsProviderError), + WechatPay(String), Oss(OssError), Llm(LlmError), } @@ -174,6 +177,8 @@ impl AppState { WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_provider = build_wechat_provider(&config); + let wechat_pay_client = + WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?; let refresh_session_service = RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days); // AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。 @@ -206,6 +211,7 @@ impl AppState { wechat_auth_state_service, wechat_auth_service, wechat_provider, + wechat_pay_client, ai_task_service, spacetime_client, llm_client, @@ -454,6 +460,10 @@ impl AppState { &self.wechat_provider } + pub fn wechat_pay_client(&self) -> &WechatPayClient { + &self.wechat_pay_client + } + #[cfg_attr(not(test), allow(dead_code))] pub fn ai_task_service(&self) -> &AiTaskService { &self.ai_task_service @@ -860,7 +870,7 @@ impl fmt::Display for AppStateInitError { match self { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), - Self::AuthStore(error) => write!(f, "{error}"), + Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"), Self::SmsProvider(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"), Self::Llm(error) => write!(f, "{error}"), diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs new file mode 100644 index 00000000..1c87a7ff --- /dev/null +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -0,0 +1,780 @@ +use std::{fs, path::Path, sync::Arc}; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use bytes::Bytes; +use ring::{ + aead, + rand::{SecureRandom, SystemRandom}, + signature, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use shared_contracts::runtime::WechatMiniProgramPayParamsResponse; +use shared_kernel::offset_datetime_to_unix_micros; +use time::OffsetDateTime; +use tracing::{info, warn}; + +use crate::{http_error::AppError, state::AppState}; + +const WECHAT_PAY_PROVIDER_MOCK: &str = "mock"; +const WECHAT_PAY_PROVIDER_REAL: &str = "real"; +const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048"; +const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA"; +const WECHAT_PAY_NOTIFY_SUCCESS: &str = ""; + +#[derive(Clone, Debug)] +pub enum WechatPayClient { + Disabled, + Mock, + Real(Arc), +} + +#[derive(Clone, Debug)] +pub struct RealWechatPayClient { + client: reqwest::Client, + app_id: String, + mch_id: String, + merchant_serial_no: String, + private_key: Arc, + platform_public_key_der: Vec, + platform_serial_no: String, + api_v3_key: String, + notify_url: String, + jsapi_endpoint: String, +} + +#[derive(Clone, Debug)] +pub struct WechatMiniProgramOrderRequest { + pub order_id: String, + pub description: String, + pub amount_cents: u64, + pub payer_openid: String, +} + +#[derive(Clone, Debug)] +pub struct WechatPayNotifyOrder { + pub out_trade_no: String, + pub transaction_id: Option, + pub trade_state: String, + pub success_time: Option, +} + +#[derive(Debug)] +pub enum WechatPayError { + Disabled, + InvalidConfig(String), + InvalidRequest(String), + RequestFailed(String), + Upstream(String), + Deserialize(String), + Crypto(String), + InvalidSignature, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct WechatJsapiOrderRequest<'a> { + appid: &'a str, + mchid: &'a str, + description: &'a str, + out_trade_no: &'a str, + notify_url: &'a str, + amount: WechatJsapiAmount, + payer: WechatJsapiPayer<'a>, +} + +#[derive(Serialize)] +struct WechatJsapiAmount { + total: i64, + currency: &'static str, +} + +#[derive(Serialize)] +struct WechatJsapiPayer<'a> { + openid: &'a str, +} + +#[derive(Deserialize)] +struct WechatJsapiOrderResponse { + prepay_id: Option, + code: Option, + message: Option, +} + +#[derive(Deserialize)] +struct WechatPayNotifyBody { + #[serde(default)] + resource: Option, +} + +#[derive(Deserialize)] +struct WechatPayNotifyResource { + ciphertext: String, + nonce: String, + #[serde(default)] + associated_data: Option, +} + +#[derive(Deserialize)] +struct WechatPayTransactionResource { + out_trade_no: String, + #[serde(default)] + transaction_id: Option, + trade_state: String, + #[serde(default)] + success_time: Option, +} + +impl WechatPayClient { + pub fn from_config(config: &crate::config::AppConfig) -> Result { + if !config.wechat_pay_enabled { + return Ok(Self::Disabled); + } + + if config + .wechat_pay_provider + .trim() + .eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK) + { + return Ok(Self::Mock); + } + + if !config + .wechat_pay_provider + .trim() + .eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL) + { + return Err(WechatPayError::InvalidConfig( + "WECHAT_PAY_PROVIDER 仅支持 mock 或 real".to_string(), + )); + } + + let app_id = config + .wechat_mini_program_app_id + .as_ref() + .or(config.wechat_app_id.as_ref()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))? + .to_string(); + let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?; + let merchant_serial_no = required_config( + config.wechat_pay_merchant_serial_no.as_deref(), + "WECHAT_PAY_MERCHANT_SERIAL_NO", + )?; + let private_key_pem = read_private_key_pem( + config.wechat_pay_private_key_pem.as_deref(), + config.wechat_pay_private_key_path.as_deref(), + )?; + let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?); + let platform_public_key_pem = read_pem( + config.wechat_pay_platform_public_key_pem.as_deref(), + config.wechat_pay_platform_public_key_path.as_deref(), + "WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置", + "读取微信支付平台公钥失败", + )?; + let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?; + let platform_serial_no = required_config( + config.wechat_pay_platform_serial_no.as_deref(), + "WECHAT_PAY_PLATFORM_SERIAL_NO", + )?; + let api_v3_key = required_config( + config.wechat_pay_api_v3_key.as_deref(), + "WECHAT_PAY_API_V3_KEY", + )?; + if api_v3_key.as_bytes().len() != 32 { + return Err(WechatPayError::InvalidConfig( + "WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(), + )); + } + let notify_url = required_config( + config.wechat_pay_notify_url.as_deref(), + "WECHAT_PAY_NOTIFY_URL", + )?; + let jsapi_endpoint = normalize_required_url( + &config.wechat_pay_jsapi_endpoint, + "WECHAT_PAY_JSAPI_ENDPOINT", + )?; + + Ok(Self::Real(Arc::new(RealWechatPayClient { + client: reqwest::Client::new(), + app_id, + mch_id, + merchant_serial_no, + private_key, + platform_public_key_der, + platform_serial_no, + api_v3_key, + notify_url, + jsapi_endpoint, + }))) + } + + pub async fn create_mini_program_order( + &self, + request: WechatMiniProgramOrderRequest, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(build_mock_pay_params(&request.order_id)), + Self::Real(client) => client.create_mini_program_order(request).await, + } + } + + pub fn parse_notify( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => parse_mock_notify(body), + Self::Real(client) => client.parse_notify(headers, body), + } + } +} + +impl RealWechatPayClient { + async fn create_mini_program_order( + &self, + request: WechatMiniProgramOrderRequest, + ) -> Result { + let amount_total = i64::try_from(request.amount_cents) + .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; + let body = serde_json::to_string(&WechatJsapiOrderRequest { + appid: &self.app_id, + mchid: &self.mch_id, + description: &request.description, + out_trade_no: &request.order_id, + notify_url: &self.notify_url, + amount: WechatJsapiAmount { + total: amount_total, + currency: "CNY", + }, + payer: WechatJsapiPayer { + openid: &request.payer_openid, + }, + }) + .map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?; + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = self.build_authorization( + "POST", + "/v3/pay/transactions/jsapi", + ×tamp, + &nonce, + &body, + )?; + let response = self + .client + .post(&self.jsapi_endpoint) + .header("Authorization", authorization) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map_err(|error| { + WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}")) + })?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}")) + })?; + let payload = + serde_json::from_str::(&response_text).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应解析失败:{error}")) + })?; + + if !status.is_success() { + return Err(WechatPayError::Upstream(format!( + "微信支付 JSAPI 下单失败:{}", + payload + .message + .or(payload.code) + .unwrap_or_else(|| format!("HTTP {status}")) + ))); + } + + let prepay_id = payload + .prepay_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WechatPayError::Upstream("微信支付未返回 prepay_id".to_string()))?; + self.build_pay_params(&prepay_id) + } + + fn build_authorization( + &self, + method: &str, + canonical_url: &str, + timestamp: &str, + nonce: &str, + body: &str, + ) -> Result { + let message = format!("{method}\n{canonical_url}\n{timestamp}\n{nonce}\n{body}\n"); + let signature = self.sign_message(&message)?; + Ok(format!( + "{WECHAT_PAY_BODY_SIGNATURE_METHOD} mchid=\"{}\",nonce_str=\"{}\",timestamp=\"{}\",serial_no=\"{}\",signature=\"{}\"", + self.mch_id, nonce, timestamp, self.merchant_serial_no, signature + )) + } + + fn build_pay_params( + &self, + prepay_id: &str, + ) -> Result { + let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce_str = create_nonce()?; + let package = format!("prepay_id={prepay_id}"); + let message = format!( + "{}\n{}\n{}\n{}\n", + self.app_id, time_stamp, nonce_str, package + ); + let pay_sign = self.sign_message(&message)?; + + Ok(WechatMiniProgramPayParamsResponse { + time_stamp, + nonce_str, + package, + sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(), + pay_sign, + }) + } + + fn parse_notify( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + self.verify_notify_signature(headers, body)?; + let notify = serde_json::from_slice::(body).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}")) + })?; + let resource = notify.resource.ok_or_else(|| { + WechatPayError::InvalidRequest("微信支付通知缺少 resource".to_string()) + })?; + let plain_text = decrypt_aes_256_gcm( + self.api_v3_key.as_bytes(), + resource.nonce.as_bytes(), + resource.associated_data.as_deref().unwrap_or("").as_bytes(), + resource.ciphertext.as_str(), + )?; + let transaction = serde_json::from_slice::(&plain_text) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}")) + })?; + + Ok(WechatPayNotifyOrder { + out_trade_no: transaction.out_trade_no, + transaction_id: transaction + .transaction_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + trade_state: transaction.trade_state, + success_time: transaction.success_time, + }) + } + + fn verify_notify_signature( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result<(), WechatPayError> { + let timestamp = read_required_header(headers, "Wechatpay-Timestamp")?; + let nonce = read_required_header(headers, "Wechatpay-Nonce")?; + let signature = read_required_header(headers, "Wechatpay-Signature")?; + let serial = read_required_header(headers, "Wechatpay-Serial")?; + if serial != self.platform_serial_no { + return Err(WechatPayError::InvalidSignature); + } + + let message = format!( + "{}\n{}\n{}\n", + timestamp, + nonce, + String::from_utf8_lossy(body) + ); + let signature_bytes = BASE64_STANDARD + .decode(signature) + .map_err(|_| WechatPayError::InvalidSignature)?; + let public_key = signature::UnparsedPublicKey::new( + &signature::RSA_PKCS1_2048_8192_SHA256, + &self.platform_public_key_der, + ); + public_key + .verify(message.as_bytes(), &signature_bytes) + .map_err(|_| WechatPayError::InvalidSignature) + } + + fn sign_message(&self, message: &str) -> Result { + let rng = SystemRandom::new(); + let mut signature = vec![0_u8; self.private_key.public().modulus_len()]; + self.private_key + .sign( + &signature::RSA_PKCS1_SHA256, + &rng, + message.as_bytes(), + &mut signature, + ) + .map_err(|_| WechatPayError::Crypto("微信支付签名失败".to_string()))?; + Ok(BASE64_STANDARD.encode(signature)) + } +} + +pub async fn handle_wechat_pay_notify( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result<&'static str, AppError> { + let notify = state + .wechat_pay_client() + .parse_notify(&headers, &body) + .map_err(map_wechat_pay_notify_error)?; + if notify.trade_state != "SUCCESS" { + info!( + order_id = notify.out_trade_no.as_str(), + trade_state = notify.trade_state.as_str(), + "收到非成功微信支付通知" + ); + return Ok(WECHAT_PAY_NOTIFY_SUCCESS); + } + + let paid_at_micros = notify + .success_time + .as_deref() + .and_then(|value| shared_kernel::parse_rfc3339(value).ok()) + .map(offset_datetime_to_unix_micros) + .unwrap_or_else(current_unix_micros); + + state + .spacetime_client() + .mark_profile_recharge_order_paid( + notify.out_trade_no.clone(), + paid_at_micros, + notify.transaction_id.clone(), + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("确认微信支付订单失败:{error}")) + })?; + info!( + order_id = notify.out_trade_no.as_str(), + "微信支付通知已确认订单入账" + ); + + Ok(WECHAT_PAY_NOTIFY_SUCCESS) +} + +pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { + match error { + WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付暂未启用") + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidConfig(message) => { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })) + } + WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("微信支付通知签名无效") + .with_details(json!({ "provider": "wechat_pay" })), + } +} + +pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError { + crate::state::AppStateInitError::WechatPay(error.to_string()) +} + +pub fn build_wechat_payment_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_openid: String, +) -> WechatMiniProgramOrderRequest { + WechatMiniProgramOrderRequest { + order_id, + description: format!("百梦 - {product_title}"), + amount_cents, + payer_openid, + } +} + +pub fn current_unix_micros() -> i64 { + let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + i64::try_from(value).unwrap_or(i64::MAX) +} + +fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { + warn!(error = %error, "微信支付通知处理失败"); + map_wechat_pay_error(error) +} + +fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { + let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce_str = "mock-nonce".to_string(); + let package = format!("prepay_id=mock-{order_id}"); + let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes()); + + WechatMiniProgramPayParamsResponse { + time_stamp, + nonce_str, + package, + sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(), + pay_sign, + } +} + +fn parse_mock_notify(body: &[u8]) -> Result { + let value = serde_json::from_slice::(body).map_err(|error| { + WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) + })?; + Ok(WechatPayNotifyOrder { + out_trade_no: value + .get("outTradeNo") + .or_else(|| value.get("out_trade_no")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string()) + })? + .to_string(), + transaction_id: value + .get("transactionId") + .or_else(|| value.get("transaction_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + trade_state: value + .get("tradeState") + .or_else(|| value.get("trade_state")) + .and_then(Value::as_str) + .unwrap_or("SUCCESS") + .to_string(), + success_time: value + .get("successTime") + .or_else(|| value.get("success_time")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + }) +} + +fn required_config(value: Option<&str>, key: &str) -> Result { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置"))) +} + +fn normalize_required_url(value: &str, key: &str) -> Result { + let value = value.trim(); + if value.starts_with("https://") { + return Ok(value.to_string()); + } + + Err(WechatPayError::InvalidConfig(format!( + "{key} 必须是 https 地址" + ))) +} + +fn read_private_key_pem( + inline_pem: Option<&str>, + path: Option<&Path>, +) -> Result { + read_pem( + inline_pem, + path, + "WECHAT_PAY_PRIVATE_KEY_PEM 或 WECHAT_PAY_PRIVATE_KEY_PATH 未配置", + "读取微信支付私钥失败", + ) +} + +fn read_pem( + inline_pem: Option<&str>, + path: Option<&Path>, + missing_message: &str, + read_error_prefix: &str, +) -> Result { + if let Some(value) = inline_pem.map(str::trim).filter(|value| !value.is_empty()) { + return Ok(value.replace("\\n", "\n")); + } + let Some(path) = path else { + return Err(WechatPayError::InvalidConfig(missing_message.to_string())); + }; + fs::read_to_string(path).map_err(|error| { + WechatPayError::InvalidConfig(format!("{read_error_prefix}:{}:{error}", path.display())) + }) +} + +fn parse_rsa_private_key(pem: &str) -> Result { + let (label, der) = parse_single_pem_block(pem)?; + match label.as_str() { + "PRIVATE KEY" => signature::RsaKeyPair::from_pkcs8(&der), + "RSA PRIVATE KEY" => signature::RsaKeyPair::from_der(&der), + _ => { + return Err(WechatPayError::InvalidConfig( + "微信支付私钥必须是 PRIVATE KEY 或 RSA PRIVATE KEY PEM".to_string(), + )); + } + } + .map_err(|error| WechatPayError::InvalidConfig(format!("微信支付私钥解析失败:{error}"))) +} + +fn parse_public_key_pem(pem: &str) -> Result, WechatPayError> { + let (label, der) = parse_single_pem_block(pem)?; + if label != "PUBLIC KEY" { + return Err(WechatPayError::InvalidConfig( + "微信支付平台公钥必须是 PUBLIC KEY PEM".to_string(), + )); + } + Ok(der) +} + +fn parse_single_pem_block(pem: &str) -> Result<(String, Vec), WechatPayError> { + let mut label: Option = None; + let mut content = String::new(); + for line in pem.lines().map(str::trim).filter(|line| !line.is_empty()) { + if let Some(raw_label) = line + .strip_prefix("-----BEGIN ") + .and_then(|value| value.strip_suffix("-----")) + { + label = Some(raw_label.trim().to_string()); + continue; + } + if line.starts_with("-----END ") { + break; + } + if label.is_some() { + content.push_str(line); + } + } + let label = label + .ok_or_else(|| WechatPayError::InvalidConfig("微信支付 PEM 缺少 BEGIN 标记".to_string()))?; + let der = BASE64_STANDARD + .decode(content) + .map_err(|_| WechatPayError::InvalidConfig("微信支付 PEM base64 无效".to_string()))?; + if der.is_empty() { + return Err(WechatPayError::InvalidConfig( + "微信支付 PEM 内容为空".to_string(), + )); + } + Ok((label, der)) +} + +fn create_nonce() -> Result { + let mut bytes = [0_u8; 16]; + SystemRandom::new() + .fill(&mut bytes) + .map_err(|_| WechatPayError::Crypto("生成微信支付 nonce 失败".to_string()))?; + Ok(hex_encode(&bytes)) +} + +fn decrypt_aes_256_gcm( + key: &[u8], + nonce: &[u8], + associated_data: &[u8], + ciphertext_base64: &str, +) -> Result, WechatPayError> { + let mut ciphertext = BASE64_STANDARD + .decode(ciphertext_base64) + .map_err(|_| WechatPayError::Crypto("微信支付通知密文 base64 无效".to_string()))?; + if ciphertext.len() < aead::AES_256_GCM.tag_len() { + return Err(WechatPayError::Crypto( + "微信支付通知密文长度无效".to_string(), + )); + } + let nonce = aead::Nonce::try_assume_unique_for_key(nonce) + .map_err(|_| WechatPayError::Crypto("微信支付通知 nonce 长度无效".to_string()))?; + let key = aead::UnboundKey::new(&aead::AES_256_GCM, key) + .map_err(|_| WechatPayError::Crypto("微信支付通知解密 key 无效".to_string()))?; + let plain_text = aead::LessSafeKey::new(key) + .open_in_place( + nonce, + aead::Aad::from(associated_data), + ciphertext.as_mut_slice(), + ) + .map_err(|_| WechatPayError::Crypto("微信支付通知认证或解密失败".to_string()))?; + Ok(plain_text.to_vec()) +} + +fn read_required_header<'a>( + headers: &'a HeaderMap, + name: &'static str, +) -> Result<&'a str, WechatPayError> { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(WechatPayError::InvalidSignature) +} + +fn hex_sha256(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(content); + hex_encode(&hasher.finalize()) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +impl std::fmt::Display for WechatPayError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disabled => formatter.write_str("微信支付暂未启用"), + Self::InvalidConfig(message) + | Self::InvalidRequest(message) + | Self::RequestFailed(message) + | Self::Upstream(message) + | Self::Deserialize(message) + | Self::Crypto(message) => formatter.write_str(message), + Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"), + } + } +} + +impl std::error::Error for WechatPayError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_pay_params_use_request_payment_shape() { + let params = build_mock_pay_params("recharge:user:1:points_60"); + + assert!(!params.time_stamp.is_empty()); + assert_eq!(params.sign_type, "RSA"); + assert!(params.package.starts_with("prepay_id=mock-")); + assert!(!params.pay_sign.is_empty()); + } + + #[test] + fn parse_mock_notify_defaults_success_state() { + let notify = + parse_mock_notify(br#"{"outTradeNo":"order-1"}"#).expect("mock notify should parse"); + + assert_eq!(notify.out_trade_no, "order-1"); + assert_eq!(notify.transaction_id, None); + assert_eq!(notify.trade_state, "SUCCESS"); + } +} diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index 188c0389..eaa6d780 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -118,6 +118,14 @@ pub struct WechatIdentityProfile { pub avatar_url: Option, } +/// 已绑定微信身份快照。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatIdentityRecord { + pub user_id: String, + pub provider_uid: String, + pub provider_union_id: Option, +} + /// 微信授权 state 快照。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatAuthStateRecord { diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index a855ab96..815be0e7 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -797,6 +797,13 @@ impl WechatAuthService { created: true, }) } + + pub fn get_identity_by_user_id( + &self, + user_id: &str, + ) -> Result, WechatAuthError> { + self.store.get_wechat_identity_by_user_id(user_id) + } } impl AuthUserService { @@ -1342,6 +1349,29 @@ impl InMemoryAuthStore { .map(|stored| stored.user.clone())) } + fn get_wechat_identity_by_user_id( + &self, + user_id: &str, + ) -> Result, WechatAuthError> { + let state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; + let Some(identity) = state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == user_id.trim()) + else { + return Ok(None); + }; + + Ok(Some(WechatIdentityRecord { + user_id: identity.user_id.clone(), + provider_uid: identity.provider_uid.clone(), + provider_union_id: identity.provider_union_id.clone(), + })) + } + fn refresh_wechat_identity_profile( &self, user_id: &str, diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index f43e2e77..53336179 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -190,8 +190,9 @@ pub fn build_runtime_profile_recharge_order_record( amount_cents: snapshot.amount_cents, status: snapshot.status, payment_channel: snapshot.payment_channel, - paid_at: format_utc_micros(snapshot.paid_at_micros), + paid_at: snapshot.paid_at_micros.map(format_utc_micros), paid_at_micros: snapshot.paid_at_micros, + provider_transaction_id: snapshot.provider_transaction_id, created_at: format_utc_micros(snapshot.created_at_micros), created_at_micros: snapshot.created_at_micros, points_delta: snapshot.points_delta, diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 1a2f10c5..90236501 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -265,6 +265,20 @@ pub fn build_runtime_profile_recharge_order_create_input( }) } +pub fn build_runtime_profile_recharge_order_paid_input( + order_id: String, + paid_at_micros: i64, + provider_transaction_id: Option, +) -> Result { + let order_id = + normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?; + Ok(RuntimeProfileRechargeOrderPaidInput { + order_id, + paid_at_micros, + provider_transaction_id: provider_transaction_id.and_then(normalize_required_string), + }) +} + pub fn build_runtime_profile_feedback_submission_input( user_id: String, description: String, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 67d280c2..88f261c2 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -33,6 +33,7 @@ pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1; pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; +pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp"; pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10; pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200; pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40; @@ -951,13 +952,21 @@ impl RuntimeProfileMembershipTier { #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { + Pending, Paid, + Failed, + Closed, + Refunded, } impl RuntimeProfileRechargeOrderStatus { pub fn as_str(&self) -> &'static str { match self { + Self::Pending => "pending", Self::Paid => "paid", + Self::Failed => "failed", + Self::Closed => "closed", + Self::Refunded => "refunded", } } } @@ -1009,7 +1018,8 @@ pub struct RuntimeProfileRechargeOrderSnapshot { pub amount_cents: u64, pub status: RuntimeProfileRechargeOrderStatus, pub payment_channel: String, - pub paid_at_micros: i64, + pub paid_at_micros: Option, + pub provider_transaction_id: Option, pub created_at_micros: i64, pub points_delta: i64, pub membership_expires_at_micros: Option, @@ -1059,6 +1069,14 @@ pub struct RuntimeProfileRechargeOrderCreateInput { pub created_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderPaidInput { + pub order_id: String, + pub paid_at_micros: i64, + pub provider_transaction_id: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerEntrySnapshot { @@ -1471,8 +1489,9 @@ pub struct RuntimeProfileRechargeOrderRecord { pub amount_cents: u64, pub status: RuntimeProfileRechargeOrderStatus, pub payment_channel: String, - pub paid_at: String, - pub paid_at_micros: i64, + pub paid_at: Option, + pub paid_at_micros: Option, + pub provider_transaction_id: Option, pub created_at: String, pub created_at_micros: i64, pub points_delta: i64, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 520d6d5a..121194c6 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -72,6 +72,7 @@ pub enum RuntimeProfileFieldError { TaskDisabled, TaskNotClaimable, TaskAlreadyClaimed, + MissingOrderId, MissingProductId, MissingWorldKey, MissingBottomTab, @@ -133,6 +134,7 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::TaskDisabled => f.write_str("任务已停用"), Self::TaskNotClaimable => f.write_str("任务尚未达成"), Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), + Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), 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 d0a56bd6..b27b6410 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -222,12 +222,23 @@ pub struct ProfileRechargeOrderResponse { pub amount_cents: u64, pub status: String, pub payment_channel: String, - pub paid_at: String, + pub paid_at: Option, + pub provider_transaction_id: Option, pub created_at: String, pub points_delta: i64, pub membership_expires_at: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WechatMiniProgramPayParamsResponse { + pub time_stamp: String, + pub nonce_str: String, + pub package: String, + pub sign_type: String, + pub pay_sign: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileRechargeCenterResponse { @@ -253,6 +264,8 @@ pub struct CreateProfileRechargeOrderRequest { pub struct CreateProfileRechargeOrderResponse { pub order: ProfileRechargeOrderResponse, pub center: ProfileRechargeCenterResponse, + #[serde(default)] + pub wechat_mini_program_pay_params: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index dcb83a7e..a530b4c5 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -176,6 +176,18 @@ impl From } } +impl From + for RuntimeProfileRechargeOrderPaidInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderPaidInput) -> Self { + Self { + order_id: input.order_id, + paid_at_micros: input.paid_at_micros, + provider_transaction_id: input.provider_transaction_id, + } + } +} + impl From for RuntimeProfileFeedbackSubmissionInput { @@ -2217,6 +2229,7 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot( status: map_runtime_profile_recharge_order_status_back(snapshot.status), payment_channel: snapshot.payment_channel, paid_at_micros: snapshot.paid_at_micros, + provider_transaction_id: snapshot.provider_transaction_id, created_at_micros: snapshot.created_at_micros, points_delta: snapshot.points_delta, membership_expires_at_micros: snapshot.membership_expires_at_micros, @@ -5026,9 +5039,21 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back( value: crate::module_bindings::RuntimeProfileRechargeOrderStatus, ) -> module_runtime::RuntimeProfileRechargeOrderStatus { match value { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Pending => { + module_runtime::RuntimeProfileRechargeOrderStatus::Pending + } crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => { module_runtime::RuntimeProfileRechargeOrderStatus::Paid } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Failed => { + module_runtime::RuntimeProfileRechargeOrderStatus::Failed + } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Closed => { + module_runtime::RuntimeProfileRechargeOrderStatus::Closed + } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded => { + module_runtime::RuntimeProfileRechargeOrderStatus::Refunded + } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs new file mode 100644 index 00000000..f412f184 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_profile_recharge_order_paid_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; +use super::runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkProfileRechargeOrderPaidAndReturnArgs { + pub input: RuntimeProfileRechargeOrderPaidInput, +} + +impl __sdk::InModule for MarkProfileRechargeOrderPaidAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_profile_recharge_order_paid_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_profile_recharge_order_paid_and_return { + fn mark_profile_recharge_order_paid_and_return( + &self, + input: RuntimeProfileRechargeOrderPaidInput, + ) { + self.mark_profile_recharge_order_paid_and_return_then(input, |_, _| {}); + } + + fn mark_profile_recharge_order_paid_and_return_then( + &self, + input: RuntimeProfileRechargeOrderPaidInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_profile_recharge_order_paid_and_return for super::RemoteProcedures { + fn mark_profile_recharge_order_paid_and_return_then( + &self, + input: RuntimeProfileRechargeOrderPaidInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( + "mark_profile_recharge_order_paid_and_return", + MarkProfileRechargeOrderPaidAndReturnArgs { 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 8cddf9d4..13fa85bd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -367,6 +367,7 @@ pub mod list_puzzle_works_procedure; pub mod list_square_hole_works_procedure; pub mod list_visual_novel_runtime_history_procedure; pub mod list_visual_novel_works_procedure; +pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; pub mod match_3_d_agent_message_submit_input_type; @@ -616,6 +617,7 @@ pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; pub mod runtime_profile_recharge_order_create_input_type; +pub mod runtime_profile_recharge_order_paid_input_type; pub mod runtime_profile_recharge_order_snapshot_type; pub mod runtime_profile_recharge_order_status_type; pub mod runtime_profile_recharge_product_kind_type; @@ -1177,6 +1179,7 @@ pub use list_puzzle_works_procedure::list_puzzle_works; pub use list_square_hole_works_procedure::list_square_hole_works; pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history; pub use list_visual_novel_works_procedure::list_visual_novel_works; +pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput; @@ -1426,6 +1429,7 @@ pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCe pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput; +pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput; pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs index fbca5da3..e8996616 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs @@ -18,7 +18,8 @@ pub struct ProfileRechargeOrder { pub amount_cents: u64, pub status: RuntimeProfileRechargeOrderStatus, pub payment_channel: String, - pub paid_at: __sdk::Timestamp, + pub paid_at: Option<__sdk::Timestamp>, + pub provider_transaction_id: Option, pub created_at: __sdk::Timestamp, pub points_delta: i64, pub membership_expires_at: Option<__sdk::Timestamp>, @@ -41,7 +42,8 @@ pub struct ProfileRechargeOrderCols { pub status: __sdk::__query_builder::Col, pub payment_channel: __sdk::__query_builder::Col, - pub paid_at: __sdk::__query_builder::Col, + pub paid_at: __sdk::__query_builder::Col>, + pub provider_transaction_id: __sdk::__query_builder::Col>, pub created_at: __sdk::__query_builder::Col, pub points_delta: __sdk::__query_builder::Col, pub membership_expires_at: @@ -61,6 +63,10 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeOrder { status: __sdk::__query_builder::Col::new(table_name, "status"), payment_channel: __sdk::__query_builder::Col::new(table_name, "payment_channel"), paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"), + provider_transaction_id: __sdk::__query_builder::Col::new( + table_name, + "provider_transaction_id", + ), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), points_delta: __sdk::__query_builder::Col::new(table_name, "points_delta"), membership_expires_at: __sdk::__query_builder::Col::new( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_paid_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_paid_input_type.rs new file mode 100644 index 00000000..ecf49781 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_paid_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 RuntimeProfileRechargeOrderPaidInput { + pub order_id: String, + pub paid_at_micros: i64, + pub provider_transaction_id: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderPaidInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_snapshot_type.rs index d2beea3b..c7cfdf09 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_snapshot_type.rs @@ -18,7 +18,8 @@ pub struct RuntimeProfileRechargeOrderSnapshot { pub amount_cents: u64, pub status: RuntimeProfileRechargeOrderStatus, pub payment_channel: String, - pub paid_at_micros: i64, + pub paid_at_micros: Option, + pub provider_transaction_id: Option, pub created_at_micros: i64, pub points_delta: i64, pub membership_expires_at_micros: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_status_type.rs index 3a1f01a8..d302f109 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_status_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_status_type.rs @@ -8,7 +8,15 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] #[derive(Copy, Eq, Hash)] pub enum RuntimeProfileRechargeOrderStatus { + Pending, + Paid, + + Failed, + + Closed, + + Refunded, } impl __sdk::InModule for RuntimeProfileRechargeOrderStatus { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index cdd9ad7a..076aef6c 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -268,6 +268,42 @@ impl SpacetimeClient { .await } + pub async fn mark_profile_recharge_order_paid( + &self, + order_id: String, + paid_at_micros: i64, + provider_transaction_id: Option, + ) -> Result< + ( + RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, + ), + SpacetimeClientError, + > { + let procedure_input = module_runtime::build_runtime_profile_recharge_order_paid_input( + order_id, + paid_at_micros, + provider_transaction_id, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .mark_profile_recharge_order_paid_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_order_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_profile_feedback( &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 28b9df3f..0447d739 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1151,6 +1151,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert_with(|| serde_json::Value::String("{}".to_string())); } } + if table_name == "profile_recharge_order" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:真实微信支付接入后才有平台交易号,旧迁移包按未回填处理。 + object + .entry("provider_transaction_id".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 2d27ca06..8a118138 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -336,6 +336,7 @@ pub struct ProfileMembership { btree(columns = [user_id, created_at]) ) )] +#[derive(Clone)] pub struct ProfileRechargeOrder { #[primary_key] pub(crate) order_id: String, @@ -346,7 +347,10 @@ pub struct ProfileRechargeOrder { pub(crate) amount_cents: u64, pub(crate) status: RuntimeProfileRechargeOrderStatus, pub(crate) payment_channel: String, - pub(crate) paid_at: Timestamp, + #[default(None::)] + pub(crate) paid_at: Option, + #[default(None::)] + pub(crate) provider_transaction_id: Option, pub(crate) created_at: Timestamp, pub(crate) points_delta: i64, pub(crate) membership_expires_at: Option, @@ -767,7 +771,6 @@ pub fn get_profile_recharge_center( } } -// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。 #[spacetimedb::procedure] pub fn create_profile_recharge_order_and_return( ctx: &mut ProcedureContext, @@ -789,6 +792,27 @@ pub fn create_profile_recharge_order_and_return( } } +#[spacetimedb::procedure] +pub fn mark_profile_recharge_order_paid_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeOrderPaidInput, +) -> RuntimeProfileRechargeCenterProcedureResult { + match ctx.try_with_tx(|tx| mark_profile_recharge_order_paid_record(tx, input.clone())) { + Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { + ok: true, + record: Some(record), + order: Some(order), + error_message: None, + }, + Err(message) => RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_profile_feedback_and_return( ctx: &mut ProcedureContext, @@ -2049,36 +2073,24 @@ fn create_profile_recharge_order_record( let product = runtime_profile_recharge_product_by_id(&validated_input.product_id) .ok_or_else(|| "recharge.product_id 不存在".to_string())?; let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - - let (points_delta, membership_expires_at) = match product.kind { - RuntimeProfileRechargeProductKind::Points => { - let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id); - let points_delta = - resolve_runtime_profile_points_recharge_delta(&product, has_recharged); - apply_profile_wallet_delta( - ctx, - &validated_input.user_id, - points_delta, - RuntimeProfileWalletLedgerSourceType::PointsRecharge, - &build_runtime_profile_recharge_wallet_ledger_id( - &validated_input.user_id, - validated_input.created_at_micros, - &product.product_id, - ), - created_at, - )?; - (points_delta as i64, None) - } - RuntimeProfileRechargeProductKind::Membership => { - let expires_at = apply_profile_membership_purchase( - ctx, - &validated_input.user_id, - product.tier, - product.duration_days, - created_at, - ); - (0, Some(expires_at)) - } + let should_settle_immediately = + validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; + let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately { + let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( + ctx, + &validated_input.user_id, + &product, + validated_input.created_at_micros, + created_at, + )?; + ( + RuntimeProfileRechargeOrderStatus::Paid, + Some(created_at), + points_delta, + membership_expires_at, + ) + } else { + (RuntimeProfileRechargeOrderStatus::Pending, None, 0, None) }; let order = ProfileRechargeOrder { @@ -2092,9 +2104,10 @@ fn create_profile_recharge_order_record( product_title: product.title.clone(), kind: product.kind, amount_cents: product.price_cents, - status: RuntimeProfileRechargeOrderStatus::Paid, + status, payment_channel: validated_input.payment_channel, - paid_at: created_at, + paid_at, + provider_transaction_id: None, created_at, points_delta, membership_expires_at, @@ -2109,6 +2122,106 @@ fn create_profile_recharge_order_record( )) } +fn mark_profile_recharge_order_paid_record( + ctx: &ReducerContext, + input: RuntimeProfileRechargeOrderPaidInput, +) -> Result< + ( + RuntimeProfileRechargeCenterSnapshot, + RuntimeProfileRechargeOrderSnapshot, + ), + String, +> { + let validated_input = build_runtime_profile_recharge_order_paid_input( + input.order_id, + input.paid_at_micros, + input.provider_transaction_id, + ) + .map_err(|error| error.to_string())?; + let mut order = ctx + .db + .profile_recharge_order() + .order_id() + .find(&validated_input.order_id) + .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; + + if order.status == RuntimeProfileRechargeOrderStatus::Paid { + return Ok(( + build_profile_recharge_center_snapshot(ctx, &order.user_id), + build_profile_recharge_order_snapshot_from_row(&order), + )); + } + if order.status != RuntimeProfileRechargeOrderStatus::Pending { + return Err("profile_recharge_order 当前状态不能确认支付".to_string()); + } + + let product = runtime_profile_recharge_product_by_id(&order.product_id) + .ok_or_else(|| "recharge.product_id 不存在".to_string())?; + let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); + let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( + ctx, + &order.user_id, + &product, + order.created_at.to_micros_since_unix_epoch(), + paid_at, + )?; + + ctx.db + .profile_recharge_order() + .order_id() + .delete(&order.order_id); + order.status = RuntimeProfileRechargeOrderStatus::Paid; + order.paid_at = Some(paid_at); + order.provider_transaction_id = validated_input.provider_transaction_id; + order.points_delta = points_delta; + order.membership_expires_at = membership_expires_at; + ctx.db.profile_recharge_order().insert(order.clone()); + + Ok(( + build_profile_recharge_center_snapshot(ctx, &order.user_id), + build_profile_recharge_order_snapshot_from_row(&order), + )) +} + +fn apply_profile_recharge_purchase( + ctx: &ReducerContext, + user_id: &str, + product: &RuntimeProfileRechargeProductSnapshot, + order_created_at_micros: i64, + paid_at: Timestamp, +) -> Result<(i64, Option), String> { + match product.kind { + RuntimeProfileRechargeProductKind::Points => { + let has_recharged = has_profile_points_recharged(ctx, user_id); + let points_delta = + resolve_runtime_profile_points_recharge_delta(product, has_recharged); + apply_profile_wallet_delta( + ctx, + user_id, + points_delta, + RuntimeProfileWalletLedgerSourceType::PointsRecharge, + &build_runtime_profile_recharge_wallet_ledger_id( + user_id, + order_created_at_micros, + &product.product_id, + ), + paid_at, + )?; + Ok((points_delta as i64, None)) + } + RuntimeProfileRechargeProductKind::Membership => { + let expires_at = apply_profile_membership_purchase( + ctx, + user_id, + product.tier, + product.duration_days, + paid_at, + ); + Ok((0, Some(expires_at))) + } + } +} + fn submit_profile_feedback_record( ctx: &ReducerContext, input: RuntimeProfileFeedbackSubmissionInput, @@ -3745,7 +3858,8 @@ fn build_profile_recharge_order_snapshot_from_row( amount_cents: row.amount_cents, status: row.status, payment_channel: row.payment_channel.clone(), - paid_at_micros: row.paid_at.to_micros_since_unix_epoch(), + paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), + provider_transaction_id: row.provider_transaction_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), points_delta: row.points_delta, membership_expires_at_micros: row diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index b4f47d21..e91b9e0f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -17,14 +17,12 @@ import type { PublicUserSummary, } from '../../../packages/shared/src/contracts/auth'; import type { + CreateProfileRechargeOrderResponse, ProfileReferralInviteCenterResponse, ProfileTaskCenterResponse, } from '../../../packages/shared/src/contracts/runtime'; import { AuthUiContext } from '../auth/AuthUiContext'; -import { - ICP_RECORD_NUMBER, - ICP_RECORD_URL, -} from '../common/legalDocuments'; +import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments'; import { RpgEntryHomeView, type RpgEntryHomeViewProps, @@ -41,7 +39,9 @@ const { mockBuildReferralCenter, mockBuildTaskCenter, mockClaimRpgProfileTaskReward, + mockCreateRpgProfileRechargeOrder, mockGetRpgProfileReferralInviteCenter, + mockGetRpgProfileRechargeCenter, mockGetRpgProfileTasks, mockGetRpgProfileWalletLedger, mockRedeemRpgProfileReferralInviteCode, @@ -137,6 +137,88 @@ const { }, center: buildClaimedTaskCenter(), })), + mockGetRpgProfileRechargeCenter: vi.fn(async () => ({ + walletBalance: 0, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [ + { + productId: 'points_60', + title: '60光点', + priceCents: 600, + kind: 'points', + pointsAmount: 60, + bonusPoints: 60, + durationDays: 0, + badgeLabel: '首充双倍', + description: '首充送60光点', + tier: 'normal', + }, + ], + membershipProducts: [ + { + productId: 'member_month', + title: '月卡', + priceCents: 2800, + kind: 'membership', + pointsAmount: 0, + bonusPoints: 0, + durationDays: 30, + badgeLabel: '', + description: '30天会员', + tier: 'month', + }, + ], + benefits: [ + { + benefitName: '免光点回合数', + normalValue: '30', + monthValue: '100', + seasonValue: '100', + yearValue: '100', + }, + ], + latestOrder: null, + hasPointsRecharged: false, + })), + mockCreateRpgProfileRechargeOrder: vi.fn( + async (): Promise => ({ + order: { + orderId: 'order-1', + productId: 'points_60', + productTitle: '60光点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'mock', + paidAt: '2026-04-25T10:00:00Z', + providerTransactionId: null, + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 120, + membershipExpiresAt: null, + }, + center: { + walletBalance: 120, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: true, + }, + }), + ), mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({ center: buildReferralCenter({ invitedUsers: [], @@ -219,85 +301,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward, redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode, - getRpgProfileRechargeCenter: vi.fn(async () => ({ - walletBalance: 0, - membership: { - status: 'normal', - tier: 'normal', - startedAt: null, - expiresAt: null, - updatedAt: null, - }, - pointProducts: [ - { - productId: 'points_60', - title: '60光点', - priceCents: 600, - kind: 'points', - pointsAmount: 60, - bonusPoints: 60, - durationDays: 0, - badgeLabel: '首充双倍', - description: '首充送60光点', - tier: 'normal', - }, - ], - membershipProducts: [ - { - productId: 'member_month', - title: '月卡', - priceCents: 2800, - kind: 'membership', - pointsAmount: 0, - bonusPoints: 0, - durationDays: 30, - badgeLabel: '', - description: '30天会员', - tier: 'month', - }, - ], - benefits: [ - { - benefitName: '免光点回合数', - normalValue: '30', - monthValue: '100', - seasonValue: '100', - yearValue: '100', - }, - ], - latestOrder: null, - hasPointsRecharged: false, - })), - createRpgProfileRechargeOrder: vi.fn(async () => ({ - order: { - orderId: 'order-1', - productId: 'points_60', - productTitle: '60光点', - kind: 'points', - amountCents: 600, - status: 'paid', - paymentChannel: 'mock', - paidAt: '2026-04-25T10:00:00Z', - createdAt: '2026-04-25T10:00:00Z', - pointsDelta: 120, - membershipExpiresAt: null, - }, - center: { - walletBalance: 120, - membership: { - status: 'normal', - tier: 'normal', - startedAt: null, - expiresAt: null, - updatedAt: null, - }, - pointProducts: [], - membershipProducts: [], - benefits: [], - latestOrder: null, - hasPointsRecharged: true, - }, - })), + getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter, + createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, })); vi.mock('../ResolvedAssetImage', () => ({ @@ -906,6 +911,106 @@ test('opens wallet ledger modal from narrative coin card', async () => { expect(screen.getByText('+30')).toBeTruthy(); }); +test('profile recharge modal buys points through mock channel outside mini program', async () => { + const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); + + renderProfileView(onRechargeSuccess); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + + expect(await screen.findByText('账户充值')).toBeTruthy(); + expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: /60光点/u })); + + await waitFor(() => { + expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'points_60', + 'mock', + ); + }); + expect(await screen.findByText('已到账')).toBeTruthy(); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); +}); + +test('profile recharge modal posts requestPayment params in mini program web-view', async () => { + const user = userEvent.setup(); + window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); + const navigateTo = vi.fn((options: { url: string }) => { + const url = new URL(`https://mini.test${options.url}`); + const requestId = url.searchParams.get('requestId'); + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-1', + productId: 'points_60', + productTitle: '60光点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp', + paidAt: null as string | null, + providerTransactionId: null, + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: { + walletBalance: 0, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + wechatMiniProgramPayParams: { + timeStamp: '1777110165', + nonceStr: 'nonce', + package: 'prepay_id=wx-prepay', + signType: 'RSA', + paySign: 'signature', + }, + }); + + renderProfileView(); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + await user.click(await screen.findByRole('button', { name: /60光点/u })); + + await waitFor(() => { + expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'points_60', + 'wechat_mp', + ); + }); + expect(navigateTo).toHaveBeenCalledWith({ + url: expect.stringContaining('/pages/wechat-pay/index?'), + fail: expect.any(Function), + }); + const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + expect(navigateUrl).toContain('order-wechat-1'); + expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); + expect(await screen.findByText('支付已提交')).toBeTruthy(); +}); + test('profile daily task shortcut opens task center and claims reward', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); @@ -1136,22 +1241,29 @@ test('profile page shows legal entries and ICP record link', async () => { expect( shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'), ).toBe(true); - expect(within(shortcutRegion).getByRole('button', { name: /每日任务/u })) - .toBeTruthy(); - expect(within(shortcutRegion).getByRole('button', { name: /邀请好友/u })) - .toBeTruthy(); - expect(within(shortcutRegion).getByRole('button', { name: /玩家社区/u })) - .toBeTruthy(); - expect(within(shortcutRegion).getByRole('button', { name: /反馈/u })) - .toBeTruthy(); + expect( + within(shortcutRegion).getByRole('button', { name: /每日任务/u }), + ).toBeTruthy(); + expect( + within(shortcutRegion).getByRole('button', { name: /邀请好友/u }), + ).toBeTruthy(); + expect( + within(shortcutRegion).getByRole('button', { name: /玩家社区/u }), + ).toBeTruthy(); + expect( + within(shortcutRegion).getByRole('button', { name: /反馈/u }), + ).toBeTruthy(); const legalRegion = screen.getByRole('region', { name: '法律信息' }); - expect(within(legalRegion).getByRole('button', { name: /用户协议/u })) - .toBeTruthy(); - expect(within(legalRegion).getByRole('button', { name: /隐私政策/u })) - .toBeTruthy(); - expect(within(legalRegion).getByRole('button', { name: /免责声明/u })) - .toBeTruthy(); + expect( + within(legalRegion).getByRole('button', { name: /用户协议/u }), + ).toBeTruthy(); + expect( + within(legalRegion).getByRole('button', { name: /隐私政策/u }), + ).toBeTruthy(); + expect( + within(legalRegion).getByRole('button', { name: /免责声明/u }), + ).toBeTruthy(); const recordLink = within(legalRegion).getByRole('link', { name: ICP_RECORD_NUMBER, @@ -1160,7 +1272,9 @@ test('profile page shows legal entries and ICP record link', async () => { expect(recordLink.getAttribute('target')).toBe('_blank'); expect(recordLink.getAttribute('rel')).toBe('noreferrer'); - await user.click(within(legalRegion).getByRole('button', { name: /隐私政策/u })); + await user.click( + within(legalRegion).getByRole('button', { name: /隐私政策/u }), + ); expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy(); }); @@ -1423,7 +1537,8 @@ test('mobile discover keeps baby object match works in edutainment channel only' await user.click(babyObjectMatchButton); expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry); - const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); + const searchInput = + screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, '宝贝识物水果篮{enter}'); expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 6e44f1b3..58686f15 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -50,6 +50,9 @@ import type { ProfilePlayedWorkSummary, ProfilePlayStatsResponse, ProfileReferralInviteCenterResponse, + ProfileRechargeCenterResponse, + ProfileRechargeProduct, + WechatMiniProgramPayParams, ProfileSaveArchiveSummary, ProfileTaskCenterResponse, ProfileTaskItem, @@ -67,7 +70,9 @@ import { import { copyTextToClipboard } from '../../services/clipboard'; import { claimRpgProfileTaskReward, + createRpgProfileRechargeOrder, getRpgProfileReferralInviteCenter, + getRpgProfileRechargeCenter, getRpgProfileTasks, getRpgProfileWalletLedger, redeemRpgProfileReferralInviteCode, @@ -199,8 +204,11 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; +const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp'; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; +type RechargeTab = 'points' | 'membership'; +type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; type DiscoverChannel = | 'recommend' | 'today' @@ -2141,7 +2149,9 @@ function ProfileLegalSection({ type="button" onClick={() => onOpenDocument(document.id)} className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${ - index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : '' + index > 0 + ? 'border-t border-[var(--platform-subpanel-border)]' + : '' }`} > @@ -2484,6 +2494,254 @@ function formatWalletLedgerAmount(amountDelta: number) { return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`; } +function formatRechargePrice(priceCents: number) { + const yuan = priceCents / 100; + return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`; +} + +function isWechatMiniProgramWebView() { + if (typeof window === 'undefined') { + return false; + } + + const params = new URLSearchParams(window.location.search); + return ( + params.get('clientRuntime') === 'wechat_mini_program' || + params.get('clientType') === 'mini_program' + ); +} + +function clearWechatPayResultHash() { + if (typeof window === 'undefined') { + return; + } + + const rawHash = window.location.hash.replace(/^#/, ''); + if (!rawHash.includes('wx_pay_result=')) { + return; + } + const params = new URLSearchParams(rawHash); + params.delete('wx_pay_result'); + const nextHash = params.toString(); + const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`; + window.history.replaceState(null, '', nextUrl); +} + +function requestWechatMiniProgramPayment( + payload: WechatMiniProgramPayParams | null | undefined, + orderId: string, +) { + const miniProgram = window.wx?.miniProgram; + if ( + !payload || + !miniProgram || + typeof miniProgram.navigateTo !== 'function' + ) { + return Promise.reject(new Error('请在微信小程序内完成支付')); + } + const navigateTo = miniProgram.navigateTo; + + return new Promise((resolve) => { + const requestId = `wechat_pay_${orderId}_${Date.now()}`; + const handleHashChange = () => { + const params = new URLSearchParams( + window.location.hash.replace(/^#/, ''), + ); + const result = params.get('wx_pay_result') ?? ''; + const [resultRequestId, status] = result.split(':'); + if (resultRequestId !== requestId) { + return; + } + + window.removeEventListener('hashchange', handleHashChange); + resolve( + status === 'success' + ? 'success' + : status === 'cancel' + ? 'cancel' + : 'fail', + ); + }; + + window.addEventListener('hashchange', handleHashChange); + navigateTo({ + url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`, + fail(error) { + window.removeEventListener('hashchange', handleHashChange); + console.error('[wechat-pay] navigateTo failed', error); + resolve('fail'); + }, + }); + }); +} + +function RechargeProductCard({ + product, + submittingProductId, + onBuy, +}: { + product: ProfileRechargeProduct; + submittingProductId: string | null; + onBuy: (product: ProfileRechargeProduct) => void; +}) { + const submitting = submittingProductId === product.productId; + const value = + product.kind === 'points' + ? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}光点` + : `${product.durationDays}天`; + + return ( + + ); +} + +function ProfileRechargeModal({ + center, + isLoading, + error, + success, + submittingProductId, + activeTab, + onTabChange, + onClose, + onRetry, + onBuy, +}: { + center: ProfileRechargeCenterResponse | null; + isLoading: boolean; + error: string | null; + success: string | null; + submittingProductId: string | null; + activeTab: RechargeTab; + onTabChange: (tab: RechargeTab) => void; + onClose: () => void; + onRetry: () => void; + onBuy: (product: ProfileRechargeProduct) => void; +}) { + const products = + activeTab === 'points' + ? (center?.pointProducts ?? []) + : (center?.membershipProducts ?? []); + const memberLabel = + center?.membership.status === 'active' + ? center.membership.expiresAt + ? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}` + : '会员已生效' + : '普通用户'; + + return ( +
+
+
+
+
账户充值
+
+ {center + ? `${center.walletBalance}光点 · ${memberLabel}` + : '读取中'} +
+
+ +
+
+
+ + +
+ + {error ? ( +
+
{error}
+ +
+ ) : null} + {success ? ( +
+ {success} +
+ ) : null} + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : products.length > 0 ? ( +
+ {products.map((product) => ( + + ))} +
+ ) : ( +
+ 暂无可购买套餐 +
+ )} +
+
+
+ ); +} + function WalletLedgerModal({ ledger, fallbackBalance, @@ -3184,6 +3442,16 @@ export function RpgEntryHomeView({ const [rewardCodeSuccess, setRewardCodeSuccess] = useState( null, ); + const [isRechargeOpen, setIsRechargeOpen] = useState(false); + const [rechargeCenter, setRechargeCenter] = + useState(null); + const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false); + const [rechargeError, setRechargeError] = useState(null); + const [rechargeSuccess, setRechargeSuccess] = useState(null); + const [activeRechargeTab, setActiveRechargeTab] = + useState('points'); + const [submittingRechargeProductId, setSubmittingRechargeProductId] = + useState(null); const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); const [walletLedger, setWalletLedger] = useState(null); @@ -3725,6 +3993,100 @@ export function RpgEntryHomeView({ setIsWalletLedgerOpen(true); loadWalletLedger(); }; + const loadRechargeCenter = () => { + setRechargeError(null); + setIsLoadingRechargeCenter(true); + void getRpgProfileRechargeCenter() + .then(setRechargeCenter) + .catch((error: unknown) => { + setRechargeCenter(null); + setRechargeError( + error instanceof Error ? error.message : '读取账户充值失败', + ); + }) + .finally(() => setIsLoadingRechargeCenter(false)); + }; + const openRechargeModal = () => { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + + setIsRechargeOpen(true); + setRechargeSuccess(null); + loadRechargeCenter(); + }; + const buyRechargeProduct = (product: ProfileRechargeProduct) => { + if (submittingRechargeProductId) { + return; + } + + const paymentChannel = isWechatMiniProgramWebView() + ? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL + : 'mock'; + setSubmittingRechargeProductId(product.productId); + setRechargeError(null); + setRechargeSuccess(null); + void createRpgProfileRechargeOrder(product.productId, paymentChannel) + .then(async (response) => { + if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { + const status = await requestWechatMiniProgramPayment( + response.wechatMiniProgramPayParams, + response.order.orderId, + ); + if (status === 'cancel') { + setRechargeCenter(response.center); + setRechargeSuccess('支付已取消'); + return; + } + if (status !== 'success') { + throw new Error('微信支付未完成'); + } + setRechargeSuccess('支付已提交'); + loadRechargeCenter(); + } else { + setRechargeCenter(response.center); + setRechargeSuccess('已到账'); + } + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setRechargeError(error instanceof Error ? error.message : '充值失败'); + }) + .finally(() => setSubmittingRechargeProductId(null)); + }; + useEffect(() => { + if (!isRechargeOpen) { + return undefined; + } + + const handleWechatPayResult = () => { + const result = new URLSearchParams( + window.location.hash.replace(/^#/, ''), + ).get('wx_pay_result'); + if (!result) { + return; + } + const [, status] = result.split(':'); + if (status === 'success') { + setRechargeSuccess('支付已提交'); + loadRechargeCenter(); + void onRechargeSuccess?.(); + clearWechatPayResultHash(); + } else if (status === 'cancel') { + setRechargeSuccess('支付已取消'); + clearWechatPayResultHash(); + } else { + setRechargeError('微信支付未完成'); + clearWechatPayResultHash(); + } + }; + + window.addEventListener('hashchange', handleWechatPayResult); + handleWechatPayResult(); + return () => + window.removeEventListener('hashchange', handleWechatPayResult); + }, [isRechargeOpen, onRechargeSuccess]); const loadTaskCenter = () => { setTaskCenterError(null); setIsLoadingTaskCenter(true); @@ -4919,13 +5281,13 @@ export function RpgEntryHomeView({ @@ -5013,6 +5375,18 @@ export function RpgEntryHomeView({ icon={Star} onClick={openTaskCenterPanel} /> + + setIsRewardCodeOpen(false)} /> ) : null; + const rechargeModal: ReactNode = isRechargeOpen ? ( + setIsRechargeOpen(false)} + onRetry={loadRechargeCenter} + onBuy={buyRechargeProduct} + /> + ) : null; if (!isDesktopLayout) { const isMobileRecommendTab = activeTab === 'home'; @@ -5537,6 +5925,7 @@ export function RpgEntryHomeView({ /> ) : null} {rewardCodeModal} + {rechargeModal} {isTaskCenterOpen ? (
{rewardCodeModal} + {rechargeModal} {isTaskCenterOpen ? ( { const sessions = await getAuthSessions(); expect(sessions).toHaveLength(1); - expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']); - expect(sessions[0].sessionCount).toBe(2); + const [session] = sessions; + expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']); + expect(session?.sessionCount).toBe(2); }); it('revokes a single auth session by backend route', async () => { diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 6bb44c52..d56702fb 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -90,6 +90,7 @@ export function getRpgProfileRechargeCenter( export function createRpgProfileRechargeOrder( productId: string, + paymentChannel = 'mock', options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( @@ -97,7 +98,7 @@ export function createRpgProfileRechargeOrder( { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ productId, paymentChannel: 'mock' }), + body: JSON.stringify({ productId, paymentChannel }), }, '充值失败', options, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1a542cfc..f9f39034 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,3 +3,15 @@ interface ImportMetaEnv { readonly VITE_DEBUG_MODE?: string; } + +interface Window { + wx?: { + miniProgram?: { + navigateTo?: (options: { + url: string; + fail?: (error: { errMsg?: string }) => void; + }) => void; + postMessage?: (message: unknown) => void; + }; + }; +} From e8648e45fcfe2bb2b384e352035bdb1d7d951ed9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 02:40:34 +0800 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E7=94=9F=E4=BA=A7=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-rs/crates/api-server/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 06e24092..accd7621 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -38,6 +38,7 @@ platform-speech = { workspace = true } ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } shared-contracts = { workspace = true, features = ["oss-contracts"] } shared-kernel = { workspace = true } shared-logging = { workspace = true } @@ -58,5 +59,4 @@ base64 = { workspace = true } hmac = { workspace = true } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } -sha2 = { workspace = true } tower = { workspace = true, features = ["util"] } From 166544fae619cac9eb71c43b5930f693dfaaf2b0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 23:24:32 +0800 Subject: [PATCH 14/14] Persist auth store into formal tables --- .hermes/shared-memory/decision-log.md | 10 +- ...OT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md | 6 +- ...FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md | 12 +- ...ACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md | 3 +- docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 15 +- server-rs/crates/api-server/src/state.rs | 17 +- server-rs/crates/spacetime-client/src/auth.rs | 23 +++ .../auth_store_projection_meta_table.rs | 162 ++++++++++++++++++ .../auth_store_projection_meta_type.rs | 52 ++++++ ...port_auth_store_snapshot_json_procedure.rs | 59 +++++++ .../src/module_bindings/mod.rs | 32 +++- .../spacetime-module/src/auth/procedures.rs | 85 ++++++++- .../spacetime-module/src/auth/tables.rs | 7 + .../crates/spacetime-module/src/migration.rs | 1 + 14 files changed, 452 insertions(+), 32 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b912bfb4..b0a911cb 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-13 认证运行期同步直接导入正式认证表 + +- 背景:`auth_store_snapshot` 是 Stage 1 整包快照过渡表,主键固定 `default`,会让所有用户状态集中在一条 `snapshot_json` 中;Stage 2/3 已有 `user_account/auth_identity/refresh_session` 正式认证表,继续刷新 `default` 容易让运行时真相和表拆分目标混在一起。 +- 决策:运行期认证变更继续由 `module-auth` 生成一致内存快照,但 `api-server` 改为调用 `import_auth_store_snapshot_json` 直接覆盖导入 `user_account/auth_identity/refresh_session`;`auth_store_projection_meta/default` 只记录正式认证表最近一次导入时间;`upsert_auth_store_snapshot` 与 `import_auth_store_snapshot` 仅保留为旧库迁移和兜底入口。 +- 影响范围:`spacetime-module` auth procedures/tables、`spacetime-client` auth facade/bindings、`api-server` 认证同步和启动恢复、SpacetimeDB 表目录与认证 Stage 3 文档。 +- 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding`。 +- 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 + ## 2026-05-13 微信小程序支付以后端通知为唯一入账事实 - 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。 @@ -408,4 +416,4 @@ - 决策:埋点原始事实进入 `tracking_event`,聚合投影进入 `tracking_daily_stat`;个人任务配置/进度/领奖/钱包分别进入 `profile_task_config`、`profile_task_progress`、`profile_task_reward_claim`、`profile_wallet_ledger`;首版个人任务 scope 仅支持 `user`。 - 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。 - 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/` 和 `docs/tracking/`。 -- 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`、`RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。 +- 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`、`RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。 \ No newline at end of file diff --git a/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md b/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md index 89d9e231..e461cde2 100644 --- a/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md +++ b/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md @@ -40,12 +40,12 @@ HTTP status server error (503 Service Unavailable) ### 3.1 认证快照同步改为非阻断 -`AppState::sync_auth_store_snapshot_to_spacetime` 保持导出本地快照、写入 SpacetimeDB、导入正式表的顺序,但当远端写入或导入失败时只写 warn 日志并返回 `Ok(())`。 +`AppState::sync_auth_store_snapshot_to_spacetime` 保持先导出本地认证快照,但运行期会直接调用 `import_auth_store_snapshot_json` 覆盖导入 SpacetimeDB 正式认证表,不再刷新 `auth_store_snapshot/default`;当远端导入失败时只写 warn 日志并返回 `Ok(())`。 设计边界: 1. 当前认证请求的即时真相源是本地 `auth_store`。 -2. SpacetimeDB 认证快照用于跨进程恢复和正式表投影。 +2. SpacetimeDB 正式认证表用于跨进程恢复;`auth_store_snapshot/default` 只保留为历史迁移和兜底恢复记录。 3. 远端库挂起或网络异常只降级远端恢复能力,不回滚已经成功的登录、刷新、退出和资料更新。 ### 3.2 Vite 补齐创作接口代理 @@ -98,7 +98,7 @@ npm run dev:web 1. `GET http://127.0.0.1:3000/api/auth/login-options` 返回 `["phone","password"]`。 2. `GET http://127.0.0.1:3000/api/runtime/match3d/gallery` 返回 `{"items":[]}`,不再返回 SpacetimeDB 503。 3. 未登录请求 `POST http://127.0.0.1:3000/api/creation/match3d/sessions` 返回 `401`,说明同源请求已进入 Rust 鉴权层,不再被 Vite `404`。 -4. 隔离端口指向挂起的远端库并使用 mock 短信时,手机号验证码登录返回 `200` 和 token;日志只记录“认证快照写入 SpacetimeDB 失败,当前认证流程继续”。 +4. 隔离端口指向挂起的远端库并使用 mock 短信时,手机号验证码登录返回 `200` 和 token;日志只记录“认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续”。 ## 6. 后续 diff --git a/docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md b/docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md index f41a7127..769a4164 100644 --- a/docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md +++ b/docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md @@ -6,8 +6,9 @@ 落地口径: - `user_account`、`auth_identity`、`refresh_session` 作为 SpacetimeDB 中的正式认证持久化表。 +- `auth_store_projection_meta` 只记录正式认证表最近一次由认证快照导入的时间,不保存用户快照内容。 - API 启动时优先从正式表导出兼容 `module-auth` 的认证快照,再恢复到内存认证服务。 -- 运行期认证变更仍先复用现有 `module-auth` 逻辑生成一致快照,随后同步快照并导入正式表,保证正式表与快照一致。 +- 运行期认证变更仍先复用现有 `module-auth` 逻辑生成一致快照,随后调用 `import_auth_store_snapshot_json` 直接覆盖导入正式表;不再继续刷新 `auth_store_snapshot/default`。 - 本阶段不重写登录、刷新、登出内部业务规则,避免在 JWT、refresh rotation、微信绑定合并等复杂语义中引入行为漂移。 ## 2. 非目标 @@ -21,7 +22,7 @@ ### 3.1 启动恢复 1. API 调用 `export_auth_store_snapshot_from_tables`。 -2. 若正式表已有用户、身份或会话数据,则返回兼容 `module-auth` 的 JSON 快照。 +2. 若正式表已有用户、身份或会话数据,则返回兼容 `module-auth` 的 JSON 快照,并带上 `auth_store_projection_meta/default.updated_at`。 3. API 用 `InMemoryAuthStore::from_snapshot_json` 恢复认证服务。 4. 若正式表为空或调用失败,则回退到 Stage 1 的 `auth_store_snapshot`。 5. 若 Stage 1 也不可用,则回退本地 JSON 热修复文件。 @@ -29,9 +30,10 @@ ### 3.2 运行期同步 1. 登录、刷新、登出等路径继续调用当前内存认证服务。 -2. 每次认证状态变更后调用 `upsert_auth_store_snapshot`。 -3. 快照写入成功后调用 `import_auth_store_snapshot`,覆盖导入正式表。 -4. 导入失败时返回错误,避免用户误以为状态已经持久化。 +2. 每次认证状态变更后导出当前内存认证快照 JSON。 +3. API 调用 `import_auth_store_snapshot_json`,在同一 SpacetimeDB transaction 中清空并重建 `user_account/auth_identity/refresh_session`,同时更新 `auth_store_projection_meta/default.updated_at`。 +4. `upsert_auth_store_snapshot` 和 `import_auth_store_snapshot` 保留为旧库迁移入口,只服务 `auth_store_snapshot/default` 到正式认证表的历史导入,不作为运行期同步路径。 +5. 远端导入失败只记录 warn 并继续当前认证响应,避免远端库挂起时回滚已经成功的登录、刷新、退出和资料更新。 ## 4. 数据重建规则 diff --git a/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md b/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md index fe958971..6d77c38d 100644 --- a/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md +++ b/docs/technical/AUTH_SPACETIMEDB_TABLE_SPLIT_STAGE2_2026-04-24.md @@ -23,7 +23,7 @@ Stage 1 已把 Rust 鉴权快照同步到 SpacetimeDB 的 `auth_store_snapshot` 1. `POST /api/auth/refresh` 改写 `refresh_session` 表。 2. 登录成功写 `user_account/auth_identity/refresh_session`。 3. `logout/logout-all/revoke-session` 改写细粒度表。 -4. `auth_store_snapshot` 退化为迁移备份。 +4. `auth_store_snapshot` 退化为迁移备份;运行期若仍复用内存认证快照,也应通过 `import_auth_store_snapshot_json` 直接导入正式认证表,不再刷新 `auth_store_snapshot/default`。 ## 3. 表设计落地口径 @@ -94,4 +94,3 @@ Stage 1 已把 Rust 鉴权快照同步到 SpacetimeDB 的 `auth_store_snapshot` 2. Rust bindings 已刷新。 3. `spacetime-client` 暴露导入 procedure facade。 4. `api-server/spacetime-client/module-auth` 定向检查通过。 - diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index bf472de7..301e1d54 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" | 领域 | 表 | | --- | --- | | 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` | -| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | +| 认证 | `auth_store_snapshot`, `auth_store_projection_meta`, `user_account`, `auth_identity`, `refresh_session` | | 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_feedback_submission`, `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` | @@ -60,7 +60,7 @@ SELECT * FROM database_migration_operator WHERE operator_identity = '' ### `auth_store_snapshot` -- 作用:保存旧内存认证仓储的整份 JSON 快照,用于迁移和恢复;后续正式表拆分后仍可作为导入/导出桥。 +- 作用:保存旧内存认证仓储的整份 JSON 快照,用于历史迁移和兜底恢复;运行期认证同步不再继续刷新 `snapshot_id = 'default'`,而是直接导入正式认证表。 - 结构:`snapshot_id PK: String`, `snapshot_json: String`, `updated_at: Timestamp`。 - 索引:主键 `snapshot_id`。 @@ -69,6 +69,17 @@ SELECT * FROM auth_store_snapshot; SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default'; ``` +### `auth_store_projection_meta` + +- 作用:记录正式认证表最近一次由认证快照导入的时间,避免启动恢复时旧 `auth_store_snapshot/default` 因带有时间戳而覆盖较新的正式认证表。 +- 结构:`meta_id PK: String`, `updated_at: Timestamp`。 +- 索引:主键 `meta_id`。 + +```sql +SELECT * FROM auth_store_projection_meta; +SELECT * FROM auth_store_projection_meta WHERE meta_id = 'default'; +``` + ### `user_account` - 作用:用户账号主表,保存用户名、公开百梦号、手机号掩码、登录方式、密码登录开关、token 版本和默认不前端展示的运营标签。 diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ad730042..29350028 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -339,23 +339,14 @@ impl AppState { OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; - // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 快照用于跨进程恢复。 + // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 正式认证表用于跨进程恢复。 // 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 #[cfg(not(test))] if let Err(error) = self .spacetime_client - .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) + .import_auth_store_snapshot_json(snapshot_json, updated_at_micros) .await { - warn!( - error = %error, - "认证快照写入 SpacetimeDB 失败,当前认证流程继续" - ); - return Ok(()); - } - // 写入快照后尝试拆入正式认证表;失败只影响远端表恢复,不阻断当前认证响应。 - #[cfg(not(test))] - if let Err(error) = self.spacetime_client.import_auth_store_snapshot().await { warn!( error = %error, "认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续" @@ -859,8 +850,8 @@ fn select_auth_store_restore_candidate( fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 { match source { - AuthStoreRestoreSource::SpacetimeSnapshot => 3, - AuthStoreRestoreSource::SpacetimeTables => 2, + AuthStoreRestoreSource::SpacetimeTables => 3, + AuthStoreRestoreSource::SpacetimeSnapshot => 2, AuthStoreRestoreSource::LocalFile => 1, } } diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index 5b380948..438a2d69 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -57,6 +57,29 @@ impl SpacetimeClient { .await } + pub async fn import_auth_store_snapshot_json( + &self, + snapshot_json: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = AuthStoreSnapshotUpsertInput { + snapshot_json, + updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .import_auth_store_snapshot_json_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_auth_store_snapshot_import_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn import_auth_store_snapshot( &self, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_table.rs new file mode 100644 index 00000000..d0bbb6f4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_table.rs @@ -0,0 +1,162 @@ +// 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 super::auth_store_projection_meta_type::AuthStoreProjectionMeta; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `auth_store_projection_meta`. +/// +/// Obtain a handle from the [`AuthStoreProjectionMetaTableAccess::auth_store_projection_meta`] method on [`super::RemoteTables`], +/// like `ctx.db.auth_store_projection_meta()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.auth_store_projection_meta().on_insert(...)`. +pub struct AuthStoreProjectionMetaTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `auth_store_projection_meta`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AuthStoreProjectionMetaTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AuthStoreProjectionMetaTableHandle`], which mediates access to the table `auth_store_projection_meta`. + fn auth_store_projection_meta(&self) -> AuthStoreProjectionMetaTableHandle<'_>; +} + +impl AuthStoreProjectionMetaTableAccess for super::RemoteTables { + fn auth_store_projection_meta(&self) -> AuthStoreProjectionMetaTableHandle<'_> { + AuthStoreProjectionMetaTableHandle { + imp: self + .imp + .get_table::("auth_store_projection_meta"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AuthStoreProjectionMetaInsertCallbackId(__sdk::CallbackId); +pub struct AuthStoreProjectionMetaDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for AuthStoreProjectionMetaTableHandle<'ctx> { + type Row = AuthStoreProjectionMeta; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AuthStoreProjectionMetaInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AuthStoreProjectionMetaInsertCallbackId { + AuthStoreProjectionMetaInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AuthStoreProjectionMetaInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AuthStoreProjectionMetaDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AuthStoreProjectionMetaDeleteCallbackId { + AuthStoreProjectionMetaDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AuthStoreProjectionMetaDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AuthStoreProjectionMetaUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AuthStoreProjectionMetaTableHandle<'ctx> { + type UpdateCallbackId = AuthStoreProjectionMetaUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AuthStoreProjectionMetaUpdateCallbackId { + AuthStoreProjectionMetaUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AuthStoreProjectionMetaUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `meta_id` unique index on the table `auth_store_projection_meta`, +/// which allows point queries on the field of the same name +/// via the [`AuthStoreProjectionMetaMetaIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.auth_store_projection_meta().meta_id().find(...)`. +pub struct AuthStoreProjectionMetaMetaIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AuthStoreProjectionMetaTableHandle<'ctx> { + /// Get a handle on the `meta_id` unique index on the table `auth_store_projection_meta`. + pub fn meta_id(&self) -> AuthStoreProjectionMetaMetaIdUnique<'ctx> { + AuthStoreProjectionMetaMetaIdUnique { + imp: self.imp.get_unique_constraint::("meta_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AuthStoreProjectionMetaMetaIdUnique<'ctx> { + /// Find the subscribed row whose `meta_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("auth_store_projection_meta"); + _table.add_unique_constraint::("meta_id", |row| &row.meta_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AuthStoreProjectionMeta`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait auth_store_projection_metaQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AuthStoreProjectionMeta`. + fn auth_store_projection_meta(&self) -> __sdk::__query_builder::Table; +} + +impl auth_store_projection_metaQueryTableAccess for __sdk::QueryTableAccessor { + fn auth_store_projection_meta(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("auth_store_projection_meta") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs new file mode 100644 index 00000000..309dceff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs @@ -0,0 +1,52 @@ +// 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 AuthStoreProjectionMeta { + pub meta_id: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AuthStoreProjectionMeta { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AuthStoreProjectionMeta`. +/// +/// Provides typed access to columns for query building. +pub struct AuthStoreProjectionMetaCols { + pub meta_id: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AuthStoreProjectionMeta { + type Cols = AuthStoreProjectionMetaCols; + fn cols(table_name: &'static str) -> Self::Cols { + AuthStoreProjectionMetaCols { + meta_id: __sdk::__query_builder::Col::new(table_name, "meta_id"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `AuthStoreProjectionMeta`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AuthStoreProjectionMetaIxCols { + pub meta_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AuthStoreProjectionMeta { + type IxCols = AuthStoreProjectionMetaIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AuthStoreProjectionMetaIxCols { + meta_id: __sdk::__query_builder::IxCol::new(table_name, "meta_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AuthStoreProjectionMeta {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs new file mode 100644 index 00000000..3cd6d71f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_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::auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult; +use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ImportAuthStoreSnapshotJsonArgs { + pub input: AuthStoreSnapshotUpsertInput, +} + +impl __sdk::InModule for ImportAuthStoreSnapshotJsonArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `import_auth_store_snapshot_json`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait import_auth_store_snapshot_json { + fn import_auth_store_snapshot_json(&self, input: AuthStoreSnapshotUpsertInput) { + self.import_auth_store_snapshot_json_then(input, |_, _| {}); + } + + fn import_auth_store_snapshot_json_then( + &self, + input: AuthStoreSnapshotUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl import_auth_store_snapshot_json for super::RemoteProcedures { + fn import_auth_store_snapshot_json_then( + &self, + input: AuthStoreSnapshotUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AuthStoreSnapshotImportProcedureResult>( + "import_auth_store_snapshot_json", + ImportAuthStoreSnapshotJsonArgs { 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 13fa85bd..a4006654 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). +// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -82,6 +82,8 @@ pub mod asset_object_upsert_snapshot_type; pub mod attach_ai_result_reference_and_return_procedure; pub mod auth_identity_table; pub mod auth_identity_type; +pub mod auth_store_projection_meta_table; +pub mod auth_store_projection_meta_type; pub mod auth_store_snapshot_import_procedure_result_type; pub mod auth_store_snapshot_import_record_type; pub mod auth_store_snapshot_procedure_result_type; @@ -338,6 +340,7 @@ pub mod grant_inventory_item_input_type; pub mod grant_new_user_registration_wallet_reward_procedure; pub mod grant_player_progression_experience_and_return_procedure; pub mod grant_player_progression_experience_reducer; +pub mod import_auth_store_snapshot_json_procedure; pub mod import_auth_store_snapshot_procedure; pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; @@ -894,6 +897,8 @@ pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; pub use attach_ai_result_reference_and_return_procedure::attach_ai_result_reference_and_return; pub use auth_identity_table::*; pub use auth_identity_type::AuthIdentity; +pub use auth_store_projection_meta_table::*; +pub use auth_store_projection_meta_type::AuthStoreProjectionMeta; pub use auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult; pub use auth_store_snapshot_import_record_type::AuthStoreSnapshotImportRecord; pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; @@ -1150,6 +1155,7 @@ pub use grant_inventory_item_input_type::GrantInventoryItemInput; pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; pub use grant_player_progression_experience_reducer::grant_player_progression_experience; +pub use import_auth_store_snapshot_json_procedure::import_auth_store_snapshot_json; pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot; pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks; pub use import_database_migration_from_file_procedure::import_database_migration_from_file; @@ -1912,6 +1918,7 @@ pub struct DbUpdate { asset_event: __sdk::TableUpdate, asset_object: __sdk::TableUpdate, auth_identity: __sdk::TableUpdate, + auth_store_projection_meta: __sdk::TableUpdate, auth_store_snapshot: __sdk::TableUpdate, battle_state: __sdk::TableUpdate, big_fish_agent_message: __sdk::TableUpdate, @@ -2020,6 +2027,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "auth_identity" => db_update .auth_identity .append(auth_identity_table::parse_table_update(table_update)?), + "auth_store_projection_meta" => db_update.auth_store_projection_meta.append( + auth_store_projection_meta_table::parse_table_update(table_update)?, + ), "auth_store_snapshot" => db_update .auth_store_snapshot .append(auth_store_snapshot_table::parse_table_update(table_update)?), @@ -2295,6 +2305,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.auth_identity = cache .apply_diff_to_table::("auth_identity", &self.auth_identity) .with_updates_by_pk(|row| &row.identity_id); + diff.auth_store_projection_meta = cache + .apply_diff_to_table::( + "auth_store_projection_meta", + &self.auth_store_projection_meta, + ) + .with_updates_by_pk(|row| &row.meta_id); diff.auth_store_snapshot = cache .apply_diff_to_table::( "auth_store_snapshot", @@ -2695,6 +2711,9 @@ impl __sdk::DbUpdate for DbUpdate { "auth_identity" => db_update .auth_identity .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "auth_store_projection_meta" => db_update + .auth_store_projection_meta + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "auth_store_snapshot" => db_update .auth_store_snapshot .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -2948,6 +2967,9 @@ impl __sdk::DbUpdate for DbUpdate { "auth_identity" => db_update .auth_identity .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "auth_store_projection_meta" => db_update + .auth_store_projection_meta + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "auth_store_snapshot" => db_update .auth_store_snapshot .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3183,6 +3205,7 @@ pub struct AppliedDiff<'r> { asset_event: __sdk::TableAppliedDiff<'r, AssetEvent>, asset_object: __sdk::TableAppliedDiff<'r, AssetObject>, auth_identity: __sdk::TableAppliedDiff<'r, AuthIdentity>, + auth_store_projection_meta: __sdk::TableAppliedDiff<'r, AuthStoreProjectionMeta>, auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>, battle_state: __sdk::TableAppliedDiff<'r, BattleState>, big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>, @@ -3309,6 +3332,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.auth_identity, event, ); + callbacks.invoke_table_row_callbacks::( + "auth_store_projection_meta", + &self.auth_store_projection_meta, + event, + ); callbacks.invoke_table_row_callbacks::( "auth_store_snapshot", &self.auth_store_snapshot, @@ -4317,6 +4345,7 @@ impl __sdk::SpacetimeModule for RemoteModule { asset_event_table::register_table(client_cache); asset_object_table::register_table(client_cache); auth_identity_table::register_table(client_cache); + auth_store_projection_meta_table::register_table(client_cache); auth_store_snapshot_table::register_table(client_cache); battle_state_table::register_table(client_cache); big_fish_agent_message_table::register_table(client_cache); @@ -4399,6 +4428,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "asset_event", "asset_object", "auth_identity", + "auth_store_projection_meta", "auth_store_snapshot", "battle_state", "big_fish_agent_message", diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 3c9a37e6..7b9ee01d 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -7,12 +7,14 @@ use super::{ sanitize_identity_component, }, tables::{ - AuthIdentity, AuthStoreSnapshot, RefreshSession, UserAccount, auth_identity, - auth_store_snapshot, refresh_session, user_account, + AuthIdentity, AuthStoreProjectionMeta, AuthStoreSnapshot, RefreshSession, UserAccount, + auth_identity, auth_store_projection_meta, auth_store_snapshot, refresh_session, + user_account, }, }; const AUTH_STORE_SNAPSHOT_ID: &str = "default"; +const AUTH_STORE_PROJECTION_META_ID: &str = "default"; #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreSnapshotRecord { @@ -70,7 +72,7 @@ pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotP } } -// Axum 每次鉴权仓储变更后覆盖写入整份快照,后续拆表阶段再替换为细粒度 reducer。 +// 历史迁移入口:覆盖写入整份快照,供旧库从 `auth_store_snapshot/default` 导入正式表。 #[spacetimedb::procedure] pub fn upsert_auth_store_snapshot( ctx: &mut ProcedureContext, @@ -90,6 +92,26 @@ pub fn upsert_auth_store_snapshot( } } +// Axum 运行期认证变更直接导入正式认证表,不再继续刷新 `auth_store_snapshot/default`。 +#[spacetimedb::procedure] +pub fn import_auth_store_snapshot_json( + ctx: &mut ProcedureContext, + input: AuthStoreSnapshotUpsertInput, +) -> AuthStoreSnapshotImportProcedureResult { + match ctx.try_with_tx(|tx| import_auth_store_snapshot_json_tx(tx, input.clone())) { + Ok(record) => AuthStoreSnapshotImportProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AuthStoreSnapshotImportProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn import_auth_store_snapshot( ctx: &mut ProcedureContext, @@ -191,10 +213,35 @@ fn import_auth_store_snapshot_tx( .snapshot_id() .find(&AUTH_STORE_SNAPSHOT_ID.to_string()) .ok_or_else(|| "认证快照不存在,无法导入正式表".to_string())?; - let parsed = serde_json::from_str::(&snapshot.snapshot_json) + + import_auth_store_snapshot_json_value_tx( + ctx, + &snapshot.snapshot_json, + snapshot.updated_at.to_micros_since_unix_epoch(), + ) +} + +fn import_auth_store_snapshot_json_tx( + ctx: &ReducerContext, + input: AuthStoreSnapshotUpsertInput, +) -> Result { + import_auth_store_snapshot_json_value_tx(ctx, &input.snapshot_json, input.updated_at_micros) +} + +fn import_auth_store_snapshot_json_value_tx( + ctx: &ReducerContext, + snapshot_json: &str, + updated_at_micros: i64, +) -> Result { + let snapshot_json = snapshot_json.trim(); + if snapshot_json.is_empty() { + return Err("认证快照 JSON 不能为空".to_string()); + } + let parsed = serde_json::from_str::(snapshot_json) .map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?; clear_auth_target_tables(ctx); + upsert_auth_projection_meta(ctx, updated_at_micros); let mut imported_user_count = 0_u32; let mut imported_identity_count = 0_u32; @@ -293,6 +340,12 @@ fn export_auth_store_snapshot_from_tables_tx( updated_at_micros: None, }); } + let updated_at_micros = ctx + .db + .auth_store_projection_meta() + .meta_id() + .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) + .map(|row| row.updated_at.to_micros_since_unix_epoch()); let mut phone_identity_by_user_id = std::collections::HashMap::new(); let mut phone_to_user_id = std::collections::HashMap::new(); @@ -407,7 +460,7 @@ fn export_auth_store_snapshot_from_tables_tx( Ok(AuthStoreSnapshotRecord { snapshot_json: Some(snapshot_json), - updated_at_micros: None, + updated_at_micros, }) } @@ -428,3 +481,25 @@ fn clear_auth_target_tables(ctx: &ReducerContext) { ctx.db.user_account().user_id().delete(&row.user_id); } } + +fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { + let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string(); + if ctx + .db + .auth_store_projection_meta() + .meta_id() + .find(&meta_id) + .is_some() + { + ctx.db + .auth_store_projection_meta() + .meta_id() + .delete(&meta_id); + } + ctx.db + .auth_store_projection_meta() + .insert(AuthStoreProjectionMeta { + meta_id, + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + }); +} diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index 9f12e29e..c2154cc5 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -8,6 +8,13 @@ pub struct AuthStoreSnapshot { pub(crate) updated_at: Timestamp, } +#[spacetimedb::table(accessor = auth_store_projection_meta)] +pub struct AuthStoreProjectionMeta { + #[primary_key] + pub(crate) meta_id: String, + pub(crate) updated_at: Timestamp, +} + #[spacetimedb::table( accessor = user_account, index(accessor = by_user_account_username, btree(columns = [username])), diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0447d739..70a95422 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -158,6 +158,7 @@ macro_rules! migration_tables { $macro_name! { $($arg,)* auth_store_snapshot, + auth_store_projection_meta, user_account, auth_identity, refresh_session,