From 332f887c66a15fcffa2e30b2c9209ee4c98d1698 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 15:45:25 +0800 Subject: [PATCH 01/11] docs: add shared todo plan naming rules --- .hermes/README.md | 3 +- .hermes/shared-memory/document-map.md | 3 ++ .hermes/shared-memory/team-conventions.md | 9 ++-- .hermes/todos/README.md | 15 ++++++ ...力模块化与图片资产Adapter收口计划-2026-05-14.md | 54 +++++++++++++++++++ AGENTS.md | 1 + docs/README.md | 4 ++ 7 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 .hermes/todos/README.md create mode 100644 .hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md diff --git a/.hermes/README.md b/.hermes/README.md index b5cdaf6a..ac288c9f 100644 --- a/.hermes/README.md +++ b/.hermes/README.md @@ -8,6 +8,7 @@ - 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。 - 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。 - 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。 +- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。 - 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。 ## 目录结构 @@ -24,6 +25,7 @@ │ ├─ pitfalls.md # 踩坑与排障记录 │ └─ handoff-template.md # 任务交接模板 ├─ plans/ # 阶段性计划与实施方案 +├─ todos/ # 已定稿但尚未执行的共享 TODO 计划 ├─ skills/ # 仓库级 Hermes skills └─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin) ``` @@ -89,4 +91,3 @@ HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取 - 大段临时聊天记录 - 尚未确认的一次性猜测 - 构建产物、日志、缓存、数据库 dump - diff --git a/.hermes/shared-memory/document-map.md b/.hermes/shared-memory/document-map.md index 409eee36..6d0285bc 100644 --- a/.hermes/shared-memory/document-map.md +++ b/.hermes/shared-memory/document-map.md @@ -30,6 +30,8 @@ - `operations/`:后台运营核查、对账和排障查询。 - `prd/`:产品需求与阶段计划。 +新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。标签用于跨目录检索,不替代 `docs/` 的目录分类;历史文档不要求批量重命名。 + ## 推荐阅读顺序 通用复杂任务: @@ -96,5 +98,6 @@ RPG 创作与运行时链路: - 新增工程实现时,如果已有对应文档,必须同步更新。 - 如果没有对应文档,新文档放入 `docs/` 下合适分类。 +- 新文档文件名必须使用 `【标签名】` 前缀,标题尽量保留中文语义,日期使用 `YYYY-MM-DD`。 - `.hermes/shared-memory/` 只保留跨任务、跨成员、高频使用的摘要和索引。 - 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。 diff --git a/.hermes/shared-memory/team-conventions.md b/.hermes/shared-memory/team-conventions.md index 3c3cc18f..dbb357a4 100644 --- a/.hermes/shared-memory/team-conventions.md +++ b/.hermes/shared-memory/team-conventions.md @@ -15,6 +15,7 @@ - `.hermes/shared-memory/` 团队级长期记忆 - `.hermes/plans/` 阶段性实施计划 +- `.hermes/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划 - `.hermes/skills/` 未来可复用仓库级 skills - `docs/` 中 PRD、设计、技术、经验、审计、查询手册 - `AGENTS.md` 项目级 Agent 约束 @@ -41,6 +42,7 @@ - 保持修改范围聚焦,不做无关重构。 - 复用、修改、扩展现有系统优先,避免新建重复系统或页面。 +- 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;只在任务需要时重命名历史文档,避免无关大 diff。 - 涉及中文文本时注意 UTF-8 编码和乱码排查。 - 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。 - `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run api-server` 与 `/healthz`。 @@ -51,9 +53,10 @@ 1. 运行与修改范围匹配的测试或验证命令。 2. 更新相关 `docs/` 文档。 -3. 若产生长期有效知识,更新 `.hermes/shared-memory/`。 -4. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。 -5. 在提交信息中区分代码变更与文档/记忆变更。 +3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。 +4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。 +5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。 +6. 在提交信息中区分代码变更与文档/记忆变更。 ## 文档阅读顺序 diff --git a/.hermes/todos/README.md b/.hermes/todos/README.md new file mode 100644 index 00000000..04a11d76 --- /dev/null +++ b/.hermes/todos/README.md @@ -0,0 +1,15 @@ +# 项目共享 TODO 计划 + +本目录用于存放已经讨论定稿、需要后续执行,但当前尚未实施的项目级计划文档。 + +## 使用规则 + +- 每个 TODO 计划单独一个 Markdown 文件,文件名优先使用可检索的中文标题与日期。 +- 每个 TODO 计划文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。 +- 计划内容必须足够明确,后续开发者可以直接据此开始实施。 +- 已执行完成的计划应迁移到对应 `docs/` 技术/规划文档,或在本文档中标记完成并移出 TODO 队列。 +- 不在这里保存个人私密路径、密钥、临时聊天记录或未确认猜测。 + +## 当前待执行 + +- [【后端架构】api-server 能力模块化与图片资产 Adapter 收口计划](./【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md) diff --git a/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md b/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md new file mode 100644 index 00000000..e076e786 --- /dev/null +++ b/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md @@ -0,0 +1,54 @@ +# api-server 能力模块化与图片资产 Adapter 收口计划 + +状态:待执行 + +## Summary + +把两份计划合并成一个分阶段重构:先把 `api-server` 从扁平 `src/*.rs` 收成按能力组织的 Module + Router 结构;同时把散落在玩法 handler 里的“图片生成 -> 下载 -> OSS 写入/head -> asset_object 确认 -> entity binding”收成 `generated_image_assets` 深 Module。 + +本轮不改变 HTTP contract、DTO、前端行为、SpacetimeDB schema 或计费语义。 + +## Key Changes + +- 新增 `server-rs/crates/api-server/src/modules/`,每个能力 Module 暴露 `router(state) -> Router`,`app.rs` 只负责全局 middleware 和 `.merge(...)`。 +- 能力分组建议:`admin`、`auth`、`assets`、`creation`、`runtime`、`platform`,核心基础设施继续保留在根层或 `core`。 +- 在 `modules/assets/generated_image_assets` 新增内部 Adapter Module,Interface 固定为“生成并入库”: + - 输入包含 provider、prompt、size、参考图、OSS 路径、asset kind、entity kind/id、slot、owner/profile/source_job_id。 + - 输出包含 `asset_object_id`、`legacy_public_path`、`object_key`、mime/extension、task_id/actual_prompt。 +- `asset_billing.rs` 不改语义;调用方继续在外层显式包 `execute_billable_asset_operation*`,图片 Adapter 不读写钱包。 +- 首批迁移稳定单图链路:Big Fish 正式图片、Square Hole 图片、Custom World 场景图/封面类图片入库路径。 +- 暂不迁移 Puzzle VectorEngine edits/multipart、Match3D 5x5 sheet/切图/绿幕后处理,以及音频、视频、GLB 转存链路。 + +## Implementation Notes + +- `generated_image_assets` 内部集中处理 OSS 缺配置、下载图片 mime/extension 归一、空内容/上游错误映射、`put_object -> head_object -> confirm_asset_object -> bind_asset_object_to_entity`。 +- 调用方玩法文件只保留领域上下文拼装、prompt 构造、计费包裹和响应映射。 +- `openai_image_generation.rs` 继续保留底层 VectorEngine 能力;本轮只让新 Adapter 封装“provider 结果 + OSS/asset_object 持久化”的更深 Interface。 +- 第一阶段不拆 `match3d.rs`、`puzzle.rs`、`custom_world.rs` 等超大 handler 内部实现;后续再单独切第二阶段。 + +## Docs + +- 新增或更新 `server-rs/crates/api-server/README.md`,说明能力 Module 目录规则、Router 暴露规则和 Adapter 边界。 +- 新增 `docs/technical` 说明,并挂到 `docs/technical/README.md`。 +- 文档明确:`api-server` 仍是 HTTP/SSE/BFF Adapter 与平台编排层,不承接领域规则主逻辑。 + +## Test Plan + +- 结构与路由回归: + - `cargo check -p api-server --manifest-path server-rs/Cargo.toml` + - `cargo test -p api-server --manifest-path server-rs/Cargo.toml` + - `npm run check:server-rs-ddd` + - `npm run check:encoding` + - `git diff --check` +- 图片 Adapter 单测覆盖 OSS 缺配置、asset object/binding 输入构造、URL/base64 结果归一、上游错误和空图片内容映射。 +- 迁移点回归: + - `cargo test -p api-server big_fish --manifest-path server-rs/Cargo.toml` + - `cargo test -p api-server square_hole --manifest-path server-rs/Cargo.toml` + - `cargo test -p api-server custom_world --manifest-path server-rs/Cargo.toml` +- API smoke 使用 `npm run api-server` 并检查 `/healthz`;不使用 `api-server:maincloud`。 + +## Assumptions + +- 本轮目标是提升 locality、降低重复和减少 `app.rs` 路由树压力。 +- `generated_image_assets` 先留在 `api-server` 内部,不新增 workspace crate。 +- Match3D、Puzzle 的复杂图片链路作为第二阶段迁移,不阻塞第一阶段收口。 diff --git a/AGENTS.md b/AGENTS.md index 6970172b..4a13fb06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ Single-context layout: read root `CONTEXT.md` when present and architecture deci - 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 - 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 +- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;例如 `【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md`。不要求批量重命名历史文档,除非本次任务明确涉及该文档。 - 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 - 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。 - 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。 diff --git a/docs/README.md b/docs/README.md index 7ead8697..4b9a3b5f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,3 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ - `reference/`:偏目录、速查、检索辅助。 - `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。 - `operations/`:偏后台运营核查、对账和排障查询。 + +## 文档命名规则 + +后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。标签用于跨目录检索,不替代上面的目录分类;历史文档不要求批量重命名,除非本次任务明确涉及该文档。 From 4ba1ebbbdfa17f497120b70d4171b36fcdae337e Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 16:12:28 +0800 Subject: [PATCH 02/11] docs: expand api-server modularization plan --- ...力模块化与图片资产Adapter收口计划-2026-05-14.md | 118 +++++++++++++----- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md b/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md index e076e786..1c695ead 100644 --- a/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md +++ b/.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md @@ -1,54 +1,108 @@ -# api-server 能力模块化与图片资产 Adapter 收口计划 +# api-server 能力模块化与生成资产 Adapter 完整收口计划 状态:待执行 ## Summary -把两份计划合并成一个分阶段重构:先把 `api-server` 从扁平 `src/*.rs` 收成按能力组织的 Module + Router 结构;同时把散落在玩法 handler 里的“图片生成 -> 下载 -> OSS 写入/head -> asset_object 确认 -> entity binding”收成 `generated_image_assets` 深 Module。 +目标是把 `api-server` 从“超大 `app.rs` + 多个超大 handler 文件 + 多处生成资产重复链路”收成可长期维护的能力 Module 结构。 -本轮不改变 HTTP contract、DTO、前端行为、SpacetimeDB schema 或计费语义。 +本计划完整覆盖:路由能力模块化、生成图片资产 Adapter、复杂媒体链路扩展、大 handler 瘦身、文档与验收。全程不改变 HTTP contract、DTO、SpacetimeDB schema、前端行为和计费语义,除非某阶段文档先明确提出并单独批准。 -## Key Changes +## 文档交付 -- 新增 `server-rs/crates/api-server/src/modules/`,每个能力 Module 暴露 `router(state) -> Router`,`app.rs` 只负责全局 middleware 和 `.merge(...)`。 -- 能力分组建议:`admin`、`auth`、`assets`、`creation`、`runtime`、`platform`,核心基础设施继续保留在根层或 `core`。 -- 在 `modules/assets/generated_image_assets` 新增内部 Adapter Module,Interface 固定为“生成并入库”: - - 输入包含 provider、prompt、size、参考图、OSS 路径、asset kind、entity kind/id、slot、owner/profile/source_job_id。 - - 输出包含 `asset_object_id`、`legacy_public_path`、`object_key`、mime/extension、task_id/actual_prompt。 -- `asset_billing.rs` 不改语义;调用方继续在外层显式包 `execute_billable_asset_operation*`,图片 Adapter 不读写钱包。 -- 首批迁移稳定单图链路:Big Fish 正式图片、Square Hole 图片、Custom World 场景图/封面类图片入库路径。 -- 暂不迁移 Puzzle VectorEngine edits/multipart、Match3D 5x5 sheet/切图/绿幕后处理,以及音频、视频、GLB 转存链路。 +先新增一个总纲,再按阶段新增单独执行文档: -## Implementation Notes +- 总纲:`docs/technical/【后端架构】api-server能力模块化与生成资产Adapter总纲-2026-05-14.md` +- 阶段 1:`docs/technical/【后端架构】api-server路由能力模块化执行计划-2026-05-14.md` +- 阶段 2:`docs/technical/【后端架构】生成图片资产Adapter收口执行计划-2026-05-14.md` +- 阶段 3:`docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md` +- 阶段 4:`docs/technical/【后端架构】api-server大Handler瘦身执行计划-2026-05-14.md` +- 同步更新 `server-rs/crates/api-server/README.md`、`docs/technical/README.md`、本 TODO 文档。 -- `generated_image_assets` 内部集中处理 OSS 缺配置、下载图片 mime/extension 归一、空内容/上游错误映射、`put_object -> head_object -> confirm_asset_object -> bind_asset_object_to_entity`。 -- 调用方玩法文件只保留领域上下文拼装、prompt 构造、计费包裹和响应映射。 -- `openai_image_generation.rs` 继续保留底层 VectorEngine 能力;本轮只让新 Adapter 封装“provider 结果 + OSS/asset_object 持久化”的更深 Interface。 -- 第一阶段不拆 `match3d.rs`、`puzzle.rs`、`custom_world.rs` 等超大 handler 内部实现;后续再单独切第二阶段。 +## 阶段 0:基线盘点与总纲冻结 -## Docs +- 盘点 `app.rs` 当前全部 route,按 `admin/auth/assets/profile/creation/runtime/story/platform/internal` 分类形成 route inventory。 +- 盘点 Big Fish、Square Hole、Custom World、Puzzle、Match3D、Visual Novel、音频、视频、GLB 的生成资产链路,标出 provider、下载方式、OSS prefix、asset kind、entity binding、计费位置、降级行为。 +- 在总纲文档中冻结边界:`api-server` 只做 HTTP/SSE/BFF、鉴权、DTO 映射、平台服务编排;领域规则仍归 `module-*`,SpacetimeDB 真相仍归 `spacetime-module`。 -- 新增或更新 `server-rs/crates/api-server/README.md`,说明能力 Module 目录规则、Router 暴露规则和 Adapter 边界。 -- 新增 `docs/technical` 说明,并挂到 `docs/technical/README.md`。 -- 文档明确:`api-server` 仍是 HTTP/SSE/BFF Adapter 与平台编排层,不承接领域规则主逻辑。 +退出条件:总纲和 inventory 能指导后续编码,不再只是一段方向描述。 -## Test Plan +## 阶段 1:路由能力模块化完整收口 -- 结构与路由回归: +- 新增 `server-rs/crates/api-server/src/modules/`,每个能力 Module 暴露 `router(state) -> Router`。 +- 第一批迁移低风险路由:`admin`、`auth`、`assets`、`profile`、`internal`、`health`。 +- 第二批迁移平台编排路由:`ai_tasks`、`llm`、`speech`、`wechat`、`creative_agent`、`visual_novel`、通用 audio。 +- 第三批迁移玩法路由:`big_fish`、`square_hole`、`puzzle`、`match3d`、`custom_world`、`story`、runtime save/settings/chat/inventory。 +- `app.rs` 最终只保留全局 middleware、TraceLayer、request context、tracking middleware、`.merge(modules::*::router(...))` 和少量顶层 glue。 +- handler 实现第一阶段可以继续留在原文件;本阶段只改变路由装配位置,不混入业务重构。 + +验收:route inventory 中所有原 route 仍存在;旧明确下线 route 继续 404;`cargo test -p api-server app --manifest-path server-rs/Cargo.toml` 或等价 route 回归通过。 + +## 阶段 2:稳定单图生成资产 Adapter 收口 + +- 新增 `modules/assets/generated_image_assets` 内部 Module,Interface 覆盖“provider 生成 -> 下载/base64 解码 -> MIME/extension 归一 -> OSS private upload -> HEAD -> asset_object confirm -> entity binding”。 +- Adapter 输入包含 provider、prompt、negative prompt、size、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source_job_id、metadata、可选透明背景后处理。 +- Adapter 输出包含 `legacy_public_path`、`object_key`、`asset_object_id`、`mime_type`、`extension`、`task_id`、`actual_prompt`。 +- 首批迁移: + - Big Fish 正式图片:主图、动作图、舞台背景。 + - Square Hole 图片:保留生成成功但入库失败时回退 Data URL。 + - Custom World 场景图、自动草稿场景图、生成封面图。 +- `asset_billing.rs` 仍由调用方显式包裹;Adapter 不扣费、不退款、不读钱包。 + +验收:三类调用方都经过同一 Adapter;删除旧重复 persist 函数后行为不变;Big Fish、Square Hole、Custom World 定向测试通过。 + +## 阶段 3:复杂媒体资产链路扩展 + +- 扩展 Adapter,但不把玩法图像处理规则塞进公共 Interface。 +- Puzzle: + - 收口普通 generations、edits/multipart 生成结果的下载、OSS、asset object、binding。 + - 拼图关卡 JSON 更新、参考图策略、UI 背景落位仍留在 Puzzle 编排层。 +- Match3D: + - APIMart material sheet、VectorEngine 背景/容器/封面生成接入统一入库能力。 + - 5x5 切图、绿幕透明化、格内校准、批量新增补齐规则仍留在 Match3D 专属处理器。 +- 音频: + - 评估是否抽 `generated_media_assets`,但背景音乐、点击音效的计费和落位语义不与图片 Adapter 混用。 +- GLB/视频: + - 仅历史转存链路复用“OSS + asset_object + binding”底层持久化能力,不恢复新草稿 GLB 生产。 + +验收:Puzzle 与 Match3D 的 generated 私有资产仍通过 `/api/assets/read-url` 换签读取;Match3D 不回退 Rodin/GLB;音频试听和运行态仍可播放。 + +## 阶段 4:超大 handler 能力内瘦身 + +- 在路由已模块化、资产 Adapter 已稳定后,再拆大文件,避免同时改 route 和业务实现。 +- 对 `match3d.rs`、`puzzle.rs`、`custom_world.rs`、`custom_world_ai.rs`、`big_fish.rs`、`square_hole.rs` 分别按能力拆: + - `router.rs` 只挂路由。 + - `handlers.rs` 只做 Axum extract、鉴权、request/response。 + - `application.rs` 做 api-server 层编排。 + - `assets.rs` 只放玩法专属生成资产策略。 + - `mapper.rs` 只做 DTO/record 映射。 + - `errors.rs` 只做该能力错误映射。 +- 不把领域规则留在 handler;发现领域规则时只登记迁出候选,不在本阶段直接扩大到 `module-*` 重构。 + +验收:每个原超大文件显著缩小;新文件按能力可读;定向玩法测试和全量 `api-server` 测试通过。 + +## 阶段 5:清理、文档和最终验收 + +- 删除旧重复 helper、过时注释和已迁移的私有函数。 +- 更新 `api-server` README:目录规则、Router 暴露规则、Adapter 边界、禁止事项。 +- 更新 `.hermes/shared-memory` 中长期有效的架构约定和排障经验。 +- 最终验收命令: - `cargo check -p api-server --manifest-path server-rs/Cargo.toml` - `cargo test -p api-server --manifest-path server-rs/Cargo.toml` - `npm run check:server-rs-ddd` - `npm run check:encoding` - `git diff --check` -- 图片 Adapter 单测覆盖 OSS 缺配置、asset object/binding 输入构造、URL/base64 结果归一、上游错误和空图片内容映射。 -- 迁移点回归: - - `cargo test -p api-server big_fish --manifest-path server-rs/Cargo.toml` - - `cargo test -p api-server square_hole --manifest-path server-rs/Cargo.toml` - - `cargo test -p api-server custom_world --manifest-path server-rs/Cargo.toml` -- API smoke 使用 `npm run api-server` 并检查 `/healthz`;不使用 `api-server:maincloud`。 + - `npm run api-server` 后检查 `/healthz` +- 禁止使用 `api-server:maincloud` 作为本轮 smoke。 + +## Public Interfaces + +- HTTP route、DTO、error envelope、SpacetimeDB schema、前端调用方式默认不变。 +- 新增的都是 `api-server` 内部 Rust Interface,不进入 `shared-contracts`。 +- 若后续任何阶段发现必须改 contract,先更新对应阶段文档和 G1 route/contract 矩阵,再单独实施。 ## Assumptions -- 本轮目标是提升 locality、降低重复和减少 `app.rs` 路由树压力。 -- `generated_image_assets` 先留在 `api-server` 内部,不新增 workspace crate。 -- Match3D、Puzzle 的复杂图片链路作为第二阶段迁移,不阻塞第一阶段收口。 +- 本计划目标是完整收口,不是只完成第一阶段。 +- 可以分阶段提交,但每个阶段都必须有文档、测试和明确退出条件。 +- `generated_image_assets` 首版必须至少被三个真实调用方使用,否则不算形成有效 Module。 From 514365fdece9a8c130f474d5c685eb055b876187 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 20:07:03 +0800 Subject: [PATCH 03/11] fix(admin): decode recharge order enum cells --- .hermes/shared-memory/pitfalls.md | 8 + .../ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md | 2 +- server-rs/crates/api-server/src/admin.rs | 141 +++++++++++++++++- 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 5be6e6a2..208b1761 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -622,6 +622,14 @@ - 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。 - 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 +## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码 + +- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 +- 原因:SpacetimeDB HTTP SQL 对无载荷枚举会返回 SATS 形态 `[variant_index, []]`;后台通用 normalizer 曾把任何 `[0, value]` 都当作 `Option::Some(value)` 展开,导致 `[0, []]` 最终只剩 `[]`。 +- 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 `profile_recharge_order.kind` 映射为 `points` / `membership`,`profile_recharge_order.status` 映射为 `pending` / `paid` / `failed` / `closed` / `refunded`。 +- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。 +- 关联:`server-rs/crates/api-server/src/admin.rs`、`docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`。 + ## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布 - 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。 diff --git a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md index 8ed5c4af..1de20f68 100644 --- a/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md +++ b/docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md @@ -70,7 +70,7 @@ Query: - SQL 固定为 `SELECT * FROM {tableName} LIMIT {limit}`;SpacetimeDB 2.2 HTTP SQL 不拼 `ORDER BY`。 - 用户输入不直接拼入 SQL;关键词和条件在 API Server 内存中过滤。 - private 表或 token 不可见时返回后台可读错误信息。 -- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值,enum 可保留 tag/name 或原始数组文本。 +- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值;已知业务枚举列应在 API Server 按表名和列名转换为业务字符串,例如 `profile_recharge_order.kind` 转为 `points` / `membership`,`profile_recharge_order.status` 转为 `pending` / `paid` / `failed` / `closed` / `refunded`。 ## 前端页面 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 739cdb41..cd72a6d0 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -725,7 +725,7 @@ fn parse_admin_database_table_rows_sql_response( .ok_or_else(|| "SQL rows 字段格式非法".to_string())?; let rows = row_values .iter() - .map(|row| build_admin_database_table_row(row, &columns)) + .map(|row| build_admin_database_table_row_for_table(table_name, row, &columns)) .collect::>(); Ok(AdminDatabaseTableRowsResponse { table_name: table_name.to_string(), @@ -769,7 +769,15 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec { } fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { - let raw = normalize_admin_database_value(row); + build_admin_database_table_row_for_table("", row, columns) +} + +fn build_admin_database_table_row_for_table( + table_name: &str, + row: &Value, + columns: &[String], +) -> AdminDatabaseTableRowPayload { + let raw = normalize_admin_database_table_row_raw(table_name, row, columns); let mut cells = Map::new(); if let Some(values) = row.as_array() { for (index, value) in values.iter().enumerate() { @@ -777,11 +785,17 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab .get(index) .cloned() .unwrap_or_else(|| format!("col_{}", index + 1)); - cells.insert(key, normalize_admin_database_value(value)); + cells.insert( + key.clone(), + normalize_admin_database_table_cell(table_name, &key, value), + ); } } else if let Some(object) = row.as_object() { for (key, value) in object { - cells.insert(key.clone(), normalize_admin_database_value(value)); + cells.insert( + key.clone(), + normalize_admin_database_table_cell(table_name, key, value), + ); } } AdminDatabaseTableRowPayload { @@ -790,6 +804,85 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab } } +fn normalize_admin_database_table_row_raw( + table_name: &str, + row: &Value, + columns: &[String], +) -> Value { + if let Some(values) = row.as_array() { + return Value::Array( + values + .iter() + .enumerate() + .map(|(index, value)| { + let key = columns.get(index).map(String::as_str).unwrap_or_default(); + normalize_admin_database_table_cell(table_name, key, value) + }) + .collect(), + ); + } + + if let Some(object) = row.as_object() { + return Value::Object( + object + .iter() + .map(|(key, value)| { + ( + key.clone(), + normalize_admin_database_table_cell(table_name, key, value), + ) + }) + .collect(), + ); + } + + normalize_admin_database_value(row) +} + +fn normalize_admin_database_table_cell( + table_name: &str, + column_name: &str, + value: &Value, +) -> Value { + if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) { + return enum_value; + } + normalize_admin_database_value(value) +} + +fn normalize_admin_database_known_enum( + table_name: &str, + column_name: &str, + value: &Value, +) -> Option { + let variant_index = extract_sats_enum_variant_index(value)?; + let label = match (table_name, column_name) { + ("profile_recharge_order", "kind") => match variant_index { + 0 => "points", + 1 => "membership", + _ => return None, + }, + ("profile_recharge_order", "status") => match variant_index { + 0 => "pending", + 1 => "paid", + 2 => "failed", + 3 => "closed", + 4 => "refunded", + _ => return None, + }, + _ => return None, + }; + Some(Value::String(label.to_string())) +} + +fn extract_sats_enum_variant_index(value: &Value) -> Option { + let items = value.as_array()?; + if items.len() != 2 { + return None; + } + items.first()?.as_u64() +} + fn normalize_admin_database_value(value: &Value) -> Value { match value { Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]), @@ -1526,6 +1619,46 @@ mod tests { assert_eq!(response.rows[0].cells["points"], json!(12)); } + #[test] + fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() { + let payload = json!([ + { + "schema": { + "elements": [ + {"name": {"some": "order_id"}}, + {"name": {"some": "kind"}}, + {"name": {"some": "status"}}, + {"name": {"some": "paid_at"}} + ] + }, + "rows": [[ + "recharge:user_00000001:1778757456811099:points_60", + [0, []], + [0, []], + [1, []] + ]] + } + ]); + + let response = + parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload) + .expect("recharge order rows should parse"); + + let cells = &response.rows[0].cells; + assert_eq!(cells["kind"], json!("points")); + assert_eq!(cells["status"], json!("pending")); + assert_eq!(cells["paid_at"], json!(null)); + assert_eq!( + response.rows[0].raw, + json!([ + "recharge:user_00000001:1778757456811099:points_60", + "points", + "pending", + null + ]) + ); + } + #[test] fn build_admin_database_table_row_normalizes_optional_sats_values() { let row = build_admin_database_table_row( From 5c5a8d4a40be5ac68e9123470eafa7325529d908 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 20:22:49 +0800 Subject: [PATCH 04/11] fix: enforce WeChat Pay JSAPI field limits --- ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 2 +- server-rs/crates/api-server/src/wechat_pay.rs | 165 +++++++++++++++++- .../crates/module-runtime/src/application.rs | 45 ++++- server-rs/crates/module-runtime/src/lib.rs | 10 +- 4 files changed, 213 insertions(+), 9 deletions(-) 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 abc57c4f..b19f64ff 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 @@ -64,7 +64,7 @@ 1. 校验 `productId` 2. `paymentChannel = "mock"` 时后端创建已支付订单 -3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数 +3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态 5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index cf8e38ae..aca3825e 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -26,6 +26,12 @@ 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 = ""; +const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32; +const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; +const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; +const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32; +const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255; +const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128; #[derive(Clone, Debug)] pub enum WechatPayClient { @@ -77,7 +83,6 @@ pub enum WechatPayError { } #[derive(Serialize)] -#[serde(rename_all = "camelCase")] struct WechatJsapiOrderRequest<'a> { appid: &'a str, mchid: &'a str, @@ -196,6 +201,7 @@ impl WechatPayClient { config.wechat_pay_notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL", )?; + validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?; let jsapi_endpoint = normalize_required_url( &config.wechat_pay_jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT", @@ -244,6 +250,7 @@ impl RealWechatPayClient { &self, request: WechatMiniProgramOrderRequest, ) -> Result { + validate_jsapi_order_request(self, &request)?; let amount_total = i64::try_from(request.amount_cents) .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; let body = serde_json::to_string(&WechatJsapiOrderRequest { @@ -595,6 +602,105 @@ fn normalize_required_url(value: &str, key: &str) -> Result Result<(), WechatPayError> { + if value.chars().count() > WECHAT_PAY_NOTIFY_URL_MAX_CHARS { + return Err(WechatPayError::InvalidConfig(format!( + "{key} 不能超过 {WECHAT_PAY_NOTIFY_URL_MAX_CHARS} 字符" + ))); + } + if value.contains('?') || value.contains('#') { + return Err(WechatPayError::InvalidConfig(format!( + "{key} 不能包含 query 或 fragment" + ))); + } + Ok(()) +} + +fn validate_jsapi_order_request( + client: &RealWechatPayClient, + request: &WechatMiniProgramOrderRequest, +) -> Result<(), WechatPayError> { + validate_non_empty_max_chars( + &client.app_id, + WECHAT_PAY_APP_ID_MAX_CHARS, + "微信支付 appid", + )?; + if !client.app_id.starts_with("wx") { + return Err(WechatPayError::InvalidConfig( + "微信支付 appid 必须使用小程序 AppID".to_string(), + )); + } + validate_non_empty_max_chars( + &client.mch_id, + WECHAT_PAY_MCH_ID_MAX_CHARS, + "微信支付 mchid", + )?; + if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) { + return Err(WechatPayError::InvalidConfig( + "微信支付 mchid 必须是数字字符串".to_string(), + )); + } + + validate_non_empty_max_chars( + &request.description, + WECHAT_PAY_DESCRIPTION_MAX_CHARS, + "微信支付商品描述", + )?; + validate_out_trade_no(&request.order_id)?; + if request.amount_cents == 0 { + return Err(WechatPayError::InvalidRequest( + "微信支付金额必须大于 0 分".to_string(), + )); + } + validate_non_empty_max_chars( + &request.payer_openid, + WECHAT_PAY_OPENID_MAX_CHARS, + "微信支付 payer.openid", + )?; + Ok(()) +} + +fn validate_non_empty_max_chars( + value: &str, + max_chars: usize, + field_name: &str, +) -> Result<(), WechatPayError> { + let value = value.trim(); + if value.is_empty() { + return Err(WechatPayError::InvalidRequest(format!( + "{field_name} 不能为空" + ))); + } + if value.chars().count() > max_chars { + return Err(WechatPayError::InvalidRequest(format!( + "{field_name} 不能超过 {max_chars} 字符" + ))); + } + Ok(()) +} + +fn validate_out_trade_no(value: &str) -> Result<(), WechatPayError> { + validate_non_empty_max_chars( + value, + WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS, + "微信支付 out_trade_no", + )?; + if value.chars().count() < 6 { + return Err(WechatPayError::InvalidRequest( + "微信支付 out_trade_no 不能少于 6 字符".to_string(), + )); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*')) + { + return Err(WechatPayError::InvalidRequest( + "微信支付 out_trade_no 只能包含数字、大小写字母、_、-、|、*".to_string(), + )); + } + Ok(()) +} + fn read_private_key_pem( inline_pem: Option<&str>, path: Option<&Path>, @@ -768,6 +874,63 @@ mod tests { assert!(!params.pay_sign.is_empty()); } + #[test] + fn jsapi_order_request_uses_wechat_v3_snake_case_fields() { + let body = serde_json::to_value(WechatJsapiOrderRequest { + appid: "wx-test-app", + mchid: "1900000001", + description: "陶泥儿 - 60泥点", + out_trade_no: "rcgtest001", + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify", + amount: WechatJsapiAmount { + total: 600, + currency: "CNY", + }, + payer: WechatJsapiPayer { + openid: "openid-test", + }, + }) + .expect("JSAPI order request should serialize"); + + assert_eq!(body["out_trade_no"], "rcgtest001"); + assert_eq!( + body["notify_url"], + "https://api.example.com/api/profile/recharge/wechat/notify" + ); + assert!(body.get("outTradeNo").is_none()); + assert!(body.get("notifyUrl").is_none()); + } + + #[test] + fn jsapi_order_request_rejects_provider_field_limit_violations() { + assert!(validate_out_trade_no("abc12").is_err()); + assert!(validate_out_trade_no("abc123").is_ok()); + assert!(validate_out_trade_no("abc123_-|*").is_ok()); + assert!(validate_out_trade_no("abc123中文").is_err()); + assert!(validate_out_trade_no("a".repeat(33).as_str()).is_err()); + + assert!(validate_notify_url("https://api.example.com/pay/notify", "notify").is_ok()); + assert!(validate_notify_url("https://api.example.com/pay/notify?x=1", "notify").is_err()); + assert!(validate_notify_url(&format!("https://{}", "a".repeat(248)), "notify").is_err()); + + validate_non_empty_max_chars("陶泥儿 - 60泥点", WECHAT_PAY_DESCRIPTION_MAX_CHARS, "描述") + .expect("short description should pass"); + assert!( + validate_non_empty_max_chars( + &"泥".repeat(128), + WECHAT_PAY_DESCRIPTION_MAX_CHARS, + "描述" + ) + .is_err() + ); + validate_non_empty_max_chars("openid-test", WECHAT_PAY_OPENID_MAX_CHARS, "openid") + .expect("short openid should pass"); + assert!( + validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid") + .is_err() + ); + } + #[test] fn parse_mock_notify_defaults_success_state() { let notify = diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 53336179..829719be 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -927,10 +927,47 @@ pub fn build_runtime_profile_recharge_order_id( created_at_micros: i64, product_id: &str, ) -> String { - format!( - "recharge:{}", - build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id) - ) + // 微信支付 v3 的 out_trade_no 只接受较短的字母、数字和部分符号。 + // 订单号同时作为本地 profile_recharge_order 主键,因此统一使用可支付渠道兼容的紧凑格式。 + let timestamp = encode_runtime_profile_recharge_order_base36(created_at_micros.unsigned_abs()); + let hash = hash_runtime_profile_recharge_order_key(user_id, product_id, created_at_micros); + format!("rcg{timestamp}{:010x}", hash & 0x0000_0003_ffff_ffff) +} + +fn encode_runtime_profile_recharge_order_base36(mut value: u64) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if value == 0 { + return "0".to_string(); + } + + let mut buffer = Vec::new(); + while value > 0 { + buffer.push(DIGITS[(value % 36) as usize] as char); + value /= 36; + } + buffer.iter().rev().collect() +} + +fn hash_runtime_profile_recharge_order_key( + user_id: &str, + product_id: &str, + created_at_micros: i64, +) -> u64 { + let mut hash = 14_695_981_039_346_656_037u64; + for byte in user_id + .trim() + .as_bytes() + .iter() + .copied() + .chain([b':']) + .chain(product_id.trim().as_bytes().iter().copied()) + .chain([b':']) + .chain(created_at_micros.to_le_bytes()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(1_099_511_628_211); + } + hash } pub fn resolve_runtime_profile_points_recharge_delta( diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 9a385879..3f8eea42 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -683,9 +683,13 @@ mod tests { build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"), "user-1:200:points_60" ); - assert_eq!( - build_runtime_profile_recharge_order_id("user-1", 200, "points_60"), - "recharge:user-1:200:points_60" + let order_id = build_runtime_profile_recharge_order_id("user-1", 200, "points_60"); + assert!(order_id.starts_with("rcg")); + assert!(order_id.len() <= 32); + assert!( + order_id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) ); assert_eq!( build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2), From bca439726d1c2c24870e6f617d9a088f5458088a Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 20:51:32 +0800 Subject: [PATCH 05/11] fix wechat pay request headers --- .hermes/shared-memory/pitfalls.md | 9 +++ ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 2 + server-rs/crates/api-server/src/wechat_pay.rs | 77 +++++++++++++++---- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 208b1761..e321b193 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -619,9 +619,18 @@ - 现象:微信小程序支付下单能返回 `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`。 +- APIv3 通知成功应答使用 HTTP `204 No Content`,不要沿用 V2 XML 成功报文;失败仍返回 4XX/5XX 让微信重试。 - 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。 - 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 +## 微信支付 JSAPI 下单必须显式带 User-Agent + +- 现象:调用 `/v3/pay/transactions/jsapi` 失败,微信返回“Http头缺少Accept或User-Agent”。 +- 原因:`reqwest` 请求即使已设置 `Accept: application/json`,也不会默认附带业务侧 `User-Agent`;微信支付网关会校验这两个头。 +- 处理:`api-server` 的 JSAPI 下单请求统一通过 `with_wechat_pay_jsapi_headers(...)` 设置 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`。 +- 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 + ## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码 - 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 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 b19f64ff..987b0df8 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 @@ -65,6 +65,7 @@ 1. 校验 `productId` 2. `paymentChannel = "mock"` 时后端创建已支付订单 3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 + - JSAPI 下单请求必须显式携带 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。 4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态 5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` @@ -94,6 +95,7 @@ 4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。 5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。 6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。 +7. 验签、解密和业务确认通过后返回 HTTP `204 No Content`;不要返回 V2 XML。 关键环境变量: diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index aca3825e..e14532e5 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -25,7 +25,9 @@ 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 = ""; +const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json"; +const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json"; +const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0"; const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; @@ -277,18 +279,17 @@ impl RealWechatPayClient { &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 response = with_wechat_pay_jsapi_headers( + self.client + .post(&self.jsapi_endpoint) + .header("Authorization", authorization), + ) + .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}")) @@ -438,7 +439,7 @@ pub async fn handle_wechat_pay_notify( State(state): State, headers: HeaderMap, body: Bytes, -) -> Result<&'static str, AppError> { +) -> Result { let notify = state .wechat_pay_client() .parse_notify(&headers, &body) @@ -449,7 +450,7 @@ pub async fn handle_wechat_pay_notify( trade_state = notify.trade_state.as_str(), "收到非成功微信支付通知" ); - return Ok(WECHAT_PAY_NOTIFY_SUCCESS); + return Ok(StatusCode::NO_CONTENT); } let paid_at_micros = notify @@ -476,7 +477,7 @@ pub async fn handle_wechat_pay_notify( "微信支付通知已确认订单入账" ); - Ok(WECHAT_PAY_NOTIFY_SUCCESS) + Ok(StatusCode::NO_CONTENT) } pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { @@ -532,6 +533,16 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { map_wechat_pay_error(error) } +fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder + .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) + .header( + reqwest::header::CONTENT_TYPE, + WECHAT_PAY_CONTENT_TYPE_HEADER, + ) + .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) +} + 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(); @@ -931,6 +942,40 @@ mod tests { ); } + #[test] + fn jsapi_order_request_sets_wechat_required_http_headers() { + let request = with_wechat_pay_jsapi_headers( + reqwest::Client::new() + .post("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi") + .header( + "Authorization", + "WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"", + ), + ) + .build() + .expect("request should build"); + + let headers = request.headers(); + assert_eq!( + headers + .get(reqwest::header::ACCEPT) + .and_then(|value| value.to_str().ok()), + Some(WECHAT_PAY_ACCEPT_HEADER) + ); + assert_eq!( + headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some(WECHAT_PAY_CONTENT_TYPE_HEADER) + ); + assert_eq!( + headers + .get(reqwest::header::USER_AGENT) + .and_then(|value| value.to_str().ok()), + Some(WECHAT_PAY_USER_AGENT) + ); + } + #[test] fn parse_mock_notify_defaults_success_state() { let notify = From cf3dcc61955815f6b2a5c0b6de243d0145746382 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 21:12:37 +0800 Subject: [PATCH 06/11] fix mini program payment bridge --- ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 1 + .../RpgEntryHomeView.recharge.test.tsx | 82 +++++++++++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 55 +++++++++++-- src/vite-env.d.ts | 1 + 4 files changed, 132 insertions(+), 7 deletions(-) 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 987b0df8..79af8322 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 @@ -117,6 +117,7 @@ 2. 弹窗顶部标题为 `账户充值`,右上角关闭。 3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 + - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。 6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 45b84597..1617b4a2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -859,6 +859,10 @@ afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); + window.wx = undefined; + document + .querySelectorAll('script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]') + .forEach((script) => script.remove()); mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockBuildReferralCenter(), ); @@ -1044,6 +1048,84 @@ test('profile recharge modal posts requestPayment params in mini program web-vie expect(await screen.findByText('支付已提交')).toBeTruthy(); }); +test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { + const user = userEvent.setup(); + window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); + window.wx = undefined; + 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')); + }); + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-sdk-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(() => { + const script = document.querySelector( + 'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]', + ); + expect(script).toBeTruthy(); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + script?.dispatchEvent(new Event('load')); + }); + + await waitFor(() => { + expect(navigateTo).toHaveBeenCalledWith({ + url: expect.stringContaining('/pages/wechat-pay/index?'), + fail: expect.any(Function), + }); + }); + 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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 764983a4..62b1b746 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -211,6 +211,7 @@ 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'; +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type RechargeTab = 'points' | 'membership'; @@ -2341,16 +2342,56 @@ function clearWechatPayResultHash() { window.history.replaceState(null, '', nextUrl); } -function requestWechatMiniProgramPayment( +function loadWechatJsSdk() { + if (typeof window === 'undefined') { + return Promise.reject(new Error('请在微信小程序内完成支付')); + } + if (window.wx?.miniProgram?.navigateTo) { + return Promise.resolve(window.wx); + } + + return new Promise>((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${WECHAT_JS_SDK_URL}"]`, + ); + const complete = () => { + if (window.wx?.miniProgram?.navigateTo) { + resolve(window.wx); + } else { + reject(new Error('请在微信小程序内完成支付')); + } + }; + + if (existingScript) { + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener( + 'error', + () => reject(new Error('请在微信小程序内完成支付')), + { once: true }, + ); + complete(); + return; + } + + const script = document.createElement('script'); + script.src = WECHAT_JS_SDK_URL; + script.async = true; + script.onload = complete; + script.onerror = () => reject(new Error('请在微信小程序内完成支付')); + document.head.appendChild(script); + }); +} + +async function requestWechatMiniProgramPayment( payload: WechatMiniProgramPayParams | null | undefined, orderId: string, ) { - const miniProgram = window.wx?.miniProgram; - if ( - !payload || - !miniProgram || - typeof miniProgram.navigateTo !== 'function' - ) { + if (!payload) { + return Promise.reject(new Error('请在微信小程序内完成支付')); + } + const wxBridge = await loadWechatJsSdk(); + const miniProgram = wxBridge.miniProgram; + if (!miniProgram || typeof miniProgram.navigateTo !== 'function') { return Promise.reject(new Error('请在微信小程序内完成支付')); } const navigateTo = miniProgram.navigateTo; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f9f39034..a419fc50 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -14,4 +14,5 @@ interface Window { postMessage?: (message: unknown) => void; }; }; + WeixinJSBridge?: unknown; } From b24af5a27905468fe08fb3537180d8222de75574 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 21:55:11 +0800 Subject: [PATCH 07/11] chore: stop tracking local spacetime config --- .gitignore | 1 + spacetime.local.json | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 spacetime.local.json diff --git a/.gitignore b/.gitignore index 8abc3e04..6f27c449 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ temp*build*/ /logs .worktrees/ .env.secrets.local +spacetime.local.json # Local load-test data extracted from private migration files scripts/loadtest/data/*.local.json diff --git a/spacetime.local.json b/spacetime.local.json deleted file mode 100644 index 60c2b4c2..00000000 --- a/spacetime.local.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "database": "genarrative-dev-edu" -} From 2801b55d2fa3f4de9494d7386d5fca27fc228c8e Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 23:52:01 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E5=9B=9E=E8=B7=B3=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E4=B8=8E=E6=9F=A5=E5=8D=95=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 32 ++- miniprogram/pages/web-view/index.js | 15 ++ miniprogram/pages/wechat-pay/index.js | 13 +- packages/shared/src/contracts/runtime.ts | 5 + server-rs/crates/api-server/src/app.rs | 15 +- .../crates/api-server/src/runtime_profile.rs | 149 +++++++++++++- server-rs/crates/api-server/src/wechat_pay.rs | 190 +++++++++++++++-- .../crates/module-runtime/src/commands.rs | 8 + server-rs/crates/module-runtime/src/domain.rs | 6 + .../crates/shared-contracts/src/runtime.rs | 7 + server-rs/crates/spacetime-client/src/lib.rs | 1 + .../crates/spacetime-client/src/mapper.rs | 10 + ...ile_recharge_order_and_return_procedure.rs | 59 ++++++ .../src/module_bindings/mod.rs | 4 + ...e_profile_recharge_order_get_input_type.rs | 15 ++ .../crates/spacetime-client/src/runtime.rs | 27 +++ .../spacetime-module/src/runtime/profile.rs | 46 +++++ .../RpgEntryHomeView.recharge.test.tsx | 193 ++++++++++++++++-- src/components/rpg-entry/RpgEntryHomeView.tsx | 189 +++++++++++------ src/services/rpg-entry/rpgProfileClient.ts | 14 ++ src/vite-env.d.ts | 1 + 21 files changed, 880 insertions(+), 119 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs 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 79af8322..d4c8f436 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 @@ -85,17 +85,42 @@ } ``` -### 3.3 `POST /api/profile/recharge/wechat/notify` +### 3.3 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` + +需要 Bearer JWT。该接口用于小程序支付页返回 web-view 后的主动查单确认,不替代微信支付通知: + +1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态。 +2. 若订单已是 `paid`,直接返回订单与账户中心快照。 +3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。 +4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。 +5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只显示“支付已提交”,不提前发放泥点或会员。 + +响应结构: + +```json +{ + "order": { + "orderId": "rcg...", + "status": "paid" + }, + "center": { + "walletBalance": 120 + } +} +``` + +### 3.4 `POST /api/profile/recharge/wechat/notify` 微信支付通知地址,无需 Bearer JWT。行为: -1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。 +1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签;验签必须使用原始 HTTP body bytes 构造 `timestamp\nnonce\nbody\n`,不能先把 body 转成字符串再重建。 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 内写入钱包流水或会员到期时间,确保重复通知幂等。 7. 验签、解密和业务确认通过后返回 HTTP `204 No Content`;不要返回 V2 XML。 +8. 微信支付公钥模式下,真实请求会携带 `Wechatpay-Serial: PUB_KEY_ID_...`,通知验签必须要求回调头 `Wechatpay-Serial` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 对应;若不匹配应返回 `401` 并在日志里记录 reason。 关键环境变量: @@ -118,6 +143,9 @@ 3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 + - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 + - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 + - `cancel` 和 `fail` 只复位按钮、刷新账户中心并展示状态,不调用入账逻辑。 5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。 6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 5cbc3925..534128d3 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -9,6 +9,7 @@ const { const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; +const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; function isConfiguredEntryUrl(value) { const trimmed = String(value || '').trim(); @@ -273,6 +274,20 @@ Page({ } }, + onShow() { + const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); + if (!result || !this.data.webViewUrl) { + return; + } + + wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); + this.setData({ + webViewUrl: appendHashParams(this.data.webViewUrl, { + wx_pay_result: result, + }), + }); + }, + async handleGetPhoneNumber(event) { if (!this.data.authResult || !this.data.authResult.token) { this.handleRetryLogin(); diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js index ab0e0041..332849ca 100644 --- a/miniprogram/pages/wechat-pay/index.js +++ b/miniprogram/pages/wechat-pay/index.js @@ -30,18 +30,25 @@ function requestPayment(payParams) { }); } +const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; + 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()}`; + const nextHash = rawHash + .split('&') + .filter((part) => part && !part.startsWith('wx_pay_result=')) + .concat(`wx_pay_result=${encodeURIComponent(value)}`) + .join('&'); + return `${baseUrl}#${nextHash}`; } function notifyPreviousWebView(requestId, status) { + const result = `${requestId}:${status}`; + wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result); const pages = getCurrentPages(); const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null; if (previousPage && typeof previousPage.setData === 'function') { diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index eab877b8..97a82ada 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -158,6 +158,11 @@ export type CreateProfileRechargeOrderResponse = { wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null; }; +export type ConfirmWechatProfileRechargeOrderResponse = { + order: ProfileRechargeOrder; + center: ProfileRechargeCenterResponse; +}; + export type ProfileFeedbackStatus = 'open'; export type ProfileFeedbackEvidenceItemInput = { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 85c86f4a..0448a3f3 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -129,10 +129,11 @@ use crate::{ admin_list_profile_invite_codes, admin_list_profile_redeem_codes, admin_list_profile_task_configs, admin_upsert_profile_invite_code, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, - claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric, - get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, - get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback, + claim_profile_task_reward, confirm_wechat_profile_recharge_order, + create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, + get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, + get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code, + redeem_profile_reward_code, submit_profile_feedback, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -1409,6 +1410,12 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/recharge/orders/{order_id}/wechat/confirm", + post(confirm_wechat_profile_recharge_order).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index f58a829e..987a8cf1 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -10,12 +10,12 @@ use module_runtime::{ RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, - RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeTrackingScopeKind, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, + RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, + RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -25,10 +25,10 @@ use shared_contracts::runtime::{ AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, - CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, - PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, + CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, + PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, + PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, @@ -63,7 +63,10 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, - wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error}, + wechat_pay::{ + WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros, + map_wechat_pay_error, + }, }; pub async fn get_profile_dashboard( @@ -244,6 +247,106 @@ pub async fn create_profile_recharge_order( )) } +pub async fn confirm_wechat_profile_recharge_order( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(order_id): Path, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let (center, order) = state + .spacetime_client() + .get_profile_recharge_order(order_id.clone()) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + if order.user_id != user_id { + return Err(runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), + )); + } + if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM { + return Err(runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("该充值订单不是微信小程序支付订单"), + )); + } + if order.status == RuntimeProfileRechargeOrderStatus::Paid { + return Ok(json_success_body( + Some(&request_context), + ConfirmWechatProfileRechargeOrderResponse { + order: build_profile_recharge_order_response(order), + center: build_profile_recharge_center_response(center), + }, + )); + } + if order.status != RuntimeProfileRechargeOrderStatus::Pending { + return Ok(json_success_body( + Some(&request_context), + ConfirmWechatProfileRechargeOrderResponse { + order: build_profile_recharge_order_response(order), + center: build_profile_recharge_center_response(center), + }, + )); + } + + let wechat_order = state + .wechat_pay_client() + .query_order_by_out_trade_no(&order.order_id) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?; + if wechat_order.out_trade_no != order.order_id { + return Err(runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信支付查单返回的商户订单号与本地订单不一致") + .with_details(json!({ "provider": "wechat_pay" })), + )); + } + if wechat_order.trade_state != "SUCCESS" { + return Ok(json_success_body( + Some(&request_context), + ConfirmWechatProfileRechargeOrderResponse { + order: build_profile_recharge_order_response(order), + center: build_profile_recharge_center_response(center), + }, + )); + } + + let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order); + let (center, order) = state + .spacetime_client() + .mark_profile_recharge_order_paid( + wechat_order.out_trade_no, + paid_at_micros, + wechat_order.transaction_id, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ConfirmWechatProfileRechargeOrderResponse { + order: build_profile_recharge_order_response(order), + center: build_profile_recharge_center_response(center), + }, + )) +} + pub async fn submit_profile_feedback( State(state): State, Extension(request_context): Extension, @@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment( .with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")) } +fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 { + order + .success_time + .as_deref() + .and_then(|value| parse_rfc3339(value).ok()) + .map(offset_datetime_to_unix_micros) + .unwrap_or_else(current_unix_micros) +} + fn build_profile_recharge_center_response( record: RuntimeProfileRechargeCenterRecord, ) -> ProfileRechargeCenterResponse { @@ -1260,6 +1372,7 @@ mod tests { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app + .clone() .oneshot( Request::builder() .method("GET") @@ -1271,6 +1384,20 @@ mod tests { .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let confirm_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index e14532e5..63b15b8d 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse; use shared_kernel::offset_datetime_to_unix_micros; use time::OffsetDateTime; use tracing::{info, warn}; +use url::Url; use crate::{http_error::AppError, state::AppState}; @@ -28,6 +29,8 @@ const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA"; const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json"; const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json"; const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0"; +const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial"; +const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/"; const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; @@ -54,6 +57,7 @@ pub struct RealWechatPayClient { api_v3_key: String, notify_url: String, jsapi_endpoint: String, + query_order_endpoint_base: String, } #[derive(Clone, Debug)] @@ -81,7 +85,7 @@ pub enum WechatPayError { Upstream(String), Deserialize(String), Crypto(String), - InvalidSignature, + InvalidSignature(String), } #[derive(Serialize)] @@ -137,6 +141,16 @@ struct WechatPayTransactionResource { success_time: Option, } +#[derive(Deserialize)] +struct WechatPayQueryOrderResponse { + 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 { @@ -208,6 +222,7 @@ impl WechatPayClient { &config.wechat_pay_jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT", )?; + let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?; Ok(Self::Real(Arc::new(RealWechatPayClient { client: reqwest::Client::new(), @@ -220,6 +235,7 @@ impl WechatPayClient { api_v3_key, notify_url, jsapi_endpoint, + query_order_endpoint_base, }))) } @@ -245,6 +261,22 @@ impl WechatPayClient { Self::Real(client) => client.parse_notify(headers, body), } } + + pub async fn query_order_by_out_trade_no( + &self, + order_id: &str, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(WechatPayNotifyOrder { + out_trade_no: normalize_out_trade_no(order_id)?, + transaction_id: Some(format!("mock-{order_id}")), + trade_state: "SUCCESS".to_string(), + success_time: Some(OffsetDateTime::now_utc().to_string()), + }), + Self::Real(client) => client.query_order_by_out_trade_no(order_id).await, + } + } } impl RealWechatPayClient { @@ -283,6 +315,7 @@ impl RealWechatPayClient { self.client .post(&self.jsapi_endpoint) .header("Authorization", authorization), + &self.platform_serial_no, ) .body(body) .send() @@ -389,6 +422,58 @@ impl RealWechatPayClient { }) } + async fn query_order_by_out_trade_no( + &self, + order_id: &str, + ) -> Result { + let order_id = normalize_out_trade_no(order_id)?; + let path = format!( + "/v3/pay/transactions/out-trade-no/{}?mchid={}", + urlencoding::encode(&order_id), + urlencoding::encode(&self.mch_id), + ); + let request_url = format!( + "{}/{}?mchid={}", + self.query_order_endpoint_base.trim_end_matches('/'), + urlencoding::encode(&order_id), + urlencoding::encode(&self.mch_id), + ); + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = self.build_authorization("GET", &path, ×tamp, &nonce, "")?; + let response = with_wechat_pay_json_headers( + self.client + .get(request_url) + .header("Authorization", authorization), + &self.platform_serial_no, + ) + .send() + .await + .map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}")) + })?; + if !status.is_success() { + return Err(WechatPayError::Upstream(format!( + "微信支付查单失败:HTTP {status},{response_text}" + ))); + } + let payload = serde_json::from_str::(&response_text).map_err( + |error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")), + )?; + + Ok(WechatPayNotifyOrder { + out_trade_no: payload.out_trade_no, + transaction_id: payload + .transaction_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + trade_state: payload.trade_state, + success_time: payload.success_time, + }) + } + fn verify_notify_signature( &self, headers: &HeaderMap, @@ -399,25 +484,33 @@ impl RealWechatPayClient { 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); + warn!( + received_serial = serial, + configured_serial = self.platform_serial_no.as_str(), + "微信支付通知平台公钥序列号不匹配" + ); + return Err(WechatPayError::InvalidSignature(format!( + "微信支付通知平台公钥序列号不匹配:received={serial}" + ))); + } + if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) { + warn!("收到微信支付签名探测通知"); + return Err(WechatPayError::InvalidSignature( + "微信支付签名探测通知".to_string(), + )); } - 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 message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body); + let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| { + WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string()) + })?; 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) + .verify(&message, &signature_bytes) + .map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string())) } fn sign_message(&self, message: &str) -> Result { @@ -499,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { | 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" })), + WechatPayError::InvalidSignature(message) => { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("微信支付通知签名无效") + .with_details(json!({ "provider": "wechat_pay", "reason": message })) + } } } @@ -533,7 +628,10 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { map_wechat_pay_error(error) } -fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { +fn with_wechat_pay_json_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { builder .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) .header( @@ -541,6 +639,14 @@ fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::R WECHAT_PAY_CONTENT_TYPE_HEADER, ) .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) + .header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no) +} + +fn with_wechat_pay_jsapi_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { + with_wechat_pay_json_headers(builder, platform_serial_no) } fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { @@ -627,6 +733,23 @@ fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> { Ok(()) } +fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result { + let url = Url::parse(jsapi_endpoint) + .map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?; + let origin = url + .origin() + .ascii_serialization() + .trim_end_matches('/') + .to_string(); + Ok(format!("{origin}/v3/pay/transactions/out-trade-no")) +} + +fn normalize_out_trade_no(value: &str) -> Result { + let value = value.trim(); + validate_out_trade_no(value)?; + Ok(value.to_string()) +} + fn validate_jsapi_order_request( client: &RealWechatPayClient, request: &WechatMiniProgramOrderRequest, @@ -841,7 +964,18 @@ fn read_required_header<'a>( .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or(WechatPayError::InvalidSignature) + .ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头"))) +} + +fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec { + let mut message = Vec::with_capacity(timestamp.len() + nonce.len() + body.len() + 3); + message.extend_from_slice(timestamp); + message.push(b'\n'); + message.extend_from_slice(nonce); + message.push(b'\n'); + message.extend_from_slice(body); + message.push(b'\n'); + message } fn hex_sha256(content: &[u8]) -> String { @@ -864,7 +998,7 @@ impl std::fmt::Display for WechatPayError { | Self::Upstream(message) | Self::Deserialize(message) | Self::Crypto(message) => formatter.write_str(message), - Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"), + Self::InvalidSignature(message) => formatter.write_str(message), } } } @@ -951,6 +1085,7 @@ mod tests { "Authorization", "WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"", ), + "PUB_KEY_ID_0119000000012026051400000000000001", ) .build() .expect("request should build"); @@ -974,6 +1109,23 @@ mod tests { .and_then(|value| value.to_str().ok()), Some(WECHAT_PAY_USER_AGENT) ); + assert_eq!( + headers + .get(WECHAT_PAY_SERIAL_HEADER) + .and_then(|value| value.to_str().ok()), + Some("PUB_KEY_ID_0119000000012026051400000000000001") + ); + } + + #[test] + fn notify_signature_message_preserves_raw_body_bytes() { + let body = b"{\"message\":\"hello\\r\\nworld\"}\r\n"; + let message = build_notify_signature_message(b"1778759600", b"nonce-1", body); + + assert_eq!( + message, + b"1778759600\nnonce-1\n{\"message\":\"hello\\r\\nworld\"}\r\n\n".to_vec() + ); } #[test] diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 90236501..e3249e64 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -242,6 +242,14 @@ pub fn build_runtime_profile_recharge_center_get_input( Ok(RuntimeProfileRechargeCenterGetInput { user_id }) } +pub fn build_runtime_profile_recharge_order_get_input( + order_id: String, +) -> Result { + let order_id = + normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?; + Ok(RuntimeProfileRechargeOrderGetInput { order_id }) +} + pub fn build_runtime_profile_recharge_order_create_input( user_id: String, product_id: String, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 88f261c2..e327f28d 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1060,6 +1060,12 @@ pub struct RuntimeProfileRechargeCenterGetInput { pub user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderGetInput { + pub order_id: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderCreateInput { diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index cedb0cc0..e36ac817 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -268,6 +268,13 @@ pub struct CreateProfileRechargeOrderResponse { pub wechat_mini_program_pay_params: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmWechatProfileRechargeOrderResponse { + pub order: ProfileRechargeOrderResponse, + pub center: ProfileRechargeCenterResponse, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileFeedbackEvidenceItemRequest { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 8ff41ab3..e620e3d9 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -177,6 +177,7 @@ use module_runtime::{ build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, + build_runtime_profile_recharge_order_get_input, build_runtime_profile_redeem_code_admin_disable_input, build_runtime_profile_redeem_code_admin_list_input, build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index a530b4c5..800bc384 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -163,6 +163,16 @@ impl From } } +impl From + for RuntimeProfileRechargeOrderGetInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderGetInput) -> Self { + Self { + order_id: input.order_id, + } + } +} + impl From for RuntimeProfileRechargeOrderCreateInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs new file mode 100644 index 00000000..f187bc6f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_order_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; +use super::runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrderGetInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileRechargeOrderAndReturnArgs { + pub input: RuntimeProfileRechargeOrderGetInput, +} + +impl __sdk::InModule for GetProfileRechargeOrderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_recharge_order_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_recharge_order_and_return { + fn get_profile_recharge_order_and_return(&self, input: RuntimeProfileRechargeOrderGetInput) { + self.get_profile_recharge_order_and_return_then(input, |_, _| {}); + } + + fn get_profile_recharge_order_and_return_then( + &self, + input: RuntimeProfileRechargeOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_recharge_order_and_return for super::RemoteProcedures { + fn get_profile_recharge_order_and_return_then( + &self, + input: RuntimeProfileRechargeOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>( + "get_profile_recharge_order_and_return", + GetProfileRechargeOrderAndReturnArgs { 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 a4006654..9a56b47d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -320,6 +320,7 @@ pub mod get_player_progression_or_default_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_play_stats_procedure; pub mod get_profile_recharge_center_procedure; +pub mod get_profile_recharge_order_and_return_procedure; pub mod get_profile_referral_invite_center_procedure; pub mod get_profile_task_center_procedure; pub mod get_puzzle_agent_session_procedure; @@ -620,6 +621,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_get_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; @@ -1135,6 +1137,7 @@ pub use get_player_progression_or_default_procedure::get_player_progression_or_d pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_play_stats_procedure::get_profile_play_stats; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; +pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center; pub use get_profile_task_center_procedure::get_profile_task_center; pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session; @@ -1435,6 +1438,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_get_input_type::RuntimeProfileRechargeOrderGetInput; 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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs new file mode 100644 index 00000000..c1241d4e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_get_input_type.rs @@ -0,0 +1,15 @@ +// 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 RuntimeProfileRechargeOrderGetInput { + pub order_id: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 076aef6c..5e86b21f 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -268,6 +268,33 @@ impl SpacetimeClient { .await } + pub async fn get_profile_recharge_order( + &self, + order_id: String, + ) -> Result< + ( + RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, + ), + SpacetimeClientError, + > { + let procedure_input = build_runtime_profile_recharge_order_get_input(order_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_profile_recharge_order_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 mark_profile_recharge_order_paid( &self, order_id: String, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index f23e0969..656e023b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -771,6 +771,27 @@ pub fn get_profile_recharge_center( } } +#[spacetimedb::procedure] +pub fn get_profile_recharge_order_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeOrderGetInput, +) -> RuntimeProfileRechargeCenterProcedureResult { + match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(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 create_profile_recharge_order_and_return( ctx: &mut ProcedureContext, @@ -2122,6 +2143,31 @@ fn create_profile_recharge_order_record( )) } +fn get_profile_recharge_order_snapshot( + ctx: &ReducerContext, + input: RuntimeProfileRechargeOrderGetInput, +) -> Result< + ( + RuntimeProfileRechargeCenterSnapshot, + RuntimeProfileRechargeOrderSnapshot, + ), + String, +> { + let validated_input = build_runtime_profile_recharge_order_get_input(input.order_id) + .map_err(|error| error.to_string())?; + let order = ctx + .db + .profile_recharge_order() + .order_id() + .find(&validated_input.order_id) + .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; + + Ok(( + build_profile_recharge_center_snapshot(ctx, &order.user_id), + build_profile_recharge_order_snapshot_from_row(&order), + )) +} + fn mark_profile_recharge_order_paid_record( ctx: &ReducerContext, input: RuntimeProfileRechargeOrderPaidInput, diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 1617b4a2..657eb51d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -17,6 +17,7 @@ import type { PublicUserSummary, } from '../../../packages/shared/src/contracts/auth'; import type { + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, ProfileReferralInviteCenterResponse, ProfileTaskCenterResponse, @@ -39,6 +40,7 @@ const { mockBuildReferralCenter, mockBuildTaskCenter, mockClaimRpgProfileTaskReward, + mockConfirmWechatRpgProfileRechargeOrder, mockCreateRpgProfileRechargeOrder, mockGetRpgProfileReferralInviteCenter, mockGetRpgProfileRechargeCenter, @@ -219,6 +221,65 @@ const { }, }), ), + mockConfirmWechatRpgProfileRechargeOrder: vi.fn( + async (): Promise => ({ + order: { + orderId: 'order-wechat-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_mp', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-transaction-1', + 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: [ + { + productId: 'points_60', + title: '60泥点', + priceCents: 600, + kind: 'points', + pointsAmount: 60, + bonusPoints: 0, + durationDays: 0, + badgeLabel: '', + description: '60泥点', + tier: 'normal', + }, + ], + membershipProducts: [], + benefits: [], + latestOrder: { + orderId: 'order-wechat-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_mp', + providerTransactionId: 'wx-transaction-1', + createdAt: '2026-04-25T10:00:00Z', + paidAt: '2026-04-25T10:01:00Z', + pointsDelta: 120, + membershipExpiresAt: null, + }, + hasPointsRecharged: true, + }, + }), + ), mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({ center: buildReferralCenter({ invitedUsers: [], @@ -303,6 +364,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode, getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter, createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, + confirmWechatRpgProfileRechargeOrder: + mockConfirmWechatRpgProfileRechargeOrder, })); vi.mock('../ResolvedAssetImage', () => ({ @@ -975,11 +1038,8 @@ test('profile recharge modal buys points through mock channel outside mini progr 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')); + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); }); window.wx = { miniProgram: { @@ -1040,23 +1100,32 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); expect(navigateTo).toHaveBeenCalledWith({ url: expect.stringContaining('/pages/wechat-pay/index?'), + success: expect.any(Function), fail: expect.any(Function), }); const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + act(() => { + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); - expect(await screen.findByText('支付已提交')).toBeTruthy(); + expect(await screen.findByText('已到账')).toBeTruthy(); + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'order-wechat-1', + ); }); test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); window.wx = undefined; - 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')); + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); }); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { @@ -1120,10 +1189,110 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri await waitFor(() => { expect(navigateTo).toHaveBeenCalledWith({ url: expect.stringContaining('/pages/wechat-pay/index?'), + success: expect.any(Function), fail: expect.any(Function), }); }); - expect(await screen.findByText('支付已提交')).toBeTruthy(); + const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + act(() => { + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(await screen.findByText('已到账')).toBeTruthy(); +}); + +test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { + const user = userEvent.setup(); + window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); + }); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-cancel-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-cancel', + signType: 'RSA', + paySign: 'signature', + }, + }); + + renderProfileView(); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + const buyButton = await screen.findByRole('button', { name: /60泥点/u }); + await user.click(buyButton); + + await waitFor(() => { + expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'points_60', + 'wechat_mp', + ); + }); + expect( + within(buyButton).getByText('处理中', { selector: 'span' }), + ).toBeTruthy(); + + const requestUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${requestUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + act(() => { + window.location.hash = `wx_pay_result=${requestId}:cancel`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + expect(await screen.findByText('支付已取消')).toBeTruthy(); + await waitFor(() => { + expect( + within(screen.getByRole('button', { name: /60泥点/u })).getByText( + '购买', + { selector: 'span' }, + ), + ).toBeTruthy(); + }); + expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); }); test('profile daily task shortcut opens task center and claims reward', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 62b1b746..4ac8b0f2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -70,6 +70,7 @@ import { import { copyTextToClipboard } from '../../services/clipboard'; import { claimRpgProfileTaskReward, + confirmWechatRpgProfileRechargeOrder, createRpgProfileRechargeOrder, getRpgProfileReferralInviteCenter, getRpgProfileRechargeCenter, @@ -216,6 +217,11 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type RechargeTab = 'points' | 'membership'; type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; +type WechatPayResult = { + requestId: string; + orderId: string | null; + status: WechatMiniProgramPaymentStatus; +}; type DiscoverChannel = | 'recommend' | 'today' @@ -2342,6 +2348,37 @@ function clearWechatPayResultHash() { window.history.replaceState(null, '', nextUrl); } +function readWechatPayResultFromHash(): WechatPayResult | null { + if (typeof window === 'undefined') { + return null; + } + + const result = new URLSearchParams( + window.location.hash.replace(/^#/, ''), + ).get('wx_pay_result'); + if (!result) { + return null; + } + + const [requestId = '', rawStatus = ''] = result.split(':'); + const orderId = requestId + .replace(/^wechat_pay_/, '') + .replace(/_\d+$/, '') + .trim(); + const status = + rawStatus === 'success' + ? 'success' + : rawStatus === 'cancel' + ? 'cancel' + : 'fail'; + + return { + requestId, + orderId: orderId || null, + status, + }; +} + function loadWechatJsSdk() { if (typeof window === 'undefined') { return Promise.reject(new Error('请在微信小程序内完成支付')); @@ -2385,7 +2422,7 @@ function loadWechatJsSdk() { async function requestWechatMiniProgramPayment( payload: WechatMiniProgramPayParams | null | undefined, orderId: string, -) { +): Promise { if (!payload) { return Promise.reject(new Error('请在微信小程序内完成支付')); } @@ -2396,35 +2433,20 @@ async function requestWechatMiniProgramPayment( } 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); + const requestId = `wechat_pay_${orderId}_${Date.now()}`; + return new Promise((resolve, reject) => { navigateTo({ url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`, + success() { + resolve(); + }, fail(error) { - window.removeEventListener('hashchange', handleHashChange); console.error('[wechat-pay] navigateTo failed', error); - resolve('fail'); + reject( + error instanceof Error + ? error + : new Error('请在微信小程序内完成支付'), + ); }, }); }); @@ -3368,6 +3390,7 @@ export function RpgEntryHomeView({ useState(null); const profileCopyResetTimerRef = useRef(null); const avatarFileInputRef = useRef(null); + const pendingWechatRechargeOrderIdRef = useRef(null); const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false); const [nicknameInput, setNicknameInput] = useState(''); const [nicknameError, setNicknameError] = useState(null); @@ -3823,6 +3846,55 @@ export function RpgEntryHomeView({ }) .finally(() => setIsLoadingRechargeCenter(false)); }; + const refreshRechargeState = useCallback( + () => { + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + }, + [loadRechargeCenter], + ); + const handleWechatPayResult = useCallback(() => { + const payResult = readWechatPayResultFromHash(); + if (!payResult) { + return; + } + + if ( + pendingWechatRechargeOrderIdRef.current && + payResult.orderId && + payResult.orderId !== pendingWechatRechargeOrderIdRef.current + ) { + return; + } + + if (payResult.status === 'success') { + setRechargeSuccess('支付已提交'); + if (payResult.orderId) { + void confirmWechatRpgProfileRechargeOrder(payResult.orderId) + .then((response) => { + setRechargeCenter(response.center); + setRechargeSuccess( + response.order.status === 'paid' ? '已到账' : '支付已提交', + ); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + }) + .catch(() => refreshRechargeState()); + } else { + refreshRechargeState(); + } + void onRechargeSuccess?.(); + } else if (payResult.status === 'cancel') { + setRechargeSuccess('支付已取消'); + refreshRechargeState(); + } else { + setRechargeError('微信支付未完成'); + refreshRechargeState(); + } + + clearWechatPayResultHash(); + }, [onRechargeSuccess, refreshRechargeState]); const openRechargeModal = () => { if (!authUi?.user) { authUi?.openLoginModal(); @@ -3847,63 +3919,44 @@ export function RpgEntryHomeView({ void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { - const status = await requestWechatMiniProgramPayment( + pendingWechatRechargeOrderIdRef.current = response.order.orderId; + await requestWechatMiniProgramPayment( response.wechatMiniProgramPayParams, response.order.orderId, ); - if (status === 'cancel') { - setRechargeCenter(response.center); - setRechargeSuccess('支付已取消'); - return; - } - if (status !== 'success') { - throw new Error('微信支付未完成'); - } - setRechargeSuccess('支付已提交'); - loadRechargeCenter(); + setRechargeCenter(response.center); + return; } else { setRechargeCenter(response.center); setRechargeSuccess('已到账'); + pendingWechatRechargeOrderIdRef.current = null; + setSubmittingRechargeProductId(null); } void onRechargeSuccess?.(); }) .catch((error: unknown) => { + pendingWechatRechargeOrderIdRef.current = null; setRechargeError(error instanceof Error ? error.message : '充值失败'); - }) - .finally(() => setSubmittingRechargeProductId(null)); + 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(); - } + const handleResume = () => { + handleWechatPayResult(); }; - window.addEventListener('hashchange', handleWechatPayResult); - handleWechatPayResult(); - return () => - window.removeEventListener('hashchange', handleWechatPayResult); - }, [isRechargeOpen, onRechargeSuccess]); + window.addEventListener('hashchange', handleResume); + window.addEventListener('focus', handleResume); + window.addEventListener('pageshow', handleResume); + document.addEventListener('visibilitychange', handleResume); + handleResume(); + return () => { + window.removeEventListener('hashchange', handleResume); + window.removeEventListener('focus', handleResume); + window.removeEventListener('pageshow', handleResume); + document.removeEventListener('visibilitychange', handleResume); + }; + }, [handleWechatPayResult]); const loadTaskCenter = () => { setTaskCenterError(null); setIsLoadingTaskCenter(true); diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index d56702fb..a0b9a479 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -1,4 +1,5 @@ import type { + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, ClaimProfileTaskRewardResponse, PlatformBrowseHistoryBatchSyncRequest, @@ -105,6 +106,18 @@ export function createRpgProfileRechargeOrder( ); } +export function confirmWechatRpgProfileRechargeOrder( + orderId: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + `/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`, + { method: 'POST' }, + '确认微信支付订单失败', + options, + ); +} + export function submitRpgProfileFeedback( payload: SubmitProfileFeedbackRequest, options: RuntimeRequestOptions = {}, @@ -305,6 +318,7 @@ export const rpgProfileClient = { getWalletLedger: getRpgProfileWalletLedger, getRechargeCenter: getRpgProfileRechargeCenter, createRechargeOrder: createRpgProfileRechargeOrder, + confirmWechatRechargeOrder: confirmWechatRpgProfileRechargeOrder, submitFeedback: submitRpgProfileFeedback, getReferralInviteCenter: getRpgProfileReferralInviteCenter, redeemReferralInviteCode: redeemRpgProfileReferralInviteCode, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a419fc50..209a90be 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface Window { miniProgram?: { navigateTo?: (options: { url: string; + success?: (result?: unknown) => void; fail?: (error: { errMsg?: string }) => void; }) => void; postMessage?: (message: unknown) => void; From 8ade75390cfd75d7293a7d9904a24cc934ef071a Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 01:07:39 +0800 Subject: [PATCH 09/11] Persist api-server logs and refresh recharge balance --- .hermes/shared-memory/development-workflow.md | 2 + ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 11 +- ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 8 +- project.config.json | 16 +- project.private.config.json | 11 +- scripts/api-server-dev.mjs | 158 ++++++++++++++++-- scripts/api-server-dev.test.ts | 42 ++++- scripts/dev-rust-stack.sh | 59 ++++++- server-rs/crates/api-server/README.md | 1 + .../RpgEntryHomeView.recharge.test.tsx | 19 ++- src/components/rpg-entry/RpgEntryHomeView.tsx | 133 +++++++++++++-- 11 files changed, 406 insertions(+), 54 deletions(-) diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 5f73294a..454c34ed 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -69,6 +69,8 @@ npm run dev:web npm run api-server ``` +该命令会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.log`。完整联调入口 `npm run dev` / `npm run dev:rust` 启动的 Rust `api-server` 也会写入 `logs/api-server/api-server-dev-rust-.log`。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。 + 查看本地 Rust/SpacetimeDB 日志: ```bash 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 d4c8f436..d2f52ad2 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 @@ -93,7 +93,7 @@ 2. 若订单已是 `paid`,直接返回订单与账户中心快照。 3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。 4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。 -5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只显示“支付已提交”,不提前发放泥点或会员。 +5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只在全局支付结果模态显示“支付已提交”,不提前发放泥点或会员。 响应结构: @@ -144,10 +144,11 @@ 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - - `cancel` 和 `fail` 只复位按钮、刷新账户中心并展示状态,不调用入账逻辑。 -5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。 -6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 + - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 + - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 +5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 +6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 +7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 ## 5. 验收 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 81cff52e..58c3e18a 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -48,7 +48,7 @@ npm run dev:rust 5. 如果确认需要新启动 SpacetimeDB,脚本会先检测 `127.0.0.1:3101` 是否可监听;若已占用,输出占用进程并选择从 `3101` 起向后的最近可用端口,再执行 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr <实际地址>`。启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`,后续 publish 与 `api-server` 都使用同一个实际 URL。 6. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。 7. 执行 `spacetime publish <本地数据库名> --server <实际 SpacetimeDB URL> --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 -8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 +8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。本地启动器会保留终端实时输出,并把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。 9. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 10. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 11. 任一子进程退出时,脚本回收其余子进程。 @@ -119,6 +119,12 @@ npm run dev:rust:logs -- --follow 3. 默认输出到 `logs/spacetime/-.log`,并通过 `tee` 同步显示在终端。 4. `--follow` 仅用于本地追踪,会持续追加到同一个输出文件;停止时用 `Ctrl+C`。 +api-server 本地持久化日志: + +1. `npm run api-server` 默认写入 `logs/api-server/api-server-.log`,同时继续把同一份输出显示在当前终端。 +2. `npm run dev` / `npm run dev:rust` 中由脚本启动的 Rust `api-server` 默认写入 `logs/api-server/api-server-dev-rust-.log`;等待 `/healthz` 失败时,脚本会自动输出该日志最后 80 行。 +3. 如需固定日志文件,可设置 `GENARRATIVE_API_SERVER_LOG_FILE=logs/api-server/local.log`;如只需更换目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR=logs/api-server`。相对路径都按仓库根目录解析。 + 联调排错补充: 1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database//subscribe` 是否指向了未发布的库。 diff --git a/project.config.json b/project.config.json index f526e96e..c61c3763 100644 --- a/project.config.json +++ b/project.config.json @@ -12,7 +12,16 @@ "outputPath": "" }, "useCompilerPlugins": false, - "minifyWXML": true + "minifyWXML": true, + "compileWorklet": false, + "uploadWithSourceMap": true, + "packNpmManually": false, + "minifyWXSS": true, + "localPlugins": false, + "disableUseStrict": false, + "condition": false, + "swc": false, + "disableSWC": true }, "compileType": "miniprogram", "miniprogramRoot": "miniprogram/", @@ -22,5 +31,6 @@ "include": [] }, "appid": "wx3da23ea14ca66b65", - "editorSetting": {} -} + "editorSetting": {}, + "libVersion": "3.15.2" +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json index 220cf2f7..ba82fff3 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -2,13 +2,20 @@ "libVersion": "3.15.2", "projectname": "Genarrative", "setting": { - "urlCheck": true, + "urlCheck": false, "coverView": true, "lazyloadPlaceholderEnable": false, "skylineRenderEnable": false, "preloadBackgroundData": false, "autoAudits": false, "showShadowRootInWxmlPanel": true, - "compileHotReLoad": true + "compileHotReLoad": true, + "useApiHook": true, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true, + "bigPackageSizeSupport": false } } \ No newline at end of file diff --git a/scripts/api-server-dev.mjs b/scripts/api-server-dev.mjs index 459c9f1f..49b76b6e 100644 --- a/scripts/api-server-dev.mjs +++ b/scripts/api-server-dev.mjs @@ -1,6 +1,11 @@ import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { + createWriteStream, + existsSync, + mkdirSync, + readFileSync, +} from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = process.cwd(); @@ -67,7 +72,69 @@ export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) { return mergedEnv; } -function stopExistingWindowsApiServer() { +export function formatApiServerLogTimestamp(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '-', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); +} + +export function resolveApiServerLogFile( + repoRootPath, + env = process.env, + now = new Date(), +) { + const explicitLogFile = String( + env.GENARRATIVE_API_SERVER_LOG_FILE ?? '', + ).trim(); + + if (explicitLogFile) { + return isAbsolute(explicitLogFile) + ? explicitLogFile + : resolve(repoRootPath, explicitLogFile); + } + + const logDir = + String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() || + 'logs/api-server'; + const resolvedLogDir = isAbsolute(logDir) + ? logDir + : resolve(repoRootPath, logDir); + + return resolve( + resolvedLogDir, + `api-server-${formatApiServerLogTimestamp(now)}.log`, + ); +} + +function createApiServerLogStream(logFilePath) { + mkdirSync(dirname(logFilePath), { recursive: true }); + const logStream = createWriteStream(logFilePath, { + flags: 'a', + encoding: 'utf8', + }); + logStream.on('error', (error) => { + console.error(`[api-server] 写入日志失败: ${error.message}`); + }); + return logStream; +} + +function writeLauncherLog(logStream, message, stream = process.stdout) { + const line = `${message}\n`; + stream.write(line); + if (!logStream.destroyed) { + logStream.write(line); + } +} + +function stopExistingWindowsApiServer(logStream) { if (process.platform !== 'win32') { return; } @@ -104,7 +171,7 @@ function stopExistingWindowsApiServer() { ).trim(); if (output) { - console.log(`[api-server] 已停止旧 api-server 进程: ${output}`); + writeLauncherLog(logStream, `[api-server] 已停止旧 api-server 进程: ${output}`); } } @@ -121,19 +188,55 @@ function main() { mergedEnv.GENARRATIVE_SPACETIME_TOKEN = mergedEnv.GENARRATIVE_SPACETIME_TOKEN || ''; + const logFilePath = resolveApiServerLogFile(repoRoot, mergedEnv); + const logStream = createApiServerLogStream(logFilePath); + mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFilePath; + + let didExit = false; + const exitAfterLogFlush = (code) => { + const finish = () => { + if (didExit) { + return; + } + didExit = true; + process.exit(code); + }; + + if (logStream.destroyed) { + finish(); + return; + } + + logStream.end(finish); + setTimeout(finish, 1000).unref(); + }; + + writeLauncherLog(logStream, `[api-server] 日志: ${logFilePath}`); + if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { - console.error('[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。'); - process.exit(1); + writeLauncherLog( + logStream, + '[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。', + process.stderr, + ); + exitAfterLogFlush(1); + return; } try { - stopExistingWindowsApiServer(); + stopExistingWindowsApiServer(logStream); } catch (error) { - console.error(`[api-server] 清理旧 api-server 进程失败: ${error.message}`); - process.exit(1); + writeLauncherLog( + logStream, + `[api-server] 清理旧 api-server 进程失败: ${error.message}`, + process.stderr, + ); + exitAfterLogFlush(1); + return; } - console.log( + writeLauncherLog( + logStream, `[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, ); @@ -143,22 +246,41 @@ function main() { { cwd: repoRoot, env: mergedEnv, - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], }, ); - child.on('error', (error) => { - console.error(`[api-server] 启动 cargo 失败: ${error.message}`); - process.exit(1); + child.stdout?.on('data', (chunk) => { + process.stdout.write(chunk); + logStream.write(chunk); }); - child.on('exit', (code, signal) => { + child.stderr?.on('data', (chunk) => { + process.stderr.write(chunk); + logStream.write(chunk); + }); + + child.on('error', (error) => { + writeLauncherLog( + logStream, + `[api-server] 启动 cargo 失败: ${error.message}`, + process.stderr, + ); + exitAfterLogFlush(1); + }); + + child.on('close', (code, signal) => { if (signal) { - console.error(`[api-server] api-server 被信号终止: ${signal}`); - process.exit(1); + writeLauncherLog( + logStream, + `[api-server] api-server 被信号终止: ${signal}`, + process.stderr, + ); + exitAfterLogFlush(1); + return; } - process.exit(code ?? 0); + exitAfterLogFlush(code ?? 0); }); } diff --git a/scripts/api-server-dev.test.ts b/scripts/api-server-dev.test.ts index 836fb1f5..267c0dfe 100644 --- a/scripts/api-server-dev.test.ts +++ b/scripts/api-server-dev.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path'; import { describe, expect, test } from 'vitest'; -import { mergeApiServerEnv } from './api-server-dev.mjs'; +import { + formatApiServerLogTimestamp, + mergeApiServerEnv, + resolveApiServerLogFile, +} from './api-server-dev.mjs'; type EnvMap = Record; @@ -92,3 +96,39 @@ describe('api-server-dev env merge', () => { ); }); }); + +describe('api-server-dev log file resolution', () => { + const fixedDate = new Date(2026, 4, 15, 6, 7, 8); + + test('默认写入 logs/api-server 的时间戳文件', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-')); + + try { + expect(formatApiServerLogTimestamp(fixedDate)).toBe('20260515-060708'); + expect(resolveApiServerLogFile(tempDir, {}, fixedDate)).toBe( + join(tempDir, 'logs/api-server/api-server-20260515-060708.log'), + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('GENARRATIVE_API_SERVER_LOG_FILE 优先于日志目录默认值', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-')); + + try { + expect( + resolveApiServerLogFile( + tempDir, + { + GENARRATIVE_API_SERVER_LOG_DIR: 'logs/ignored', + GENARRATIVE_API_SERVER_LOG_FILE: 'logs/custom/api.log', + }, + fixedDate, + ), + ).toBe(join(tempDir, 'logs/custom/api.log')); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 330868ed..6f7df857 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -653,11 +653,13 @@ wait_for_api_server() { local health_url="$1" local timeout_seconds="$2" local process_pid="${3:-}" + local log_file="${4:-${API_SERVER_LOG_FILE:-}}" local deadline=$((SECONDS + timeout_seconds)) while ((SECONDS < deadline)); do if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[dev:rust] api-server 进程在就绪前退出。" >&2 + print_api_server_log_tail "${log_file}" exit 1 fi @@ -679,9 +681,58 @@ request.on("error", () => process.exit(1)); done echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2 + print_api_server_log_tail "${log_file}" exit 1 } +format_api_server_log_timestamp() { + date +%Y%m%d-%H%M%S +} + +normalize_api_server_log_path() { + local path_value="$1" + + if [[ "${path_value}" == *\\* ]]; then + path_value="${path_value//\\//}" + fi + + echo "${path_value}" +} + +resolve_api_server_log_file() { + local explicit_log_file="${GENARRATIVE_API_SERVER_LOG_FILE:-}" + local log_dir="${GENARRATIVE_API_SERVER_LOG_DIR:-${REPO_ROOT}/logs/api-server}" + + if [[ -n "${explicit_log_file//[[:space:]]/}" ]]; then + explicit_log_file="$(normalize_api_server_log_path "${explicit_log_file}")" + if [[ "${explicit_log_file}" = /* || "${explicit_log_file}" =~ ^[A-Za-z]:[\\/] ]]; then + echo "${explicit_log_file}" + return + fi + + echo "${REPO_ROOT}/${explicit_log_file}" + return + fi + + log_dir="$(normalize_api_server_log_path "${log_dir}")" + if [[ ! "${log_dir}" = /* && ! "${log_dir}" =~ ^[A-Za-z]:[\\/] ]]; then + log_dir="${REPO_ROOT}/${log_dir}" + fi + + echo "${log_dir}/api-server-dev-rust-$(format_api_server_log_timestamp).log" +} + +print_api_server_log_tail() { + local log_file="${1:-}" + + if [[ -z "${log_file}" || ! -f "${log_file}" ]]; then + return + fi + + echo "[dev:rust] api-server 最近日志: ${log_file}" >&2 + tail -n 80 "${log_file}" >&2 || true +} + generate_migration_bootstrap_secret() { node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));' @@ -990,22 +1041,26 @@ API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server" API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" # `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。 RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" +API_SERVER_LOG_FILE="$(resolve_api_server_log_file)" +mkdir -p "$(dirname -- "${API_SERVER_LOG_FILE}")" echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}" +echo "[dev:rust] api-server log: ${API_SERVER_LOG_FILE}" ( cd "${REPO_ROOT}" GENARRATIVE_API_HOST="${API_HOST}" \ GENARRATIVE_API_PORT="${API_PORT}" \ GENARRATIVE_API_LOG="${API_LOG}" \ + GENARRATIVE_API_SERVER_LOG_FILE="${API_SERVER_LOG_FILE}" \ GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \ GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \ exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}" -) & +) > >(tee -a "${API_SERVER_LOG_FILE}") 2>&1 & API_PID="$!" PIDS+=("${API_PID}") NAMES+=("api-server") echo "[dev:rust] 等待 api-server 就绪" -wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" +wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" "${API_SERVER_LOG_FILE}" echo "[dev:rust] 启动 vite" ( diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 57e4d4b3..86b8cb6f 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -99,6 +99,7 @@ 1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。 2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。 3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。 +4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。 当前 request context 约定: diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 657eb51d..f2898a05 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1031,7 +1031,10 @@ test('profile recharge modal buys points through mock channel outside mini progr 'mock', ); }); - expect(await screen.findByText('已到账')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); + expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); @@ -1114,7 +1117,10 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); - expect(await screen.findByText('已到账')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); + expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', ); @@ -1202,7 +1208,9 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); - expect(await screen.findByText('已到账')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); }); test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { @@ -1283,7 +1291,10 @@ test('profile recharge modal releases submitting state after cancelled wechat pa window.dispatchEvent(new HashChangeEvent('hashchange')); }); - expect(await screen.findByText('支付已取消')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付已取消' }), + ).toBeTruthy(); + expect(screen.getByText('本次没有扣款,账户状态未发生变化。')).toBeTruthy(); await waitFor(() => { expect( within(screen.getByRole('button', { name: /60泥点/u })).getByText( diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 4ac8b0f2..fe63d38f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1,7 +1,9 @@ import { ArrowRight, + AlertCircle, BookOpen, Camera, + CheckCircle2, ChevronDown, ChevronRight, Clock3, @@ -26,6 +28,7 @@ import { Ticket, UserPlus, UserRound, + XCircle, } from 'lucide-react'; import { type ComponentType, @@ -222,6 +225,12 @@ type WechatPayResult = { orderId: string | null; status: WechatMiniProgramPaymentStatus; }; +type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed'; +type RechargePaymentResult = { + kind: RechargePaymentResultKind; + title: string; + message: string; +}; type DiscoverChannel = | 'recommend' | 'today' @@ -2501,7 +2510,6 @@ function ProfileRechargeModal({ center, isLoading, error, - success, submittingProductId, activeTab, onTabChange, @@ -2512,7 +2520,6 @@ function ProfileRechargeModal({ center: ProfileRechargeCenterResponse | null; isLoading: boolean; error: string | null; - success: string | null; submittingProductId: string | null; activeTab: RechargeTab; onTabChange: (tab: RechargeTab) => void; @@ -2582,11 +2589,6 @@ function ProfileRechargeModal({ ) : null} - {success ? ( -
- {success} -
- ) : null} {isLoading ? (
@@ -2619,6 +2621,62 @@ function ProfileRechargeModal({ ); } +function RechargePaymentResultModal({ + result, + onClose, +}: { + result: RechargePaymentResult; + onClose: () => void; +}) { + const Icon = + result.kind === 'success' + ? CheckCircle2 + : result.kind === 'cancel' + ? XCircle + : AlertCircle; + const iconClass = + result.kind === 'success' + ? 'text-[var(--platform-success-text)]' + : result.kind === 'cancel' + ? 'text-[var(--platform-text-soft)]' + : 'text-[var(--platform-button-danger-text)]'; + + return ( +
+
+
+
+
+
+ {result.title} +
+
+ {result.message} +
+ +
+
+
+ ); +} + function WalletLedgerModal({ ledger, fallbackBalance, @@ -3324,7 +3382,8 @@ export function RpgEntryHomeView({ useState(null); const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false); const [rechargeError, setRechargeError] = useState(null); - const [rechargeSuccess, setRechargeSuccess] = useState(null); + const [rechargePaymentResult, setRechargePaymentResult] = + useState(null); const [activeRechargeTab, setActiveRechargeTab] = useState('points'); const [submittingRechargeProductId, setSubmittingRechargeProductId] = @@ -3869,27 +3928,56 @@ export function RpgEntryHomeView({ } if (payResult.status === 'success') { - setRechargeSuccess('支付已提交'); + setRechargePaymentResult({ + kind: 'pending', + title: '支付已提交', + message: '正在确认到账状态,请稍后查看余额或会员状态。', + }); if (payResult.orderId) { void confirmWechatRpgProfileRechargeOrder(payResult.orderId) .then((response) => { setRechargeCenter(response.center); - setRechargeSuccess( - response.order.status === 'paid' ? '已到账' : '支付已提交', + setRechargePaymentResult( + response.order.status === 'paid' + ? { + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + } + : { + kind: 'pending', + title: '支付已提交', + message: '正在等待微信支付确认,请稍后查看账户状态。', + }, ); setSubmittingRechargeProductId(null); pendingWechatRechargeOrderIdRef.current = null; }) - .catch(() => refreshRechargeState()); + .catch(() => { + setRechargePaymentResult({ + kind: 'pending', + title: '支付已提交', + message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', + }); + refreshRechargeState(); + }); } else { refreshRechargeState(); } void onRechargeSuccess?.(); } else if (payResult.status === 'cancel') { - setRechargeSuccess('支付已取消'); + setRechargePaymentResult({ + kind: 'cancel', + title: '支付已取消', + message: '本次没有扣款,账户状态未发生变化。', + }); refreshRechargeState(); } else { - setRechargeError('微信支付未完成'); + setRechargePaymentResult({ + kind: 'failed', + title: '支付未完成', + message: '微信支付没有完成,本次不会入账。', + }); refreshRechargeState(); } @@ -3902,7 +3990,6 @@ export function RpgEntryHomeView({ } setIsRechargeOpen(true); - setRechargeSuccess(null); loadRechargeCenter(); }; const buyRechargeProduct = (product: ProfileRechargeProduct) => { @@ -3915,7 +4002,6 @@ export function RpgEntryHomeView({ : 'mock'; setSubmittingRechargeProductId(product.productId); setRechargeError(null); - setRechargeSuccess(null); void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { @@ -3928,7 +4014,11 @@ export function RpgEntryHomeView({ return; } else { setRechargeCenter(response.center); - setRechargeSuccess('已到账'); + setRechargePaymentResult({ + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + }); pendingWechatRechargeOrderIdRef.current = null; setSubmittingRechargeProductId(null); } @@ -5712,7 +5802,6 @@ export function RpgEntryHomeView({ center={rechargeCenter} isLoading={isLoadingRechargeCenter} error={rechargeError} - success={rechargeSuccess} submittingProductId={submittingRechargeProductId} activeTab={activeRechargeTab} onTabChange={setActiveRechargeTab} @@ -5721,6 +5810,12 @@ export function RpgEntryHomeView({ onBuy={buyRechargeProduct} /> ) : null; + const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( + setRechargePaymentResult(null)} + /> + ) : null; if (!isDesktopLayout) { const isMobileRecommendTab = activeTab === 'home'; @@ -5804,6 +5899,7 @@ export function RpgEntryHomeView({ ) : null} {rewardCodeModal} {rechargeModal} + {rechargePaymentResultModal} {isTaskCenterOpen ? ( {rewardCodeModal} {rechargeModal} + {rechargePaymentResultModal} {isTaskCenterOpen ? ( Date: Fri, 15 May 2026 01:19:34 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=E5=88=B7=E6=96=B0=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E5=88=B0=E8=B4=A6=E6=B3=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 1 + .../RpgEntryHomeView.recharge.test.tsx | 148 +++++++++++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 37 ++++- 3 files changed, 182 insertions(+), 4 deletions(-) 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 d2f52ad2..7d16625d 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 @@ -145,6 +145,7 @@ - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 + - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f2898a05..7a45adee 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1040,6 +1040,7 @@ test('profile recharge modal buys points through mock channel outside mini progr test('profile recharge modal posts requestPayment params in mini program web-view', async () => { const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { options.success?.(); @@ -1088,7 +1089,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }, }); - renderProfileView(); + renderProfileView(onRechargeSuccess); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), @@ -1124,6 +1125,151 @@ test('profile recharge modal posts requestPayment params in mini program web-vie expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', ); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); +}); + +test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => { + const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); + window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); + }); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-pending-then-paid', + 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', + }, + }); + mockConfirmWechatRpgProfileRechargeOrder + .mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-pending-then-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_mp', + paidAt: 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, + }, + }) + .mockResolvedValueOnce({ + order: { + orderId: 'order-wechat-pending-then-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid' as const, + paymentChannel: 'wechat_mp', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-transaction-2', + 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, + }, + }); + + renderProfileView(onRechargeSuccess); + 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 })); + + const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; + const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( + 'requestId', + ); + expect(requestId).toBeTruthy(); + await act(async () => { + window.location.hash = `wx_pay_result=${requestId}:success`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(1); + expect(onRechargeSuccess).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2); + }); + + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index fe63d38f..004aa685 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -48,6 +48,7 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a import type { CustomWorldLibraryEntry, PlatformBrowseHistoryEntry, + ConfirmWechatProfileRechargeOrderResponse, ProfileDashboardCardKey, ProfileDashboardSummary, ProfilePlayedWorkSummary, @@ -216,6 +217,7 @@ const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp'; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type RechargeTab = 'points' | 'membership'; @@ -2461,6 +2463,32 @@ async function requestWechatMiniProgramPayment( }); } +function waitWechatPayConfirmDelay(delayMs: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); +} + +async function confirmWechatRechargeOrderUntilSettled( + orderId: string, +): Promise { + let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + if (latestResponse.order.status === 'paid') { + return latestResponse; + } + + for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { + await waitWechatPayConfirmDelay(delayMs); + + latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + if (latestResponse.order.status === 'paid') { + return latestResponse; + } + } + + return latestResponse; +} + function RechargeProductCard({ product, submittingProductId, @@ -3934,11 +3962,12 @@ export function RpgEntryHomeView({ message: '正在确认到账状态,请稍后查看余额或会员状态。', }); if (payResult.orderId) { - void confirmWechatRpgProfileRechargeOrder(payResult.orderId) + void confirmWechatRechargeOrderUntilSettled(payResult.orderId) .then((response) => { + const isPaid = response.order.status === 'paid'; setRechargeCenter(response.center); setRechargePaymentResult( - response.order.status === 'paid' + isPaid ? { kind: 'success', title: '支付成功', @@ -3950,6 +3979,9 @@ export function RpgEntryHomeView({ message: '正在等待微信支付确认,请稍后查看账户状态。', }, ); + if (isPaid) { + void onRechargeSuccess?.(); + } setSubmittingRechargeProductId(null); pendingWechatRechargeOrderIdRef.current = null; }) @@ -3964,7 +3996,6 @@ export function RpgEntryHomeView({ } else { refreshRechargeState(); } - void onRechargeSuccess?.(); } else if (payResult.status === 'cancel') { setRechargePaymentResult({ kind: 'cancel', From f31bb7e7e50e3b7cb2cc770fcd7212b2567df75d Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 01:25:56 +0800 Subject: [PATCH 11/11] Add SpacetimeDB schema guard --- .hermes/shared-memory/development-workflow.md | 2 + .hermes/shared-memory/pitfalls.md | 4 +- AGENTS.md | 2 + .../SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md | 10 + package.json | 5 +- scripts/check-spacetime-schema-guard.mjs | 647 ++++++++++++++++++ 6 files changed, 666 insertions(+), 4 deletions(-) create mode 100644 scripts/check-spacetime-schema-guard.mjs diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 5f73294a..2134f80b 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -159,6 +159,8 @@ npm run check:server-rs-ddd - 检查 `/healthz`。 - 执行对应自动测试。 - 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。 +- SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 +- 修改 SpacetimeDB schema 后运行 `npm run check:spacetime-schema`,用自动检查拦截缺 default、插入中间、字段删除/改名/重排/改类型,以及漏改迁移、表目录或绑定。 关键文档: diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 17303bc9..1ae31a0d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -250,8 +250,8 @@ - 现象:发布时 schema 冲突、自动迁移拒绝、旧客户端调用 reducer 失败、private 表数据迁移遗漏。 - 原因:SpacetimeDB 对字段删除、类型变化、索引/主键/RLS/reducer 变化有不同自动迁移边界。 -- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;涉及表变化时同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。 -- 验证:发布前完成 schema 检查、bindings 生成、表目录更新和相关 smoke。 +- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;已有表新增字段必须放在 Rust 表结构体最后并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划;涉及表变化时同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings;必要时走 JSON 导入导出与分片导入迁移流程。 +- 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。 - 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 ## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature diff --git a/AGENTS.md b/AGENTS.md index 4a13fb06..48d21424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ Single-context layout: read root `CONTEXT.md` when present and architecture deci - 后端最新技术约束以 [`docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`](docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md) 为总纲;执行和收口状态以 [`docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`](docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md) 为准。 - 契约、路由、DTO 去留和 breaking change 以 [`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。 - SpacetimeDB 表结构、自动迁移限制和冲突处理以 [`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`](docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md) 为准;涉及 table、reducer、procedure、row shape 或绑定变化时,必须同步 `migration.rs`、表目录和生成绑定。 +- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值(例如 `#[default(...)]`);需要修改字段名时,必须先询问用户并确认迁移计划,再改代码,同时更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 +- 修改 SpacetimeDB schema 后必须运行 `npm run check:spacetime-schema`;该检查会拦截新增字段缺 default、字段不在末尾、字段删除/改名/重排/改类型,以及漏改 `migration.rs`、表目录或生成绑定。 - 后端路线固定为 `server-rs + Axum + SpacetimeDB`。旧 `server-node`、Express、PostgreSQL 不再作为兼容目标;历史实现只能作为迁移参考,若旧文档与 DDD 约束冲突,先修正文档和方案再编码。 - DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。 - 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。 diff --git a/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md b/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md index 6a332482..6fdee664 100644 --- a/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md +++ b/docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md @@ -58,6 +58,14 @@ host 会比较新模块声明的 schema 和旧数据库 schema,然后尝试自 4. 等数据迁移完成、旧客户端完成升级、旧表数据清空后,再移除旧表。 5. 开发环境可以使用 `--delete-data` 重建数据库,生产环境不要用它作为数据迁移方案。 +## 字段级硬性约束 + +- 对已有 SpacetimeDB 表新增字段时,必须把新字段追加在 Rust 表结构体的最后,不能插入已有字段中间。 +- 新增字段必须设置明确默认值,例如 Rust `#[default(...)]`;复杂集合默认值如果无法作为编译期常量表达,应优先使用 `Option` 加 `#[default(None::)]`,并在业务层归一化。 +- 修改已有字段名属于高风险 schema 变更。编码前必须先询问用户,确认旧字段名、新字段名、数据保留方式、客户端兼容窗口和发布顺序,并让用户准备或确认迁移计划。 +- 字段改名或任何 row shape 迁移都必须同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和生成绑定;不要只改表结构体。 +- 修改 SpacetimeDB schema 后必须运行 `npm run check:spacetime-schema`。该检查会对比当前工作区与基准分支的 Rust table 字段,自动拦截新增字段缺 default、字段插入中间、字段删除/改名/重排/改类型,以及漏改 `migration.rs`、表目录或生成绑定。 + ## 通常安全的变更 这些变更一般可以自动迁移,并且通常不会破坏现有客户端: @@ -223,6 +231,8 @@ fn migrate_character_batch(ctx: &ReducerContext, limit: u32) { - 是否删除、改名、重排或修改了已有列。 - 新增列是否位于表定义末尾,并且是否有 default value。 +- 如需改字段名,是否已先询问用户并确认迁移计划,且已同步更新 `migration.rs`。 +- 是否已运行 `npm run check:spacetime-schema` 并通过。 - 是否给已有表新增了 unique 或 primary key 约束。 - 是否删除了非空表。 - 是否修改了 event table、schedule table、RLS 或 view。 diff --git a/package.json b/package.json index 0f77d5f0..ea50ba07 100644 --- a/package.json +++ b/package.json @@ -23,17 +23,18 @@ "preview": "node scripts/vite-cli.mjs preview", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "check:encoding": "node scripts/check-encoding.mjs", + "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", - "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", + "check:server-rs-ddd": "npm run check:spacetime-schema && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:spacetime-schema && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs new file mode 100644 index 00000000..301ac967 --- /dev/null +++ b/scripts/check-spacetime-schema-guard.mjs @@ -0,0 +1,647 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptDir, '..'); +const moduleSrcRoot = 'server-rs/crates/spacetime-module/src'; +const migrationPath = `${moduleSrcRoot}/migration.rs`; +const tableCatalogPath = 'docs/technical/SPACETIMEDB_TABLE_CATALOG.md'; +const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/'; +const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1'; + +function normalizePath(path) { + return path.replace(/\\/gu, '/'); +} + +function runGit(args, options = {}) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: options.quiet ? ['ignore', 'pipe', 'ignore'] : ['ignore', 'pipe', 'pipe'], + maxBuffer: 32 * 1024 * 1024, + }).trim(); +} + +function tryGit(args) { + try { + return runGit(args, { quiet: true }); + } catch { + return null; + } +} + +function resolveBaseRef() { + const explicitArgIndex = process.argv.indexOf('--base-ref'); + if (explicitArgIndex >= 0 && process.argv[explicitArgIndex + 1]) { + return process.argv[explicitArgIndex + 1]; + } + + if (process.env.SPACETIME_SCHEMA_BASE_REF) { + return process.env.SPACETIME_SCHEMA_BASE_REF; + } + + const mergeBase = tryGit(['merge-base', 'HEAD', 'origin/master']); + if (mergeBase) { + return mergeBase; + } + + const originMaster = tryGit(['rev-parse', '--verify', 'origin/master']); + if (originMaster) { + return originMaster; + } + + return 'HEAD'; +} + +function listCurrentRustFiles(dir) { + const files = []; + + function walk(currentDir) { + if (!existsSync(currentDir)) { + return; + } + + for (const name of readdirSync(currentDir)) { + const fullPath = join(currentDir, name); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + walk(fullPath); + continue; + } + + if (name.endsWith('.rs')) { + files.push(normalizePath(relative(repoRoot, fullPath))); + } + } + } + + walk(join(repoRoot, dir)); + return files.sort(); +} + +function listBaseRustFiles(baseRef) { + const output = tryGit(['ls-tree', '-r', '--name-only', baseRef, '--', moduleSrcRoot]); + if (!output) { + return []; + } + + return output + .split(/\r?\n/u) + .map(normalizePath) + .filter((path) => path.endsWith('.rs')) + .sort(); +} + +function readCurrentFile(path) { + return readFileSync(join(repoRoot, path), 'utf8'); +} + +function readBaseFile(baseRef, path) { + const text = tryGit(['show', `${baseRef}:${path}`]); + return text ?? ''; +} + +function lineNumberAt(text, index) { + let line = 1; + for (let i = 0; i < index; i += 1) { + if (text[i] === '\n') { + line += 1; + } + } + return line; +} + +function findClosingBracket(text, start) { + let depth = 0; + let quote = null; + let escaped = false; + let lineComment = false; + let blockCommentDepth = 0; + + for (let i = start; i < text.length; i += 1) { + const char = text[i]; + const next = text[i + 1]; + + if (lineComment) { + if (char === '\n') { + lineComment = false; + } + continue; + } + + if (blockCommentDepth > 0) { + if (char === '/' && next === '*') { + blockCommentDepth += 1; + i += 1; + } else if (char === '*' && next === '/') { + blockCommentDepth -= 1; + i += 1; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + lineComment = true; + i += 1; + continue; + } + + if (char === '/' && next === '*') { + blockCommentDepth = 1; + i += 1; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '[') { + depth += 1; + } else if (char === ']') { + depth -= 1; + if (depth === 0) { + return i + 1; + } + } + } + + return -1; +} + +function findClosingBrace(text, start) { + let depth = 0; + let quote = null; + let escaped = false; + let lineComment = false; + let blockCommentDepth = 0; + + for (let i = start; i < text.length; i += 1) { + const char = text[i]; + const next = text[i + 1]; + + if (lineComment) { + if (char === '\n') { + lineComment = false; + } + continue; + } + + if (blockCommentDepth > 0) { + if (char === '/' && next === '*') { + blockCommentDepth += 1; + i += 1; + } else if (char === '*' && next === '/') { + blockCommentDepth -= 1; + i += 1; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + lineComment = true; + i += 1; + continue; + } + + if (char === '/' && next === '*') { + blockCommentDepth = 1; + i += 1; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + + return -1; +} + +function splitTopLevelSegments(text) { + const segments = []; + let start = 0; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + let angleDepth = 0; + let quote = null; + let escaped = false; + let lineComment = false; + let blockCommentDepth = 0; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + const next = text[i + 1]; + + if (lineComment) { + if (char === '\n') { + lineComment = false; + } + continue; + } + + if (blockCommentDepth > 0) { + if (char === '/' && next === '*') { + blockCommentDepth += 1; + i += 1; + } else if (char === '*' && next === '/') { + blockCommentDepth -= 1; + i += 1; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + lineComment = true; + i += 1; + continue; + } + + if (char === '/' && next === '*') { + blockCommentDepth = 1; + i += 1; + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '(') { + parenDepth += 1; + } else if (char === ')') { + parenDepth = Math.max(0, parenDepth - 1); + } else if (char === '[') { + bracketDepth += 1; + } else if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1); + } else if (char === '{') { + braceDepth += 1; + } else if (char === '}') { + braceDepth = Math.max(0, braceDepth - 1); + } else if (char === '<') { + angleDepth += 1; + } else if (char === '>') { + angleDepth = Math.max(0, angleDepth - 1); + } else if ( + char === ',' && + parenDepth === 0 && + bracketDepth === 0 && + braceDepth === 0 && + angleDepth === 0 + ) { + segments.push({ text: text.slice(start, i), start }); + start = i + 1; + } + } + + segments.push({ text: text.slice(start), start }); + return segments; +} + +function normalizeRustText(text) { + return text.replace(/\s+/gu, ' ').trim(); +} + +function parseField(segment, fileText, bodyStartIndex) { + const withoutLineComments = segment.text.replace(/\/\/.*$/gmu, '').trim(); + if (!withoutLineComments) { + return null; + } + + const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map((match) => + normalizeRustText(match[0]), + ); + const fieldText = withoutLineComments.replace(/#\[[\s\S]*?\]\s*/gu, '').trim(); + const fieldMatch = + /^(?:pub(?:\([^)]*\))?\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/u.exec( + fieldText, + ); + + if (!fieldMatch) { + return null; + } + + return { + name: fieldMatch[1], + type: normalizeRustText(fieldMatch[2]), + attrs, + hasDefault: attrs.some((attr) => /^#\[\s*default\b/u.test(attr)), + line: lineNumberAt(fileText, bodyStartIndex + segment.start), + }; +} + +function parseFields(body, fileText, bodyStartIndex) { + return splitTopLevelSegments(body) + .map((segment) => parseField(segment, fileText, bodyStartIndex)) + .filter(Boolean); +} + +function parseTablesFromFile(path, text) { + const tables = []; + const tableAttrPattern = /#\[\s*(?:spacetimedb::)?table\s*\(/gu; + let match; + + while ((match = tableAttrPattern.exec(text)) !== null) { + const attrStart = match.index; + const attrEnd = findClosingBracket(text, attrStart); + if (attrEnd < 0) { + continue; + } + + const attrText = text.slice(attrStart, attrEnd); + const accessorMatch = /accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec( + attrText, + ); + const accessor = accessorMatch?.[1] ?? accessorMatch?.[2]; + if (!accessor) { + continue; + } + + const afterAttr = text.slice(attrEnd, attrEnd + 4000); + const structMatch = + /(?:#\[[\s\S]*?\]\s*)*(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/u.exec( + afterAttr, + ); + if (!structMatch) { + continue; + } + + const structStart = attrEnd + structMatch.index; + const structOpenBrace = structStart + structMatch[0].lastIndexOf('{'); + const structCloseBrace = findClosingBrace(text, structOpenBrace); + if (structCloseBrace < 0) { + continue; + } + + const bodyStartIndex = structOpenBrace + 1; + const body = text.slice(bodyStartIndex, structCloseBrace); + tables.push({ + accessor, + structName: structMatch[1], + path, + line: lineNumberAt(text, structStart), + fields: parseFields(body, text, bodyStartIndex), + }); + + tableAttrPattern.lastIndex = structCloseBrace + 1; + } + + return tables; +} + +function collectTablesFromSources(sources) { + const tables = new Map(); + const failures = []; + + for (const source of sources) { + for (const table of parseTablesFromFile(source.path, source.text)) { + const previous = tables.get(table.accessor); + if (previous) { + failures.push( + `${table.path}:${table.line}: SpacetimeDB table accessor ${table.accessor} 重复定义,首次定义在 ${previous.path}:${previous.line}`, + ); + continue; + } + tables.set(table.accessor, table); + } + } + + return { tables, failures }; +} + +function loadCurrentSources() { + return listCurrentRustFiles(moduleSrcRoot).map((path) => ({ + path, + text: readCurrentFile(path), + })); +} + +function loadBaseSources(baseRef) { + return listBaseRustFiles(baseRef).map((path) => ({ + path, + text: readBaseFile(baseRef, path), + })); +} + +function getChangedFiles(baseRef) { + const diffOutput = tryGit(['diff', '--name-only', baseRef, '--']) ?? ''; + const untrackedOutput = + tryGit(['ls-files', '--others', '--exclude-standard', moduleSrcRoot]) ?? ''; + return new Set( + [...diffOutput.split(/\r?\n/u), ...untrackedOutput.split(/\r?\n/u)] + .map(normalizePath) + .filter(Boolean), + ); +} + +function sameFieldSchema(left, right) { + return ( + left.name === right.name && + left.type === right.type && + left.attrs.join('\n') === right.attrs.join('\n') + ); +} + +function fieldDescription(field) { + return `${field.name}: ${field.type}`; +} + +function compareTables(baseTables, currentTables) { + const failures = []; + let schemaChanged = false; + let breakingChanged = false; + + for (const [accessor, baseTable] of baseTables) { + const currentTable = currentTables.get(accessor); + if (!currentTable) { + schemaChanged = true; + breakingChanged = true; + failures.push( + `${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`, + ); + continue; + } + + const currentFieldNames = new Set(currentTable.fields.map((field) => field.name)); + if (currentTable.fields.length < baseTable.fields.length) { + schemaChanged = true; + breakingChanged = true; + failures.push( + `${currentTable.path}:${currentTable.line}: SpacetimeDB 表 ${accessor} 字段数量减少。删除或改名字段必须先询问用户并确认迁移计划。`, + ); + } + + for (let index = 0; index < baseTable.fields.length; index += 1) { + const baseField = baseTable.fields[index]; + const currentField = currentTable.fields[index]; + + if (!currentField) { + continue; + } + + if (sameFieldSchema(baseField, currentField)) { + continue; + } + + schemaChanged = true; + breakingChanged = true; + + if (baseField.name !== currentField.name) { + const baseFieldStillExists = currentFieldNames.has(baseField.name); + const reason = baseFieldStillExists ? '字段顺序被调整' : '字段被删除或改名'; + failures.push( + `${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor} 的第 ${ + index + 1 + } 个字段从 ${baseField.name} 变为 ${currentField.name},疑似${reason}。只能在结构体最后追加新字段;改名必须先询问用户并确认迁移计划。`, + ); + continue; + } + + failures.push( + `${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor}.${currentField.name} 的 schema 从 ${fieldDescription( + baseField, + )} 变为 ${fieldDescription(currentField)}。修改已有字段类型或属性必须先询问用户并确认迁移计划。`, + ); + } + + if (currentTable.fields.length > baseTable.fields.length) { + schemaChanged = true; + if (!breakingChanged) { + const addedFields = currentTable.fields.slice(baseTable.fields.length); + for (const field of addedFields) { + if (!field.hasDefault) { + failures.push( + `${currentTable.path}:${field.line}: SpacetimeDB 表 ${accessor} 新增字段 ${field.name} 必须放在结构体最后并添加 #[default(...)]。当前字段位于末尾但缺少默认值。`, + ); + } + } + } + } + } + + for (const [accessor, table] of currentTables) { + if (!baseTables.has(accessor)) { + schemaChanged = true; + failures.push( + `${table.path}:${table.line}: 新增 SpacetimeDB 表 ${accessor}。请同步 migration.rs、表目录和生成绑定。`, + ); + } + } + + return { failures, schemaChanged, breakingChanged }; +} + +function checkSchemaSidecars(changedFiles, schemaChanged) { + if (!schemaChanged) { + return []; + } + + const failures = []; + if (!changedFiles.has(migrationPath)) { + failures.push( + `SpacetimeDB schema 已变化,但 ${migrationPath} 没有同步变更。row shape 或表变化必须同步迁移导入导出口径。`, + ); + } + + if (!changedFiles.has(tableCatalogPath)) { + failures.push( + `SpacetimeDB schema 已变化,但 ${tableCatalogPath} 没有同步变更。表结构目录必须跟源码一致。`, + ); + } + + const bindingsChanged = [...changedFiles].some((path) => path.startsWith(bindingsRoot)); + if (!bindingsChanged) { + failures.push( + `SpacetimeDB schema 已变化,但 ${bindingsRoot} 下没有生成绑定变更。请重新生成并提交绑定。`, + ); + } + + return failures; +} + +function main() { + const baseRef = resolveBaseRef(); + const currentSources = loadCurrentSources(); + const baseSources = loadBaseSources(baseRef); + const currentResult = collectTablesFromSources(currentSources); + const baseResult = collectTablesFromSources(baseSources); + const compareResult = compareTables(baseResult.tables, currentResult.tables); + const changedFiles = getChangedFiles(baseRef); + const sidecarFailures = checkSchemaSidecars(changedFiles, compareResult.schemaChanged); + const failures = [ + ...currentResult.failures, + ...baseResult.failures, + ...compareResult.failures, + ...sidecarFailures, + ]; + + if (compareResult.breakingChanged && !allowBreaking) { + failures.push( + '检测到 SpacetimeDB 字段删除、改名、重排、类型或属性修改。请先询问用户并确认迁移计划;确认后如确需继续,可在人工确认的迁移提交中设置 SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 运行本检查。', + ); + } + + if (failures.length > 0) { + console.error(`SpacetimeDB schema guard failed against ${baseRef}:`); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log( + `SpacetimeDB schema guard passed for ${currentResult.tables.size} table(s) against ${baseRef}.`, + ); +} + +main();