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/.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 642dcd70..20724a65 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`。 @@ -533,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/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/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/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/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 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/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/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/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( 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 e0fcfcd5..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, @@ -1409,6 +1433,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; @@ -2043,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 { @@ -2086,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, @@ -2103,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, @@ -3223,6 +3342,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 +3365,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 { @@ -3726,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 ? ( = {}, +): 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 = 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/authService.test.ts b/src/services/authService.test.ts index 2b181345..d95502c7 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -538,8 +538,9 @@ 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); + 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/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/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/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/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( 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); +} 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; + }; + }; +}