Merge remote-tracking branch 'origin/hermes/wechat'
This commit is contained in:
@@ -69,6 +69,8 @@ npm run dev:web
|
|||||||
npm run api-server
|
npm run api-server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
该命令会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` / `npm run dev:rust` 启动的 Rust `api-server` 也会写入 `logs/api-server/api-server-dev-rust-<timestamp>.log`。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||||
|
|
||||||
查看本地 Rust/SpacetimeDB 日志:
|
查看本地 Rust/SpacetimeDB 日志:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -691,9 +691,18 @@
|
|||||||
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
|
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
|
||||||
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
|
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `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`。
|
- 处理: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 密钥验证签名与解密链路。
|
- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
|
||||||
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
|
- 关联:`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 解码
|
## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
|
||||||
|
|
||||||
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
|
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# api-server 能力模块化与生成资产 Adapter 完整收口计划
|
# api-server 能力模块化与生成资产 Adapter 完整收口计划
|
||||||
|
|
||||||
状态:待执行
|
状态:待执行
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@
|
|||||||
|
|
||||||
1. 校验 `productId`
|
1. 校验 `productId`
|
||||||
2. `paymentChannel = "mock"` 时后端创建已支付订单
|
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。
|
||||||
|
- JSAPI 下单请求必须显式携带 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。
|
||||||
4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态
|
4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态
|
||||||
5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams`
|
5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams`
|
||||||
|
|
||||||
@@ -84,16 +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。行为:
|
微信支付通知地址,无需 Bearer JWT。行为:
|
||||||
|
|
||||||
1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。
|
1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签;验签必须使用原始 HTTP body bytes 构造 `timestamp\nnonce\nbody\n`,不能先把 body 转成字符串再重建。
|
||||||
2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`。
|
2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`。
|
||||||
3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。
|
3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。
|
||||||
4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。
|
4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。
|
||||||
5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。
|
5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。
|
||||||
6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。
|
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。
|
||||||
|
|
||||||
关键环境变量:
|
关键环境变量:
|
||||||
|
|
||||||
@@ -115,8 +142,14 @@
|
|||||||
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
|
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
|
||||||
3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。
|
3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。
|
||||||
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。
|
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。
|
||||||
5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。
|
- 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。
|
||||||
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
|
- native 支付页通过 `wx_pay_result=<requestId>: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. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。
|
||||||
|
7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
|
||||||
|
|
||||||
## 5. 验收
|
## 5. 验收
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,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。
|
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:<spacetime-port>` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:<spacetime-port>/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
6. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:<spacetime-port>` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:<spacetime-port>/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 冲突时清除旧模块数据。
|
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:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
9. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
||||||
10. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
10. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
||||||
11. 任一子进程退出时,脚本回收其余子进程。
|
11. 任一子进程退出时,脚本回收其余子进程。
|
||||||
@@ -120,6 +120,12 @@ npm run dev:rust:logs -- --follow
|
|||||||
3. 默认输出到 `logs/spacetime/<database>-<timestamp>.log`,并通过 `tee` 同步显示在终端。
|
3. 默认输出到 `logs/spacetime/<database>-<timestamp>.log`,并通过 `tee` 同步显示在终端。
|
||||||
4. `--follow` 仅用于本地追踪,会持续追加到同一个输出文件;停止时用 `Ctrl+C`。
|
4. `--follow` 仅用于本地追踪,会持续追加到同一个输出文件;停止时用 `Ctrl+C`。
|
||||||
|
|
||||||
|
api-server 本地持久化日志:
|
||||||
|
|
||||||
|
1. `npm run api-server` 默认写入 `logs/api-server/api-server-<timestamp>.log`,同时继续把同一份输出显示在当前终端。
|
||||||
|
2. `npm run dev` / `npm run dev:rust` 中由脚本启动的 Rust `api-server` 默认写入 `logs/api-server/api-server-dev-rust-<timestamp>.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/<database>/subscribe` 是否指向了未发布的库。
|
1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database/<database>/subscribe` 是否指向了未发布的库。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||||
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
|
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
|
||||||
|
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
||||||
|
|
||||||
function isConfiguredEntryUrl(value) {
|
function isConfiguredEntryUrl(value) {
|
||||||
const trimmed = String(value || '').trim();
|
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) {
|
async handleGetPhoneNumber(event) {
|
||||||
if (!this.data.authResult || !this.data.authResult.token) {
|
if (!this.data.authResult || !this.data.authResult.token) {
|
||||||
this.handleRetryLogin();
|
this.handleRetryLogin();
|
||||||
|
|||||||
@@ -30,18 +30,25 @@ function requestPayment(payParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
||||||
|
|
||||||
function appendPayResult(url, requestId, status) {
|
function appendPayResult(url, requestId, status) {
|
||||||
const value = `${requestId}:${status}`;
|
const value = `${requestId}:${status}`;
|
||||||
const hashIndex = String(url || '').indexOf('#');
|
const hashIndex = String(url || '').indexOf('#');
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
||||||
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
||||||
const params = new URLSearchParams(rawHash);
|
const nextHash = rawHash
|
||||||
params.set('wx_pay_result', value);
|
.split('&')
|
||||||
return `${baseUrl}#${params.toString()}`;
|
.filter((part) => part && !part.startsWith('wx_pay_result='))
|
||||||
|
.concat(`wx_pay_result=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
return `${baseUrl}#${nextHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyPreviousWebView(requestId, status) {
|
function notifyPreviousWebView(requestId, status) {
|
||||||
|
const result = `${requestId}:${status}`;
|
||||||
|
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
|
||||||
const pages = getCurrentPages();
|
const pages = getCurrentPages();
|
||||||
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
|
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
|
||||||
if (previousPage && typeof previousPage.setData === 'function') {
|
if (previousPage && typeof previousPage.setData === 'function') {
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ export type CreateProfileRechargeOrderResponse = {
|
|||||||
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
|
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConfirmWechatProfileRechargeOrderResponse = {
|
||||||
|
order: ProfileRechargeOrder;
|
||||||
|
center: ProfileRechargeCenterResponse;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProfileFeedbackStatus = 'open';
|
export type ProfileFeedbackStatus = 'open';
|
||||||
|
|
||||||
export type ProfileFeedbackEvidenceItemInput = {
|
export type ProfileFeedbackEvidenceItemInput = {
|
||||||
|
|||||||
@@ -12,7 +12,16 @@
|
|||||||
"outputPath": ""
|
"outputPath": ""
|
||||||
},
|
},
|
||||||
"useCompilerPlugins": false,
|
"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",
|
"compileType": "miniprogram",
|
||||||
"miniprogramRoot": "miniprogram/",
|
"miniprogramRoot": "miniprogram/",
|
||||||
@@ -22,5 +31,6 @@
|
|||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"appid": "wx3da23ea14ca66b65",
|
"appid": "wx3da23ea14ca66b65",
|
||||||
"editorSetting": {}
|
"editorSetting": {},
|
||||||
}
|
"libVersion": "3.15.2"
|
||||||
|
}
|
||||||
@@ -2,13 +2,20 @@
|
|||||||
"libVersion": "3.15.2",
|
"libVersion": "3.15.2",
|
||||||
"projectname": "Genarrative",
|
"projectname": "Genarrative",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true,
|
"urlCheck": false,
|
||||||
"coverView": true,
|
"coverView": true,
|
||||||
"lazyloadPlaceholderEnable": false,
|
"lazyloadPlaceholderEnable": false,
|
||||||
"skylineRenderEnable": false,
|
"skylineRenderEnable": false,
|
||||||
"preloadBackgroundData": false,
|
"preloadBackgroundData": false,
|
||||||
"autoAudits": false,
|
"autoAudits": false,
|
||||||
"showShadowRootInWxmlPanel": true,
|
"showShadowRootInWxmlPanel": true,
|
||||||
"compileHotReLoad": true
|
"compileHotReLoad": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"useStaticServer": false,
|
||||||
|
"useLanDebug": false,
|
||||||
|
"showES6CompileOption": false,
|
||||||
|
"checkInvalidKey": true,
|
||||||
|
"ignoreDevUnusedFiles": true,
|
||||||
|
"bigPackageSizeSupport": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { execFileSync, spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import {
|
||||||
import { resolve } from 'node:path';
|
createWriteStream,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
@@ -67,7 +72,69 @@ export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) {
|
|||||||
return mergedEnv;
|
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') {
|
if (process.platform !== 'win32') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,7 +171,7 @@ function stopExistingWindowsApiServer() {
|
|||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
if (output) {
|
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 =
|
||||||
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) {
|
if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
|
||||||
console.error('[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。');
|
writeLauncherLog(
|
||||||
process.exit(1);
|
logStream,
|
||||||
|
'[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。',
|
||||||
|
process.stderr,
|
||||||
|
);
|
||||||
|
exitAfterLogFlush(1);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stopExistingWindowsApiServer();
|
stopExistingWindowsApiServer(logStream);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[api-server] 清理旧 api-server 进程失败: ${error.message}`);
|
writeLauncherLog(
|
||||||
process.exit(1);
|
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}`,
|
`[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,22 +246,41 @@ function main() {
|
|||||||
{
|
{
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: mergedEnv,
|
env: mergedEnv,
|
||||||
stdio: 'inherit',
|
stdio: ['inherit', 'pipe', 'pipe'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
child.on('error', (error) => {
|
child.stdout?.on('data', (chunk) => {
|
||||||
console.error(`[api-server] 启动 cargo 失败: ${error.message}`);
|
process.stdout.write(chunk);
|
||||||
process.exit(1);
|
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) {
|
if (signal) {
|
||||||
console.error(`[api-server] api-server 被信号终止: ${signal}`);
|
writeLauncherLog(
|
||||||
process.exit(1);
|
logStream,
|
||||||
|
`[api-server] api-server 被信号终止: ${signal}`,
|
||||||
|
process.stderr,
|
||||||
|
);
|
||||||
|
exitAfterLogFlush(1);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(code ?? 0);
|
exitAfterLogFlush(code ?? 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
import { describe, expect, test } from 'vitest';
|
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<string, string>;
|
type EnvMap = Record<string, string>;
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -658,11 +658,13 @@ wait_for_api_server() {
|
|||||||
local health_url="$1"
|
local health_url="$1"
|
||||||
local timeout_seconds="$2"
|
local timeout_seconds="$2"
|
||||||
local process_pid="${3:-}"
|
local process_pid="${3:-}"
|
||||||
|
local log_file="${4:-${API_SERVER_LOG_FILE:-}}"
|
||||||
local deadline=$((SECONDS + timeout_seconds))
|
local deadline=$((SECONDS + timeout_seconds))
|
||||||
|
|
||||||
while ((SECONDS < deadline)); do
|
while ((SECONDS < deadline)); do
|
||||||
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||||||
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
|
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
|
||||||
|
print_api_server_log_tail "${log_file}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -684,9 +686,58 @@ request.on("error", () => process.exit(1));
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
|
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
|
||||||
|
print_api_server_log_tail "${log_file}"
|
||||||
exit 1
|
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() {
|
generate_migration_bootstrap_secret() {
|
||||||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||||
@@ -995,22 +1046,26 @@ API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server"
|
|||||||
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
|
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
|
||||||
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
|
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
|
||||||
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${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 actual: ${RUST_SERVER_TARGET}"
|
||||||
|
echo "[dev:rust] api-server log: ${API_SERVER_LOG_FILE}"
|
||||||
(
|
(
|
||||||
cd "${REPO_ROOT}"
|
cd "${REPO_ROOT}"
|
||||||
GENARRATIVE_API_HOST="${API_HOST}" \
|
GENARRATIVE_API_HOST="${API_HOST}" \
|
||||||
GENARRATIVE_API_PORT="${API_PORT}" \
|
GENARRATIVE_API_PORT="${API_PORT}" \
|
||||||
GENARRATIVE_API_LOG="${API_LOG}" \
|
GENARRATIVE_API_LOG="${API_LOG}" \
|
||||||
|
GENARRATIVE_API_SERVER_LOG_FILE="${API_SERVER_LOG_FILE}" \
|
||||||
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
|
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
|
||||||
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
|
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
|
||||||
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
|
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
|
||||||
) &
|
) > >(tee -a "${API_SERVER_LOG_FILE}") 2>&1 &
|
||||||
API_PID="$!"
|
API_PID="$!"
|
||||||
PIDS+=("${API_PID}")
|
PIDS+=("${API_PID}")
|
||||||
NAMES+=("api-server")
|
NAMES+=("api-server")
|
||||||
|
|
||||||
echo "[dev:rust] 等待 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"
|
echo "[dev:rust] 启动 vite"
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。
|
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。
|
||||||
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。
|
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。
|
||||||
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
|
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 约定:
|
当前 request context 约定:
|
||||||
|
|
||||||
|
|||||||
@@ -136,10 +136,11 @@ use crate::{
|
|||||||
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
|
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
|
||||||
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
|
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
|
||||||
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
|
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
|
||||||
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
|
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
|
||||||
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
|
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
|
||||||
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
|
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||||
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
|
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
|
||||||
|
redeem_profile_reward_code, submit_profile_feedback,
|
||||||
},
|
},
|
||||||
runtime_save::{
|
runtime_save::{
|
||||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||||
@@ -1488,6 +1489,12 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
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(
|
.route(
|
||||||
"/api/profile/recharge/wechat/notify",
|
"/api/profile/recharge/wechat/notify",
|
||||||
post(handle_wechat_pay_notify),
|
post(handle_wechat_pay_notify),
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ use module_runtime::{
|
|||||||
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||||
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord,
|
||||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
||||||
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
|
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
|
||||||
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
|
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
|
||||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
|
||||||
RuntimeTrackingScopeKind,
|
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -25,10 +25,10 @@ use shared_contracts::runtime::{
|
|||||||
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
|
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
|
||||||
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
|
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
|
||||||
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
||||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
|
||||||
PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
|
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY,
|
||||||
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
|
PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
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_ASSET_OPERATION_REFUND,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
|
||||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||||
@@ -63,7 +63,10 @@ use crate::{
|
|||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
request_context::RequestContext,
|
request_context::RequestContext,
|
||||||
state::AppState,
|
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(
|
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<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
Path(order_id): Path<String>,
|
||||||
|
) -> Result<Json<Value>, 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(
|
pub async fn submit_profile_feedback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
@@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment(
|
|||||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
.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(
|
fn build_profile_recharge_center_response(
|
||||||
record: RuntimeProfileRechargeCenterRecord,
|
record: RuntimeProfileRechargeCenterRecord,
|
||||||
) -> ProfileRechargeCenterResponse {
|
) -> ProfileRechargeCenterResponse {
|
||||||
@@ -1260,6 +1372,7 @@ mod tests {
|
|||||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
@@ -1271,6 +1384,20 @@ mod tests {
|
|||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
|
|||||||
use shared_kernel::offset_datetime_to_unix_micros;
|
use shared_kernel::offset_datetime_to_unix_micros;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::{http_error::AppError, state::AppState};
|
use crate::{http_error::AppError, state::AppState};
|
||||||
|
|
||||||
@@ -25,7 +26,17 @@ const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
|
|||||||
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
||||||
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
||||||
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
||||||
const WECHAT_PAY_NOTIFY_SUCCESS: &str = "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
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;
|
||||||
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum WechatPayClient {
|
pub enum WechatPayClient {
|
||||||
@@ -46,6 +57,7 @@ pub struct RealWechatPayClient {
|
|||||||
api_v3_key: String,
|
api_v3_key: String,
|
||||||
notify_url: String,
|
notify_url: String,
|
||||||
jsapi_endpoint: String,
|
jsapi_endpoint: String,
|
||||||
|
query_order_endpoint_base: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -73,11 +85,10 @@ pub enum WechatPayError {
|
|||||||
Upstream(String),
|
Upstream(String),
|
||||||
Deserialize(String),
|
Deserialize(String),
|
||||||
Crypto(String),
|
Crypto(String),
|
||||||
InvalidSignature,
|
InvalidSignature(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct WechatJsapiOrderRequest<'a> {
|
struct WechatJsapiOrderRequest<'a> {
|
||||||
appid: &'a str,
|
appid: &'a str,
|
||||||
mchid: &'a str,
|
mchid: &'a str,
|
||||||
@@ -130,6 +141,16 @@ struct WechatPayTransactionResource {
|
|||||||
success_time: Option<String>,
|
success_time: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatPayQueryOrderResponse {
|
||||||
|
out_trade_no: String,
|
||||||
|
#[serde(default)]
|
||||||
|
transaction_id: Option<String>,
|
||||||
|
trade_state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
success_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl WechatPayClient {
|
impl WechatPayClient {
|
||||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||||
if !config.wechat_pay_enabled {
|
if !config.wechat_pay_enabled {
|
||||||
@@ -196,10 +217,12 @@ impl WechatPayClient {
|
|||||||
config.wechat_pay_notify_url.as_deref(),
|
config.wechat_pay_notify_url.as_deref(),
|
||||||
"WECHAT_PAY_NOTIFY_URL",
|
"WECHAT_PAY_NOTIFY_URL",
|
||||||
)?;
|
)?;
|
||||||
|
validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?;
|
||||||
let jsapi_endpoint = normalize_required_url(
|
let jsapi_endpoint = normalize_required_url(
|
||||||
&config.wechat_pay_jsapi_endpoint,
|
&config.wechat_pay_jsapi_endpoint,
|
||||||
"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 {
|
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
@@ -212,6 +235,7 @@ impl WechatPayClient {
|
|||||||
api_v3_key,
|
api_v3_key,
|
||||||
notify_url,
|
notify_url,
|
||||||
jsapi_endpoint,
|
jsapi_endpoint,
|
||||||
|
query_order_endpoint_base,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +261,22 @@ impl WechatPayClient {
|
|||||||
Self::Real(client) => client.parse_notify(headers, body),
|
Self::Real(client) => client.parse_notify(headers, body),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn query_order_by_out_trade_no(
|
||||||
|
&self,
|
||||||
|
order_id: &str,
|
||||||
|
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||||
|
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 {
|
impl RealWechatPayClient {
|
||||||
@@ -244,6 +284,7 @@ impl RealWechatPayClient {
|
|||||||
&self,
|
&self,
|
||||||
request: WechatMiniProgramOrderRequest,
|
request: WechatMiniProgramOrderRequest,
|
||||||
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||||
|
validate_jsapi_order_request(self, &request)?;
|
||||||
let amount_total = i64::try_from(request.amount_cents)
|
let amount_total = i64::try_from(request.amount_cents)
|
||||||
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
|
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
|
||||||
let body = serde_json::to_string(&WechatJsapiOrderRequest {
|
let body = serde_json::to_string(&WechatJsapiOrderRequest {
|
||||||
@@ -270,18 +311,18 @@ impl RealWechatPayClient {
|
|||||||
&nonce,
|
&nonce,
|
||||||
&body,
|
&body,
|
||||||
)?;
|
)?;
|
||||||
let response = self
|
let response = with_wechat_pay_jsapi_headers(
|
||||||
.client
|
self.client
|
||||||
.post(&self.jsapi_endpoint)
|
.post(&self.jsapi_endpoint)
|
||||||
.header("Authorization", authorization)
|
.header("Authorization", authorization),
|
||||||
.header("Accept", "application/json")
|
&self.platform_serial_no,
|
||||||
.header("Content-Type", "application/json")
|
)
|
||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
||||||
})?;
|
})?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let response_text = response.text().await.map_err(|error| {
|
let response_text = response.text().await.map_err(|error| {
|
||||||
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
|
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
|
||||||
@@ -381,6 +422,58 @@ impl RealWechatPayClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn query_order_by_out_trade_no(
|
||||||
|
&self,
|
||||||
|
order_id: &str,
|
||||||
|
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||||
|
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::<WechatPayQueryOrderResponse>(&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(
|
fn verify_notify_signature(
|
||||||
&self,
|
&self,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -391,25 +484,33 @@ impl RealWechatPayClient {
|
|||||||
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
||||||
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
||||||
if serial != self.platform_serial_no {
|
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!(
|
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
|
||||||
"{}\n{}\n{}\n",
|
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
|
||||||
timestamp,
|
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
|
||||||
nonce,
|
})?;
|
||||||
String::from_utf8_lossy(body)
|
|
||||||
);
|
|
||||||
let signature_bytes = BASE64_STANDARD
|
|
||||||
.decode(signature)
|
|
||||||
.map_err(|_| WechatPayError::InvalidSignature)?;
|
|
||||||
let public_key = signature::UnparsedPublicKey::new(
|
let public_key = signature::UnparsedPublicKey::new(
|
||||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||||
&self.platform_public_key_der,
|
&self.platform_public_key_der,
|
||||||
);
|
);
|
||||||
public_key
|
public_key
|
||||||
.verify(message.as_bytes(), &signature_bytes)
|
.verify(&message, &signature_bytes)
|
||||||
.map_err(|_| WechatPayError::InvalidSignature)
|
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
||||||
@@ -431,7 +532,7 @@ pub async fn handle_wechat_pay_notify(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<&'static str, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let notify = state
|
let notify = state
|
||||||
.wechat_pay_client()
|
.wechat_pay_client()
|
||||||
.parse_notify(&headers, &body)
|
.parse_notify(&headers, &body)
|
||||||
@@ -442,7 +543,7 @@ pub async fn handle_wechat_pay_notify(
|
|||||||
trade_state = notify.trade_state.as_str(),
|
trade_state = notify.trade_state.as_str(),
|
||||||
"收到非成功微信支付通知"
|
"收到非成功微信支付通知"
|
||||||
);
|
);
|
||||||
return Ok(WECHAT_PAY_NOTIFY_SUCCESS);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
let paid_at_micros = notify
|
let paid_at_micros = notify
|
||||||
@@ -469,7 +570,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 {
|
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||||
@@ -491,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
|||||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||||
.with_message(message)
|
.with_message(message)
|
||||||
.with_details(json!({ "provider": "wechat_pay" })),
|
.with_details(json!({ "provider": "wechat_pay" })),
|
||||||
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
|
WechatPayError::InvalidSignature(message) => {
|
||||||
.with_message("微信支付通知签名无效")
|
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||||
.with_details(json!({ "provider": "wechat_pay" })),
|
.with_message("微信支付通知签名无效")
|
||||||
|
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +628,27 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
|||||||
map_wechat_pay_error(error)
|
map_wechat_pay_error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
reqwest::header::CONTENT_TYPE,
|
||||||
|
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 {
|
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||||
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||||
let nonce_str = "mock-nonce".to_string();
|
let nonce_str = "mock-nonce".to_string();
|
||||||
@@ -595,6 +719,122 @@ fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayErr
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_notify_url(value: &str, key: &str) -> 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 resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, WechatPayError> {
|
||||||
|
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<String, WechatPayError> {
|
||||||
|
let value = value.trim();
|
||||||
|
validate_out_trade_no(value)?;
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
fn read_private_key_pem(
|
||||||
inline_pem: Option<&str>,
|
inline_pem: Option<&str>,
|
||||||
path: Option<&Path>,
|
path: Option<&Path>,
|
||||||
@@ -724,7 +964,18 @@ fn read_required_header<'a>(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.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<u8> {
|
||||||
|
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 {
|
fn hex_sha256(content: &[u8]) -> String {
|
||||||
@@ -747,7 +998,7 @@ impl std::fmt::Display for WechatPayError {
|
|||||||
| Self::Upstream(message)
|
| Self::Upstream(message)
|
||||||
| Self::Deserialize(message)
|
| Self::Deserialize(message)
|
||||||
| Self::Crypto(message) => formatter.write_str(message),
|
| Self::Crypto(message) => formatter.write_str(message),
|
||||||
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
|
Self::InvalidSignature(message) => formatter.write_str(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,6 +1019,115 @@ mod tests {
|
|||||||
assert!(!params.pay_sign.is_empty());
|
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 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\"",
|
||||||
|
),
|
||||||
|
"PUB_KEY_ID_0119000000012026051400000000000001",
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
);
|
||||||
|
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]
|
#[test]
|
||||||
fn parse_mock_notify_defaults_success_state() {
|
fn parse_mock_notify_defaults_success_state() {
|
||||||
let notify =
|
let notify =
|
||||||
|
|||||||
@@ -1069,10 +1069,47 @@ pub fn build_runtime_profile_recharge_order_id(
|
|||||||
created_at_micros: i64,
|
created_at_micros: i64,
|
||||||
product_id: &str,
|
product_id: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
// 微信支付 v3 的 out_trade_no 只接受较短的字母、数字和部分符号。
|
||||||
"recharge:{}",
|
// 订单号同时作为本地 profile_recharge_order 主键,因此统一使用可支付渠道兼容的紧凑格式。
|
||||||
build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id)
|
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(
|
pub fn resolve_runtime_profile_points_recharge_delta(
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ pub fn build_runtime_profile_recharge_center_get_input(
|
|||||||
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
|
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_runtime_profile_recharge_order_get_input(
|
||||||
|
order_id: String,
|
||||||
|
) -> Result<RuntimeProfileRechargeOrderGetInput, RuntimeProfileFieldError> {
|
||||||
|
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(
|
pub fn build_runtime_profile_recharge_order_create_input(
|
||||||
user_id: String,
|
user_id: String,
|
||||||
product_id: String,
|
product_id: String,
|
||||||
|
|||||||
@@ -1060,6 +1060,12 @@ pub struct RuntimeProfileRechargeCenterGetInput {
|
|||||||
pub user_id: String,
|
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))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeProfileRechargeOrderCreateInput {
|
pub struct RuntimeProfileRechargeOrderCreateInput {
|
||||||
|
|||||||
@@ -716,9 +716,13 @@ mod tests {
|
|||||||
build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"),
|
build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"),
|
||||||
"user-1:200:points_60"
|
"user-1:200:points_60"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let order_id = build_runtime_profile_recharge_order_id("user-1", 200, "points_60");
|
||||||
build_runtime_profile_recharge_order_id("user-1", 200, "points_60"),
|
assert!(order_id.starts_with("rcg"));
|
||||||
"recharge:user-1:200:points_60"
|
assert!(order_id.len() <= 32);
|
||||||
|
assert!(
|
||||||
|
order_id
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2),
|
build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2),
|
||||||
|
|||||||
@@ -268,6 +268,13 @@ pub struct CreateProfileRechargeOrderResponse {
|
|||||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfileFeedbackEvidenceItemRequest {
|
pub struct ProfileFeedbackEvidenceItemRequest {
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ use module_runtime::{
|
|||||||
build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record,
|
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_center_get_input, build_runtime_profile_recharge_center_record,
|
||||||
build_runtime_profile_recharge_order_create_input,
|
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_disable_input,
|
||||||
build_runtime_profile_redeem_code_admin_list_input,
|
build_runtime_profile_redeem_code_admin_list_input,
|
||||||
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
|
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
|
||||||
|
|||||||
@@ -163,6 +163,16 @@ impl From<module_runtime::RuntimeProfileRechargeCenterGetInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<module_runtime::RuntimeProfileRechargeOrderGetInput>
|
||||||
|
for RuntimeProfileRechargeOrderGetInput
|
||||||
|
{
|
||||||
|
fn from(input: module_runtime::RuntimeProfileRechargeOrderGetInput) -> Self {
|
||||||
|
Self {
|
||||||
|
order_id: input.order_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
||||||
for RuntimeProfileRechargeOrderCreateInput
|
for RuntimeProfileRechargeOrderCreateInput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||||
|
) + 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<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
|
||||||
|
"get_profile_recharge_order_and_return",
|
||||||
|
GetProfileRechargeOrderAndReturnArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,6 +346,7 @@ pub mod get_player_progression_or_default_procedure;
|
|||||||
pub mod get_profile_dashboard_procedure;
|
pub mod get_profile_dashboard_procedure;
|
||||||
pub mod get_profile_play_stats_procedure;
|
pub mod get_profile_play_stats_procedure;
|
||||||
pub mod get_profile_recharge_center_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_referral_invite_center_procedure;
|
||||||
pub mod get_profile_task_center_procedure;
|
pub mod get_profile_task_center_procedure;
|
||||||
pub mod get_puzzle_agent_session_procedure;
|
pub mod get_puzzle_agent_session_procedure;
|
||||||
@@ -647,6 +648,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_procedure_result_type;
|
||||||
pub mod runtime_profile_recharge_center_snapshot_type;
|
pub mod runtime_profile_recharge_center_snapshot_type;
|
||||||
pub mod runtime_profile_recharge_order_create_input_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_paid_input_type;
|
||||||
pub mod runtime_profile_recharge_order_snapshot_type;
|
pub mod runtime_profile_recharge_order_snapshot_type;
|
||||||
pub mod runtime_profile_recharge_order_status_type;
|
pub mod runtime_profile_recharge_order_status_type;
|
||||||
@@ -1190,6 +1192,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_dashboard_procedure::get_profile_dashboard;
|
||||||
pub use get_profile_play_stats_procedure::get_profile_play_stats;
|
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_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_referral_invite_center_procedure::get_profile_referral_invite_center;
|
||||||
pub use get_profile_task_center_procedure::get_profile_task_center;
|
pub use get_profile_task_center_procedure::get_profile_task_center;
|
||||||
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
|
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
|
||||||
@@ -1491,6 +1494,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_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||||
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
|
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_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_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
|
||||||
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
|
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
|
||||||
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -268,6 +268,33 @@ impl SpacetimeClient {
|
|||||||
.await
|
.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(
|
pub async fn mark_profile_recharge_order_paid(
|
||||||
&self,
|
&self,
|
||||||
order_id: String,
|
order_id: String,
|
||||||
|
|||||||
@@ -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]
|
#[spacetimedb::procedure]
|
||||||
pub fn create_profile_recharge_order_and_return(
|
pub fn create_profile_recharge_order_and_return(
|
||||||
ctx: &mut ProcedureContext,
|
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(
|
fn mark_profile_recharge_order_paid_record(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: RuntimeProfileRechargeOrderPaidInput,
|
input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
PublicUserSummary,
|
PublicUserSummary,
|
||||||
} from '../../../packages/shared/src/contracts/auth';
|
} from '../../../packages/shared/src/contracts/auth';
|
||||||
import type {
|
import type {
|
||||||
|
ConfirmWechatProfileRechargeOrderResponse,
|
||||||
CreateProfileRechargeOrderResponse,
|
CreateProfileRechargeOrderResponse,
|
||||||
ProfileReferralInviteCenterResponse,
|
ProfileReferralInviteCenterResponse,
|
||||||
ProfileTaskCenterResponse,
|
ProfileTaskCenterResponse,
|
||||||
@@ -39,6 +40,7 @@ const {
|
|||||||
mockBuildReferralCenter,
|
mockBuildReferralCenter,
|
||||||
mockBuildTaskCenter,
|
mockBuildTaskCenter,
|
||||||
mockClaimRpgProfileTaskReward,
|
mockClaimRpgProfileTaskReward,
|
||||||
|
mockConfirmWechatRpgProfileRechargeOrder,
|
||||||
mockCreateRpgProfileRechargeOrder,
|
mockCreateRpgProfileRechargeOrder,
|
||||||
mockGetRpgProfileReferralInviteCenter,
|
mockGetRpgProfileReferralInviteCenter,
|
||||||
mockGetRpgProfileRechargeCenter,
|
mockGetRpgProfileRechargeCenter,
|
||||||
@@ -219,6 +221,65 @@ const {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
mockConfirmWechatRpgProfileRechargeOrder: vi.fn(
|
||||||
|
async (): Promise<ConfirmWechatProfileRechargeOrderResponse> => ({
|
||||||
|
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 () => ({
|
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
||||||
center: buildReferralCenter({
|
center: buildReferralCenter({
|
||||||
invitedUsers: [],
|
invitedUsers: [],
|
||||||
@@ -303,6 +364,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
|||||||
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
||||||
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
|
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
|
||||||
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
|
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
|
||||||
|
confirmWechatRpgProfileRechargeOrder:
|
||||||
|
mockConfirmWechatRpgProfileRechargeOrder,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../ResolvedAssetImage', () => ({
|
vi.mock('../ResolvedAssetImage', () => ({
|
||||||
@@ -859,6 +922,10 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
vi.unstubAllGlobals();
|
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(
|
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
|
||||||
mockBuildReferralCenter(),
|
mockBuildReferralCenter(),
|
||||||
);
|
);
|
||||||
@@ -964,18 +1031,19 @@ test('profile recharge modal buys points through mock channel outside mini progr
|
|||||||
'mock',
|
'mock',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(await screen.findByText('已到账')).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByRole('dialog', { name: '支付成功' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
|
||||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
const onRechargeSuccess = vi.fn();
|
||||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
const navigateTo = vi.fn((options: { url: string }) => {
|
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||||
const url = new URL(`https://mini.test${options.url}`);
|
options.success?.();
|
||||||
const requestId = url.searchParams.get('requestId');
|
|
||||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
|
||||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
||||||
});
|
});
|
||||||
window.wx = {
|
window.wx = {
|
||||||
miniProgram: {
|
miniProgram: {
|
||||||
@@ -1021,7 +1089,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
renderProfileView();
|
renderProfileView(onRechargeSuccess);
|
||||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||||
await user.click(
|
await user.click(
|
||||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||||
@@ -1036,12 +1104,352 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
});
|
});
|
||||||
expect(navigateTo).toHaveBeenCalledWith({
|
expect(navigateTo).toHaveBeenCalledWith({
|
||||||
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
||||||
|
success: expect.any(Function),
|
||||||
fail: expect.any(Function),
|
fail: expect.any(Function),
|
||||||
});
|
});
|
||||||
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
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(navigateUrl).toContain('order-wechat-1');
|
||||||
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
|
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',
|
||||||
|
);
|
||||||
|
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 () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
|
window.wx = undefined;
|
||||||
|
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
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<HTMLScriptElement>(
|
||||||
|
'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?'),
|
||||||
|
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(
|
||||||
|
await screen.findByRole('dialog', { name: '支付成功' }),
|
||||||
|
).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.findByRole('dialog', { name: '支付已取消' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByText('本次没有扣款,账户状态未发生变化。')).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 () => {
|
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
AlertCircle,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Camera,
|
Camera,
|
||||||
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock3,
|
Clock3,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
Ticket,
|
Ticket,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
@@ -46,6 +49,7 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a
|
|||||||
import type {
|
import type {
|
||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
PlatformBrowseHistoryEntry,
|
PlatformBrowseHistoryEntry,
|
||||||
|
ConfirmWechatProfileRechargeOrderResponse,
|
||||||
ProfileDashboardCardKey,
|
ProfileDashboardCardKey,
|
||||||
ProfileDashboardSummary,
|
ProfileDashboardSummary,
|
||||||
ProfilePlayedWorkSummary,
|
ProfilePlayedWorkSummary,
|
||||||
@@ -71,6 +75,7 @@ import {
|
|||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import {
|
import {
|
||||||
claimRpgProfileTaskReward,
|
claimRpgProfileTaskReward,
|
||||||
|
confirmWechatRpgProfileRechargeOrder,
|
||||||
createRpgProfileRechargeOrder,
|
createRpgProfileRechargeOrder,
|
||||||
getRpgProfileReferralInviteCenter,
|
getRpgProfileReferralInviteCenter,
|
||||||
getRpgProfileRechargeCenter,
|
getRpgProfileRechargeCenter,
|
||||||
@@ -213,10 +218,23 @@ const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
|||||||
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||||
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||||
const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
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 ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||||
type RechargeTab = 'points' | 'membership';
|
type RechargeTab = 'points' | 'membership';
|
||||||
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||||
|
type WechatPayResult = {
|
||||||
|
requestId: string;
|
||||||
|
orderId: string | null;
|
||||||
|
status: WechatMiniProgramPaymentStatus;
|
||||||
|
};
|
||||||
|
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
|
||||||
|
type RechargePaymentResult = {
|
||||||
|
kind: RechargePaymentResultKind;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
type DiscoverChannel =
|
type DiscoverChannel =
|
||||||
| 'recommend'
|
| 'recommend'
|
||||||
| 'today'
|
| 'today'
|
||||||
@@ -2348,54 +2366,136 @@ function clearWechatPayResultHash() {
|
|||||||
window.history.replaceState(null, '', nextUrl);
|
window.history.replaceState(null, '', nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestWechatMiniProgramPayment(
|
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('请在微信小程序内完成支付'));
|
||||||
|
}
|
||||||
|
if (window.wx?.miniProgram?.navigateTo) {
|
||||||
|
return Promise.resolve(window.wx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||||
|
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||||
|
`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,
|
payload: WechatMiniProgramPayParams | null | undefined,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
) {
|
): Promise<void> {
|
||||||
const miniProgram = window.wx?.miniProgram;
|
if (!payload) {
|
||||||
if (
|
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
||||||
!payload ||
|
}
|
||||||
!miniProgram ||
|
const wxBridge = await loadWechatJsSdk();
|
||||||
typeof miniProgram.navigateTo !== 'function'
|
const miniProgram = wxBridge.miniProgram;
|
||||||
) {
|
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
|
||||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
||||||
}
|
}
|
||||||
const navigateTo = miniProgram.navigateTo;
|
const navigateTo = miniProgram.navigateTo;
|
||||||
|
|
||||||
return new Promise<WechatMiniProgramPaymentStatus>((resolve) => {
|
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
||||||
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
return new Promise<void>((resolve, reject) => {
|
||||||
const handleHashChange = () => {
|
|
||||||
const params = new URLSearchParams(
|
|
||||||
window.location.hash.replace(/^#/, ''),
|
|
||||||
);
|
|
||||||
const result = params.get('wx_pay_result') ?? '';
|
|
||||||
const [resultRequestId, status] = result.split(':');
|
|
||||||
if (resultRequestId !== requestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.removeEventListener('hashchange', handleHashChange);
|
|
||||||
resolve(
|
|
||||||
status === 'success'
|
|
||||||
? 'success'
|
|
||||||
: status === 'cancel'
|
|
||||||
? 'cancel'
|
|
||||||
: 'fail',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('hashchange', handleHashChange);
|
|
||||||
navigateTo({
|
navigateTo({
|
||||||
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
|
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
|
||||||
|
success() {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
fail(error) {
|
fail(error) {
|
||||||
window.removeEventListener('hashchange', handleHashChange);
|
|
||||||
console.error('[wechat-pay] navigateTo failed', error);
|
console.error('[wechat-pay] navigateTo failed', error);
|
||||||
resolve('fail');
|
reject(
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('请在微信小程序内完成支付'),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitWechatPayConfirmDelay(delayMs: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, delayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmWechatRechargeOrderUntilSettled(
|
||||||
|
orderId: string,
|
||||||
|
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||||
|
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({
|
function RechargeProductCard({
|
||||||
product,
|
product,
|
||||||
submittingProductId,
|
submittingProductId,
|
||||||
@@ -2445,7 +2545,6 @@ function ProfileRechargeModal({
|
|||||||
center,
|
center,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
success,
|
|
||||||
submittingProductId,
|
submittingProductId,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -2456,7 +2555,6 @@ function ProfileRechargeModal({
|
|||||||
center: ProfileRechargeCenterResponse | null;
|
center: ProfileRechargeCenterResponse | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
success: string | null;
|
|
||||||
submittingProductId: string | null;
|
submittingProductId: string | null;
|
||||||
activeTab: RechargeTab;
|
activeTab: RechargeTab;
|
||||||
onTabChange: (tab: RechargeTab) => void;
|
onTabChange: (tab: RechargeTab) => void;
|
||||||
@@ -2526,11 +2624,6 @@ function ProfileRechargeModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{success ? (
|
|
||||||
<div className="platform-profile-success mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
@@ -2563,6 +2656,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 (
|
||||||
|
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="recharge-payment-result-title"
|
||||||
|
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
|
||||||
|
>
|
||||||
|
<div className="px-5 pb-5 pt-6 text-center">
|
||||||
|
<div
|
||||||
|
className={`mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 ${iconClass}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-8 w-8" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="recharge-payment-result-title"
|
||||||
|
className="mt-4 text-xl font-black text-[var(--platform-text-strong)]"
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
|
||||||
|
{result.message}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="platform-primary-button mt-5 w-full rounded-2xl px-4 py-3 text-sm font-black"
|
||||||
|
>
|
||||||
|
知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WalletLedgerModal({
|
function WalletLedgerModal({
|
||||||
ledger,
|
ledger,
|
||||||
fallbackBalance,
|
fallbackBalance,
|
||||||
@@ -3269,7 +3418,8 @@ export function RpgEntryHomeView({
|
|||||||
useState<ProfileRechargeCenterResponse | null>(null);
|
useState<ProfileRechargeCenterResponse | null>(null);
|
||||||
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
|
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
|
||||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||||
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
|
const [rechargePaymentResult, setRechargePaymentResult] =
|
||||||
|
useState<RechargePaymentResult | null>(null);
|
||||||
const [activeRechargeTab, setActiveRechargeTab] =
|
const [activeRechargeTab, setActiveRechargeTab] =
|
||||||
useState<RechargeTab>('points');
|
useState<RechargeTab>('points');
|
||||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||||
@@ -3335,6 +3485,7 @@ export function RpgEntryHomeView({
|
|||||||
useState<LegalDocumentId | null>(null);
|
useState<LegalDocumentId | null>(null);
|
||||||
const profileCopyResetTimerRef = useRef<number | null>(null);
|
const profileCopyResetTimerRef = useRef<number | null>(null);
|
||||||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
|
||||||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||||||
const [nicknameInput, setNicknameInput] = useState('');
|
const [nicknameInput, setNicknameInput] = useState('');
|
||||||
const [nicknameError, setNicknameError] = useState<string | null>(null);
|
const [nicknameError, setNicknameError] = useState<string | null>(null);
|
||||||
@@ -3790,6 +3941,87 @@ export function RpgEntryHomeView({
|
|||||||
})
|
})
|
||||||
.finally(() => setIsLoadingRechargeCenter(false));
|
.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') {
|
||||||
|
setRechargePaymentResult({
|
||||||
|
kind: 'pending',
|
||||||
|
title: '支付已提交',
|
||||||
|
message: '正在确认到账状态,请稍后查看余额或会员状态。',
|
||||||
|
});
|
||||||
|
if (payResult.orderId) {
|
||||||
|
void confirmWechatRechargeOrderUntilSettled(payResult.orderId)
|
||||||
|
.then((response) => {
|
||||||
|
const isPaid = response.order.status === 'paid';
|
||||||
|
setRechargeCenter(response.center);
|
||||||
|
setRechargePaymentResult(
|
||||||
|
isPaid
|
||||||
|
? {
|
||||||
|
kind: 'success',
|
||||||
|
title: '支付成功',
|
||||||
|
message: '已到账,账户状态已刷新。',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: 'pending',
|
||||||
|
title: '支付已提交',
|
||||||
|
message: '正在等待微信支付确认,请稍后查看账户状态。',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (isPaid) {
|
||||||
|
void onRechargeSuccess?.();
|
||||||
|
}
|
||||||
|
setSubmittingRechargeProductId(null);
|
||||||
|
pendingWechatRechargeOrderIdRef.current = null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setRechargePaymentResult({
|
||||||
|
kind: 'pending',
|
||||||
|
title: '支付已提交',
|
||||||
|
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
|
||||||
|
});
|
||||||
|
refreshRechargeState();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
refreshRechargeState();
|
||||||
|
}
|
||||||
|
} else if (payResult.status === 'cancel') {
|
||||||
|
setRechargePaymentResult({
|
||||||
|
kind: 'cancel',
|
||||||
|
title: '支付已取消',
|
||||||
|
message: '本次没有扣款,账户状态未发生变化。',
|
||||||
|
});
|
||||||
|
refreshRechargeState();
|
||||||
|
} else {
|
||||||
|
setRechargePaymentResult({
|
||||||
|
kind: 'failed',
|
||||||
|
title: '支付未完成',
|
||||||
|
message: '微信支付没有完成,本次不会入账。',
|
||||||
|
});
|
||||||
|
refreshRechargeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWechatPayResultHash();
|
||||||
|
}, [onRechargeSuccess, refreshRechargeState]);
|
||||||
const openRechargeModal = () => {
|
const openRechargeModal = () => {
|
||||||
if (!authUi?.user) {
|
if (!authUi?.user) {
|
||||||
authUi?.openLoginModal();
|
authUi?.openLoginModal();
|
||||||
@@ -3797,7 +4029,6 @@ export function RpgEntryHomeView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsRechargeOpen(true);
|
setIsRechargeOpen(true);
|
||||||
setRechargeSuccess(null);
|
|
||||||
loadRechargeCenter();
|
loadRechargeCenter();
|
||||||
};
|
};
|
||||||
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
|
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||||
@@ -3810,67 +4041,51 @@ export function RpgEntryHomeView({
|
|||||||
: 'mock';
|
: 'mock';
|
||||||
setSubmittingRechargeProductId(product.productId);
|
setSubmittingRechargeProductId(product.productId);
|
||||||
setRechargeError(null);
|
setRechargeError(null);
|
||||||
setRechargeSuccess(null);
|
|
||||||
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
|
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
|
||||||
const status = await requestWechatMiniProgramPayment(
|
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||||
|
await requestWechatMiniProgramPayment(
|
||||||
response.wechatMiniProgramPayParams,
|
response.wechatMiniProgramPayParams,
|
||||||
response.order.orderId,
|
response.order.orderId,
|
||||||
);
|
);
|
||||||
if (status === 'cancel') {
|
setRechargeCenter(response.center);
|
||||||
setRechargeCenter(response.center);
|
return;
|
||||||
setRechargeSuccess('支付已取消');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status !== 'success') {
|
|
||||||
throw new Error('微信支付未完成');
|
|
||||||
}
|
|
||||||
setRechargeSuccess('支付已提交');
|
|
||||||
loadRechargeCenter();
|
|
||||||
} else {
|
} else {
|
||||||
setRechargeCenter(response.center);
|
setRechargeCenter(response.center);
|
||||||
setRechargeSuccess('已到账');
|
setRechargePaymentResult({
|
||||||
|
kind: 'success',
|
||||||
|
title: '支付成功',
|
||||||
|
message: '已到账,账户状态已刷新。',
|
||||||
|
});
|
||||||
|
pendingWechatRechargeOrderIdRef.current = null;
|
||||||
|
setSubmittingRechargeProductId(null);
|
||||||
}
|
}
|
||||||
void onRechargeSuccess?.();
|
void onRechargeSuccess?.();
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
pendingWechatRechargeOrderIdRef.current = null;
|
||||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||||
})
|
setSubmittingRechargeProductId(null);
|
||||||
.finally(() => setSubmittingRechargeProductId(null));
|
});
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRechargeOpen) {
|
const handleResume = () => {
|
||||||
return undefined;
|
handleWechatPayResult();
|
||||||
}
|
|
||||||
|
|
||||||
const handleWechatPayResult = () => {
|
|
||||||
const result = new URLSearchParams(
|
|
||||||
window.location.hash.replace(/^#/, ''),
|
|
||||||
).get('wx_pay_result');
|
|
||||||
if (!result) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [, status] = result.split(':');
|
|
||||||
if (status === 'success') {
|
|
||||||
setRechargeSuccess('支付已提交');
|
|
||||||
loadRechargeCenter();
|
|
||||||
void onRechargeSuccess?.();
|
|
||||||
clearWechatPayResultHash();
|
|
||||||
} else if (status === 'cancel') {
|
|
||||||
setRechargeSuccess('支付已取消');
|
|
||||||
clearWechatPayResultHash();
|
|
||||||
} else {
|
|
||||||
setRechargeError('微信支付未完成');
|
|
||||||
clearWechatPayResultHash();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('hashchange', handleWechatPayResult);
|
window.addEventListener('hashchange', handleResume);
|
||||||
handleWechatPayResult();
|
window.addEventListener('focus', handleResume);
|
||||||
return () =>
|
window.addEventListener('pageshow', handleResume);
|
||||||
window.removeEventListener('hashchange', handleWechatPayResult);
|
document.addEventListener('visibilitychange', handleResume);
|
||||||
}, [isRechargeOpen, onRechargeSuccess]);
|
handleResume();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', handleResume);
|
||||||
|
window.removeEventListener('focus', handleResume);
|
||||||
|
window.removeEventListener('pageshow', handleResume);
|
||||||
|
document.removeEventListener('visibilitychange', handleResume);
|
||||||
|
};
|
||||||
|
}, [handleWechatPayResult]);
|
||||||
const loadTaskCenter = () => {
|
const loadTaskCenter = () => {
|
||||||
setTaskCenterError(null);
|
setTaskCenterError(null);
|
||||||
setIsLoadingTaskCenter(true);
|
setIsLoadingTaskCenter(true);
|
||||||
@@ -5662,7 +5877,6 @@ export function RpgEntryHomeView({
|
|||||||
center={rechargeCenter}
|
center={rechargeCenter}
|
||||||
isLoading={isLoadingRechargeCenter}
|
isLoading={isLoadingRechargeCenter}
|
||||||
error={rechargeError}
|
error={rechargeError}
|
||||||
success={rechargeSuccess}
|
|
||||||
submittingProductId={submittingRechargeProductId}
|
submittingProductId={submittingRechargeProductId}
|
||||||
activeTab={activeRechargeTab}
|
activeTab={activeRechargeTab}
|
||||||
onTabChange={setActiveRechargeTab}
|
onTabChange={setActiveRechargeTab}
|
||||||
@@ -5671,6 +5885,12 @@ export function RpgEntryHomeView({
|
|||||||
onBuy={buyRechargeProduct}
|
onBuy={buyRechargeProduct}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
|
||||||
|
<RechargePaymentResultModal
|
||||||
|
result={rechargePaymentResult}
|
||||||
|
onClose={() => setRechargePaymentResult(null)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (!isDesktopLayout) {
|
if (!isDesktopLayout) {
|
||||||
const isMobileRecommendTab = activeTab === 'home';
|
const isMobileRecommendTab = activeTab === 'home';
|
||||||
@@ -5754,6 +5974,7 @@ export function RpgEntryHomeView({
|
|||||||
) : null}
|
) : null}
|
||||||
{rewardCodeModal}
|
{rewardCodeModal}
|
||||||
{rechargeModal}
|
{rechargeModal}
|
||||||
|
{rechargePaymentResultModal}
|
||||||
{isTaskCenterOpen ? (
|
{isTaskCenterOpen ? (
|
||||||
<ProfileTaskCenterModal
|
<ProfileTaskCenterModal
|
||||||
center={taskCenter}
|
center={taskCenter}
|
||||||
@@ -5885,6 +6106,7 @@ export function RpgEntryHomeView({
|
|||||||
</div>
|
</div>
|
||||||
{rewardCodeModal}
|
{rewardCodeModal}
|
||||||
{rechargeModal}
|
{rechargeModal}
|
||||||
|
{rechargePaymentResultModal}
|
||||||
{isTaskCenterOpen ? (
|
{isTaskCenterOpen ? (
|
||||||
<ProfileTaskCenterModal
|
<ProfileTaskCenterModal
|
||||||
center={taskCenter}
|
center={taskCenter}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ConfirmWechatProfileRechargeOrderResponse,
|
||||||
CreateProfileRechargeOrderResponse,
|
CreateProfileRechargeOrderResponse,
|
||||||
ClaimProfileTaskRewardResponse,
|
ClaimProfileTaskRewardResponse,
|
||||||
PlatformBrowseHistoryBatchSyncRequest,
|
PlatformBrowseHistoryBatchSyncRequest,
|
||||||
@@ -105,6 +106,18 @@ export function createRpgProfileRechargeOrder(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmWechatRpgProfileRechargeOrder(
|
||||||
|
orderId: string,
|
||||||
|
options: RuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
|
return requestRpgRuntimeJson<ConfirmWechatProfileRechargeOrderResponse>(
|
||||||
|
`/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
'确认微信支付订单失败',
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function submitRpgProfileFeedback(
|
export function submitRpgProfileFeedback(
|
||||||
payload: SubmitProfileFeedbackRequest,
|
payload: SubmitProfileFeedbackRequest,
|
||||||
options: RuntimeRequestOptions = {},
|
options: RuntimeRequestOptions = {},
|
||||||
@@ -305,6 +318,7 @@ export const rpgProfileClient = {
|
|||||||
getWalletLedger: getRpgProfileWalletLedger,
|
getWalletLedger: getRpgProfileWalletLedger,
|
||||||
getRechargeCenter: getRpgProfileRechargeCenter,
|
getRechargeCenter: getRpgProfileRechargeCenter,
|
||||||
createRechargeOrder: createRpgProfileRechargeOrder,
|
createRechargeOrder: createRpgProfileRechargeOrder,
|
||||||
|
confirmWechatRechargeOrder: confirmWechatRpgProfileRechargeOrder,
|
||||||
submitFeedback: submitRpgProfileFeedback,
|
submitFeedback: submitRpgProfileFeedback,
|
||||||
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
|
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
|
||||||
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
|
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
|
||||||
|
|||||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -9,9 +9,11 @@ interface Window {
|
|||||||
miniProgram?: {
|
miniProgram?: {
|
||||||
navigateTo?: (options: {
|
navigateTo?: (options: {
|
||||||
url: string;
|
url: string;
|
||||||
|
success?: (result?: unknown) => void;
|
||||||
fail?: (error: { errMsg?: string }) => void;
|
fail?: (error: { errMsg?: string }) => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
postMessage?: (message: unknown) => void;
|
postMessage?: (message: unknown) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
WeixinJSBridge?: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user