master #19
@@ -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。
|
||||
@@ -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,不符合“改密后全设备强制下线”的账号安全预期。
|
||||
|
||||
@@ -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/<job>/<build>/<version>/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.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
|
||||
|
||||
@@ -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. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
|
||||
|
||||
|
||||
@@ -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/<job>/<build>/<version>/` 读取 `web.tar.gz`;先校验 checksum,再解压到 `/opt/genarrative/releases/<version>/web`。
|
||||
- 当 `DEPLOY_TARGET=release` 且 release 服务器本地缓存缺少 `web.tar.gz` 时,默认先执行 `rsync -av --progress <WEB_ARTIFACT_SYNC_HOST>:/var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/ /var/cache/genarrative-build/web-artifacts/<job>/<build>/<version>/`,再继续校验 checksum;默认 Host 为 `genarrative-build-internal`,由 release 服务器本机 SSH config 解析。
|
||||
- 更新 `/opt/genarrative/current` 与 `/srv/genarrative/web` 指向。
|
||||
- 执行 Nginx 配置测试和静态页面 smoke test。
|
||||
- 不进入维护模式。
|
||||
|
||||
@@ -328,7 +328,8 @@ SELECT * FROM profile_membership WHERE user_id = '<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<Timestamp>`。
|
||||
- 结构:`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<Timestamp>`, `provider_transaction_id: Option<String>`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`。
|
||||
- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid`,`provider_transaction_id` 保存微信支付平台订单号。
|
||||
- 索引:`user_id`, `(user_id, created_at)`。
|
||||
|
||||
```sql
|
||||
|
||||
@@ -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. 后端验收点
|
||||
|
||||
当前后端至少应满足以下检查:
|
||||
|
||||
@@ -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 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/web-view/index"
|
||||
],
|
||||
"pages": ["pages/web-view/index", "pages/wechat-pay/index"],
|
||||
"window": {
|
||||
"navigationBarTitleText": "百梦",
|
||||
"navigationBarBackgroundColor": "#0b0f14",
|
||||
|
||||
@@ -343,7 +343,7 @@ Page({
|
||||
},
|
||||
|
||||
handleWebViewMessage(event) {
|
||||
// 中文注释:H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息。
|
||||
// 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。
|
||||
console.info('[web-view] message', event.detail);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "微信支付"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<view class="pay-screen">
|
||||
<view class="pay-card">
|
||||
<view class="pay-title">{{title}}</view>
|
||||
<view wx:if="{{errorMessage}}" class="pay-text pay-text--danger">
|
||||
{{errorMessage}}
|
||||
</view>
|
||||
<button wx:if="{{errorMessage}}" class="ghost-button" bindtap="handleBack">
|
||||
返回
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -71,6 +71,18 @@ pub struct AppConfig {
|
||||
pub wechat_mock_union_id: Option<String>,
|
||||
pub wechat_mock_display_name: String,
|
||||
pub wechat_mock_avatar_url: Option<String>,
|
||||
pub wechat_pay_enabled: bool,
|
||||
pub wechat_pay_provider: String,
|
||||
pub wechat_pay_mch_id: Option<String>,
|
||||
pub wechat_pay_merchant_serial_no: Option<String>,
|
||||
pub wechat_pay_private_key_pem: Option<String>,
|
||||
pub wechat_pay_private_key_path: Option<PathBuf>,
|
||||
pub wechat_pay_platform_public_key_pem: Option<String>,
|
||||
pub wechat_pay_platform_public_key_path: Option<PathBuf>,
|
||||
pub wechat_pay_platform_serial_no: Option<String>,
|
||||
pub wechat_pay_api_v3_key: Option<String>,
|
||||
pub wechat_pay_notify_url: Option<String>,
|
||||
pub wechat_pay_jsapi_endpoint: String,
|
||||
pub oss_bucket: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
pub oss_access_key_id: Option<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
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,
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user