feat: switch mini program recharge to virtual payment
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-26 微信小程序充值全面接入虚拟支付
|
||||||
|
|
||||||
|
- 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。
|
||||||
|
- 决策:小程序 WebView 内充值商品全部使用渠道 `wechat_mp_virtual` 并由 `miniprogram/pages/wechat-pay` 调用 `wx.requestVirtualPayment`;泥点使用 `short_series_coin`,会员使用 `short_series_goods`,会员 `signData` 必须带 `productId` 与 `goodsPrice`。后端保存微信小程序 `session_key`,仅用于生成 `signature`,不下发客户端。客户端 success 只作为支付页返回信号,最终到账仍由后端微信通知或查询确认后写订单。
|
||||||
|
- 影响范围:`src/services/payment/paymentPlatform.ts`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`miniprogram/pages/wechat-pay/`、`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/shared-contracts/src/runtime.rs`、`packages/shared/src/contracts/runtime.ts`、微信登录态存储。
|
||||||
|
- 验证方式:泥点和会员商品在小程序运行态都请求 `wechat_mp_virtual`;小程序页能按 payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment`;`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关前端测试通过。
|
||||||
|
- 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||||
|
|
||||||
## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态
|
## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态
|
||||||
|
|
||||||
- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。
|
- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
|
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
|
||||||
| 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` |
|
| 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` |
|
||||||
| 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` |
|
| 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` |
|
||||||
|
| 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` |
|
||||||
| UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` |
|
| UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` |
|
||||||
|
|
||||||
## 阅读顺序
|
## 阅读顺序
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。
|
从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。
|
||||||
|
|
||||||
|
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
||||||
|
|
||||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||||
|
|
||||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ npm run dev:api-server
|
|||||||
|
|
||||||
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
||||||
|
|
||||||
|
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。泥点充值在小程序 WebView 内走 `wechat_mp_virtual` / `wx.requestVirtualPayment`,会员仍走普通 `wechat_mp` / `wx.requestPayment`。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||||
|
|
||||||
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
|
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
|
||||||
|
|
||||||
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:
|
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:
|
||||||
|
|||||||
56
docs/【技术方案】微信虚拟支付接入-2026-05-26.md
Normal file
56
docs/【技术方案】微信虚拟支付接入-2026-05-26.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 微信虚拟支付接入
|
||||||
|
|
||||||
|
更新时间:`2026-05-26`
|
||||||
|
|
||||||
|
## 接入口径
|
||||||
|
|
||||||
|
- 泥点充值在微信小程序 WebView 内走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_coin` 模式。
|
||||||
|
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_goods` 模式,并在 `signData` 内带 `productId` 与 `goodsPrice`。
|
||||||
|
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
|
||||||
|
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
|
||||||
|
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端微信通知或查询确认后写入订单为准。
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
- 前端渠道选择:`src/services/payment/paymentPlatform.ts`
|
||||||
|
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||||
|
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
|
||||||
|
- API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs`
|
||||||
|
- 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs`
|
||||||
|
- 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs`
|
||||||
|
|
||||||
|
## 后端配置
|
||||||
|
|
||||||
|
生产接入虚拟支付至少需要:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WECHAT_PAY_ENABLED=true
|
||||||
|
WECHAT_PAY_PROVIDER=real
|
||||||
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID=<微信虚拟支付 offerId>
|
||||||
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey>
|
||||||
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选>
|
||||||
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
||||||
|
```
|
||||||
|
|
||||||
|
`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0` 表示现网,`1` 表示沙箱。后端会按 env 选择 AppKey,并生成:
|
||||||
|
|
||||||
|
- `signData`:传给 `wx.requestVirtualPayment` 的订单数据。
|
||||||
|
- `paySig`:`HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)` 的小写 hex。
|
||||||
|
- `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。
|
||||||
|
- 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。
|
||||||
|
|
||||||
|
## 验收命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm exec vitest run miniprogram/pages/wechat-pay/index.test.js src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
|
||||||
|
cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
||||||
|
cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml create_profile_recharge_order_response_serializes_virtual_wechat_payloads
|
||||||
|
npm run typecheck
|
||||||
|
npm run check:encoding
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 旧微信登录快照可能没有 `session_key`,用户需要在小程序内重新登录后再发起虚拟支付。
|
||||||
|
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。
|
||||||
|
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。
|
||||||
@@ -1,90 +1,3 @@
|
|||||||
function parsePayParams(rawValue) {
|
const { createWechatPayPage } = require('./index.shared');
|
||||||
try {
|
|
||||||
const params = JSON.parse(decodeURIComponent(String(rawValue || '')));
|
|
||||||
if (!params || typeof params !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[wechat-pay] parse params failed', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestPayment(payParams) {
|
Page(createWechatPayPage());
|
||||||
return new Promise((resolve) => {
|
|
||||||
wx.requestPayment({
|
|
||||||
timeStamp: String(payParams.timeStamp || ''),
|
|
||||||
nonceStr: String(payParams.nonceStr || ''),
|
|
||||||
package: String(payParams.package || ''),
|
|
||||||
signType: payParams.signType || 'RSA',
|
|
||||||
paySign: String(payParams.paySign || ''),
|
|
||||||
success() {
|
|
||||||
resolve('success');
|
|
||||||
},
|
|
||||||
fail(error) {
|
|
||||||
const errMsg = error && error.errMsg ? error.errMsg : '';
|
|
||||||
resolve(/cancel/i.test(errMsg) ? 'cancel' : 'fail');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
|
||||||
|
|
||||||
function appendPayResult(url, requestId, status) {
|
|
||||||
const value = `${requestId}:${status}`;
|
|
||||||
const hashIndex = String(url || '').indexOf('#');
|
|
||||||
const baseUrl =
|
|
||||||
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
|
||||||
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
|
||||||
const nextHash = rawHash
|
|
||||||
.split('&')
|
|
||||||
.filter((part) => part && !part.startsWith('wx_pay_result='))
|
|
||||||
.concat(`wx_pay_result=${encodeURIComponent(value)}`)
|
|
||||||
.join('&');
|
|
||||||
return `${baseUrl}#${nextHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyPreviousWebView(requestId, status) {
|
|
||||||
const result = `${requestId}:${status}`;
|
|
||||||
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
|
|
||||||
const pages = getCurrentPages();
|
|
||||||
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
|
|
||||||
if (previousPage && typeof previousPage.setData === 'function') {
|
|
||||||
previousPage.setData({
|
|
||||||
webViewUrl: appendPayResult(
|
|
||||||
previousPage.data.webViewUrl,
|
|
||||||
requestId,
|
|
||||||
status,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
title: '正在拉起支付',
|
|
||||||
errorMessage: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
async onLoad(query) {
|
|
||||||
const requestId = String(query.requestId || '');
|
|
||||||
const payParams = parsePayParams(query.payParams);
|
|
||||||
if (!requestId || !payParams) {
|
|
||||||
this.setData({
|
|
||||||
title: '支付失败',
|
|
||||||
errorMessage: '缺少支付参数。',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await requestPayment(payParams);
|
|
||||||
notifyPreviousWebView(requestId, status);
|
|
||||||
wx.navigateBack();
|
|
||||||
},
|
|
||||||
|
|
||||||
handleBack() {
|
|
||||||
wx.navigateBack();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
184
miniprogram/pages/wechat-pay/index.shared.js
Normal file
184
miniprogram/pages/wechat-pay/index.shared.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/* global wx, getCurrentPages */
|
||||||
|
|
||||||
|
function parsePayParams(rawValue) {
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(decodeURIComponent(String(rawValue || '')));
|
||||||
|
if (!params || typeof params !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[wechat-pay] parse params failed', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVirtualPaymentParams(payParams) {
|
||||||
|
return (
|
||||||
|
typeof payParams.mode === 'string' &&
|
||||||
|
typeof payParams.signData === 'string' &&
|
||||||
|
typeof payParams.paySig === 'string' &&
|
||||||
|
typeof payParams.signature === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCompareVersion(left, right) {
|
||||||
|
const leftParts = String(left || '')
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number(part) || 0);
|
||||||
|
const rightParts = String(right || '')
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number(part) || 0);
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length);
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const leftValue = leftParts[index] || 0;
|
||||||
|
const rightValue = rightParts[index] || 0;
|
||||||
|
if (leftValue > rightValue) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (leftValue < rightValue) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseVirtualPayment() {
|
||||||
|
if (typeof wx === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof wx.canIUse === 'function' && wx.canIUse('requestVirtualPayment')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version =
|
||||||
|
typeof wx.getSystemInfoSync === 'function'
|
||||||
|
? wx.getSystemInfoSync()?.SDKVersion || ''
|
||||||
|
: '';
|
||||||
|
return safeCompareVersion(version, '2.19.2') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePayStatus(error) {
|
||||||
|
const errMsg = error && error.errMsg ? error.errMsg : '';
|
||||||
|
const errCode = Number(error && error.errCode);
|
||||||
|
return errCode === -2 || /cancel/i.test(errMsg) ? 'cancel' : 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOrdinaryPayment(payParams) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
wx.requestPayment({
|
||||||
|
timeStamp: String(payParams.timeStamp || ''),
|
||||||
|
nonceStr: String(payParams.nonceStr || ''),
|
||||||
|
package: String(payParams.package || ''),
|
||||||
|
signType: payParams.signType || 'RSA',
|
||||||
|
paySign: String(payParams.paySign || ''),
|
||||||
|
success() {
|
||||||
|
resolve('success');
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
resolve(resolvePayStatus(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestVirtualPayment(payParams) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') {
|
||||||
|
resolve('fail');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wx.requestVirtualPayment({
|
||||||
|
mode: String(payParams.mode || ''),
|
||||||
|
signData: String(payParams.signData || ''),
|
||||||
|
paySig: String(payParams.paySig || ''),
|
||||||
|
signature: String(payParams.signature || ''),
|
||||||
|
success() {
|
||||||
|
resolve('success');
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
resolve(resolvePayStatus(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWechatPayment(payParams) {
|
||||||
|
if (isVirtualPaymentParams(payParams)) {
|
||||||
|
return requestVirtualPayment(payParams);
|
||||||
|
}
|
||||||
|
return requestOrdinaryPayment(payParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
||||||
|
|
||||||
|
function appendPayResult(url, requestId, status) {
|
||||||
|
const value = `${requestId}:${status}`;
|
||||||
|
const hashIndex = String(url || '').indexOf('#');
|
||||||
|
const baseUrl =
|
||||||
|
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
||||||
|
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
||||||
|
const nextHash = rawHash
|
||||||
|
.split('&')
|
||||||
|
.filter((part) => part && !part.startsWith('wx_pay_result='))
|
||||||
|
.concat(`wx_pay_result=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
return `${baseUrl}#${nextHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPreviousWebView(requestId, status) {
|
||||||
|
const result = `${requestId}:${status}`;
|
||||||
|
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
|
||||||
|
if (previousPage && typeof previousPage.setData === 'function') {
|
||||||
|
previousPage.setData({
|
||||||
|
webViewUrl: appendPayResult(
|
||||||
|
previousPage.data.webViewUrl,
|
||||||
|
requestId,
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWechatPayPage(pageContext) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
title: '正在拉起支付',
|
||||||
|
errorMessage: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLoad(query) {
|
||||||
|
const requestId = String(query.requestId || '');
|
||||||
|
const payParams = parsePayParams(query.payParams);
|
||||||
|
if (!requestId || !payParams) {
|
||||||
|
const page = pageContext ?? this;
|
||||||
|
page.setData({
|
||||||
|
title: '支付失败',
|
||||||
|
errorMessage: '缺少支付参数。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await requestWechatPayment(payParams);
|
||||||
|
notifyPreviousWebView(requestId, status);
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBack() {
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canUseVirtualPayment,
|
||||||
|
PAY_RESULT_STORAGE_KEY,
|
||||||
|
appendPayResult,
|
||||||
|
createWechatPayPage,
|
||||||
|
parsePayParams,
|
||||||
|
safeCompareVersion,
|
||||||
|
requestWechatPayment,
|
||||||
|
requestVirtualPayment,
|
||||||
|
};
|
||||||
159
miniprogram/pages/wechat-pay/index.test.js
Normal file
159
miniprogram/pages/wechat-pay/index.test.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import wechatPayBridge from './index.shared.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
appendPayResult,
|
||||||
|
createWechatPayPage,
|
||||||
|
parsePayParams,
|
||||||
|
requestWechatPayment,
|
||||||
|
} = wechatPayBridge;
|
||||||
|
|
||||||
|
describe('wechat-pay mini program payment bridge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.wx = {
|
||||||
|
requestPayment: vi.fn(),
|
||||||
|
requestVirtualPayment: vi.fn(),
|
||||||
|
setStorageSync: vi.fn(),
|
||||||
|
navigateBack: vi.fn(),
|
||||||
|
};
|
||||||
|
globalThis.getCurrentPages = vi.fn(() => []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routes virtual payloads to wx.requestVirtualPayment', async () => {
|
||||||
|
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
|
||||||
|
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
|
||||||
|
});
|
||||||
|
const payParams = {
|
||||||
|
mode: 'short_series_coin',
|
||||||
|
signData:
|
||||||
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-virtual-1","attach":"mud_points_60"}',
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await requestWechatPayment(payParams);
|
||||||
|
|
||||||
|
expect(status).toBe('success');
|
||||||
|
expect(globalThis.wx.requestVirtualPayment).toHaveBeenCalledWith({
|
||||||
|
mode: 'short_series_coin',
|
||||||
|
signData: payParams.signData,
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
success: expect.any(Function),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(globalThis.wx.requestPayment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routes goods virtual payloads to wx.requestVirtualPayment', async () => {
|
||||||
|
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
|
||||||
|
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
|
||||||
|
});
|
||||||
|
const payParams = {
|
||||||
|
mode: 'short_series_goods',
|
||||||
|
signData:
|
||||||
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","productId":"member_month","goodsPrice":2800,"outTradeNo":"order-goods-1","attach":"member_month"}',
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await requestWechatPayment(payParams);
|
||||||
|
|
||||||
|
expect(status).toBe('success');
|
||||||
|
expect(globalThis.wx.requestVirtualPayment).toHaveBeenCalledWith({
|
||||||
|
mode: 'short_series_goods',
|
||||||
|
signData: payParams.signData,
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
success: expect.any(Function),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(globalThis.wx.requestPayment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps ordinary requestPayment payloads on wx.requestPayment', async () => {
|
||||||
|
globalThis.wx.requestPayment.mockImplementationOnce((options) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await requestWechatPayment({
|
||||||
|
timeStamp: '1777110165',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=wx-prepay',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'signature',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe('success');
|
||||||
|
expect(globalThis.wx.requestPayment).toHaveBeenCalledWith({
|
||||||
|
timeStamp: '1777110165',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=wx-prepay',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'signature',
|
||||||
|
success: expect.any(Function),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(globalThis.wx.requestVirtualPayment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps virtual payment cancel errCode to cancel result', async () => {
|
||||||
|
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
|
||||||
|
options.fail?.({ errCode: -2, errMsg: 'requestVirtualPayment:fail cancel' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestWechatPayment({
|
||||||
|
mode: 'short_series_coin',
|
||||||
|
signData: '{}',
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
}),
|
||||||
|
).resolves.toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page notifies previous web-view after virtual payment', async () => {
|
||||||
|
const previousPage = {
|
||||||
|
data: { webViewUrl: 'https://web.test/#tab=profile' },
|
||||||
|
setData: vi.fn(),
|
||||||
|
};
|
||||||
|
globalThis.getCurrentPages = vi.fn(() => [{}, previousPage]);
|
||||||
|
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
|
||||||
|
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
|
||||||
|
});
|
||||||
|
const page = createWechatPayPage({
|
||||||
|
setData: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.onLoad({
|
||||||
|
requestId: 'request-1',
|
||||||
|
payParams: encodeURIComponent(
|
||||||
|
JSON.stringify({
|
||||||
|
mode: 'short_series_coin',
|
||||||
|
signData: '{}',
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||||
|
'genarrative:wechat-pay-result',
|
||||||
|
'request-1:success',
|
||||||
|
);
|
||||||
|
expect(previousPage.setData).toHaveBeenCalledWith({
|
||||||
|
webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess',
|
||||||
|
});
|
||||||
|
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePayParams and appendPayResult keep existing behavior', () => {
|
||||||
|
expect(parsePayParams(encodeURIComponent('{"paySign":"sig"}'))).toEqual({
|
||||||
|
paySign: 'sig',
|
||||||
|
});
|
||||||
|
expect(appendPayResult('https://web.test/#old=1', 'req', 'fail')).toBe(
|
||||||
|
'https://web.test/#old=1&wx_pay_result=req%3Afail',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,13 @@ export type WechatMiniProgramPayParams = {
|
|||||||
paySign: string;
|
paySign: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WechatMiniProgramVirtualPayParams = {
|
||||||
|
mode: 'short_series_coin' | 'short_series_goods';
|
||||||
|
signData: string;
|
||||||
|
paySig: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WechatH5Payment = {
|
export type WechatH5Payment = {
|
||||||
h5Url: string;
|
h5Url: string;
|
||||||
};
|
};
|
||||||
@@ -163,7 +170,10 @@ export type CreateProfileRechargeOrderRequest = {
|
|||||||
export type CreateProfileRechargeOrderResponse = {
|
export type CreateProfileRechargeOrderResponse = {
|
||||||
order: ProfileRechargeOrder;
|
order: ProfileRechargeOrder;
|
||||||
center: ProfileRechargeCenterResponse;
|
center: ProfileRechargeCenterResponse;
|
||||||
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
|
wechatMiniProgramPayParams?:
|
||||||
|
| WechatMiniProgramPayParams
|
||||||
|
| WechatMiniProgramVirtualPayParams
|
||||||
|
| null;
|
||||||
wechatH5Payment?: WechatH5Payment | null;
|
wechatH5Payment?: WechatH5Payment | null;
|
||||||
wechatNativePayment?: WechatNativePayment | null;
|
wechatNativePayment?: WechatNativePayment | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ platform-image = { workspace = true }
|
|||||||
platform-llm = { workspace = true }
|
platform-llm = { workspace = true }
|
||||||
platform-oss = { workspace = true }
|
platform-oss = { workspace = true }
|
||||||
platform-speech = { workspace = true }
|
platform-speech = { workspace = true }
|
||||||
|
hmac = { workspace = true }
|
||||||
ring = { workspace = true }
|
ring = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@@ -64,7 +65,6 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
hmac = { workspace = true }
|
|
||||||
http-body-util = { workspace = true }
|
http-body-util = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||||
tower = { workspace = true, features = ["util"] }
|
tower = { workspace = true, features = ["util"] }
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ pub struct AppConfig {
|
|||||||
pub wechat_pay_api_v3_key: Option<String>,
|
pub wechat_pay_api_v3_key: Option<String>,
|
||||||
pub wechat_pay_notify_url: Option<String>,
|
pub wechat_pay_notify_url: Option<String>,
|
||||||
pub wechat_pay_jsapi_endpoint: String,
|
pub wechat_pay_jsapi_endpoint: String,
|
||||||
|
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
|
||||||
|
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
|
||||||
|
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
|
||||||
|
pub wechat_mini_program_virtual_payment_env: u8,
|
||||||
pub oss_bucket: Option<String>,
|
pub oss_bucket: Option<String>,
|
||||||
pub oss_endpoint: Option<String>,
|
pub oss_endpoint: Option<String>,
|
||||||
pub oss_access_key_id: Option<String>,
|
pub oss_access_key_id: Option<String>,
|
||||||
@@ -240,6 +244,10 @@ impl Default for AppConfig {
|
|||||||
wechat_pay_notify_url: None,
|
wechat_pay_notify_url: None,
|
||||||
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
|
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
wechat_mini_program_virtual_payment_offer_id: None,
|
||||||
|
wechat_mini_program_virtual_payment_app_key: None,
|
||||||
|
wechat_mini_program_virtual_payment_sandbox_app_key: None,
|
||||||
|
wechat_mini_program_virtual_payment_env: 0,
|
||||||
oss_bucket: None,
|
oss_bucket: None,
|
||||||
oss_endpoint: None,
|
oss_endpoint: None,
|
||||||
oss_access_key_id: None,
|
oss_access_key_id: None,
|
||||||
@@ -590,6 +598,18 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
|
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
|
||||||
}
|
}
|
||||||
|
config.wechat_mini_program_virtual_payment_offer_id =
|
||||||
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"]);
|
||||||
|
config.wechat_mini_program_virtual_payment_app_key =
|
||||||
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
|
||||||
|
config.wechat_mini_program_virtual_payment_sandbox_app_key =
|
||||||
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
|
||||||
|
if let Some(env) =
|
||||||
|
read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
|
||||||
|
&& env <= 1
|
||||||
|
{
|
||||||
|
config.wechat_mini_program_virtual_payment_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
||||||
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
||||||
@@ -1379,6 +1399,10 @@ mod tests {
|
|||||||
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
||||||
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
||||||
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||||
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
||||||
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
||||||
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
|
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
|
||||||
@@ -1394,6 +1418,19 @@ mod tests {
|
|||||||
"WECHAT_PAY_NOTIFY_URL",
|
"WECHAT_PAY_NOTIFY_URL",
|
||||||
"https://api.example.com/api/profile/recharge/wechat/notify",
|
"https://api.example.com/api/profile/recharge/wechat/notify",
|
||||||
);
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID",
|
||||||
|
"offer-001",
|
||||||
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY",
|
||||||
|
"app-key-001",
|
||||||
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
|
||||||
|
"sandbox-app-key-001",
|
||||||
|
);
|
||||||
|
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = AppConfig::from_env();
|
let config = AppConfig::from_env();
|
||||||
@@ -1416,6 +1453,21 @@ mod tests {
|
|||||||
config.wechat_pay_platform_serial_no.as_deref(),
|
config.wechat_pay_platform_serial_no.as_deref(),
|
||||||
Some("platform-serial-001")
|
Some("platform-serial-001")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_mini_program_virtual_payment_offer_id.as_deref(),
|
||||||
|
Some("offer-001")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_mini_program_virtual_payment_app_key.as_deref(),
|
||||||
|
Some("app-key-001")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.wechat_mini_program_virtual_payment_sandbox_app_key
|
||||||
|
.as_deref(),
|
||||||
|
Some("sandbox-app-key-001")
|
||||||
|
);
|
||||||
|
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("WECHAT_PAY_ENABLED");
|
std::env::remove_var("WECHAT_PAY_ENABLED");
|
||||||
@@ -1427,6 +1479,10 @@ mod tests {
|
|||||||
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
||||||
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
||||||
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
|
||||||
|
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use module_runtime::{
|
|||||||
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
|
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
|
||||||
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
|
||||||
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||||
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||||
@@ -23,6 +24,8 @@ use module_runtime::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
use shared_contracts::runtime::{
|
use shared_contracts::runtime::{
|
||||||
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
|
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
|
||||||
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
|
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
|
||||||
@@ -59,7 +62,8 @@ use shared_contracts::runtime::{
|
|||||||
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
|
||||||
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
|
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
|
||||||
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
|
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
|
||||||
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
|
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
|
||||||
|
WechatMiniProgramVirtualPayParamsResponse,
|
||||||
};
|
};
|
||||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
@@ -78,6 +82,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
pub async fn get_profile_dashboard(
|
pub async fn get_profile_dashboard(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
@@ -231,7 +237,7 @@ pub async fn create_profile_recharge_order(
|
|||||||
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||||
Some(
|
Some(WechatMiniProgramPaymentParamsResponse::Ordinary(
|
||||||
state
|
state
|
||||||
.wechat_pay_client()
|
.wechat_pay_client()
|
||||||
.create_mini_program_order(build_wechat_payment_request(
|
.create_mini_program_order(build_wechat_payment_request(
|
||||||
@@ -244,6 +250,15 @@ pub async fn create_profile_recharge_order(
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
||||||
})?,
|
})?,
|
||||||
|
))
|
||||||
|
} else if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
|
||||||
|
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||||
|
Some(
|
||||||
|
build_wechat_virtual_pay_params(&state, &order, &openid)
|
||||||
|
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
|
||||||
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -1059,6 +1074,9 @@ fn validate_recharge_device_for_payment_channel(
|
|||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
|
||||||
claims.is_wechat_mini_program_device()
|
claims.is_wechat_mini_program_device()
|
||||||
}
|
}
|
||||||
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
|
||||||
|
claims.is_wechat_mini_program_device()
|
||||||
|
}
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -1106,6 +1124,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
|
|||||||
matches!(
|
matches!(
|
||||||
payment_channel,
|
payment_channel,
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
||||||
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||||
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
|
||||||
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
|
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
|
||||||
)
|
)
|
||||||
@@ -1148,6 +1167,127 @@ async fn resolve_wechat_identity_for_payment(
|
|||||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_wechat_virtual_pay_params(
|
||||||
|
state: &AppState,
|
||||||
|
order: &RuntimeProfileRechargeOrderRecord,
|
||||||
|
openid: &str,
|
||||||
|
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
|
||||||
|
let identity = state
|
||||||
|
.wechat_auth_service()
|
||||||
|
.get_identity_by_user_id(&order.user_id)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.with_message(format!("读取微信身份失败:{error}"))
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")
|
||||||
|
})?;
|
||||||
|
let session_key = identity.session_key.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("当前微信登录态缺少 session_key,请重新登录后再试")
|
||||||
|
})?;
|
||||||
|
let product = module_runtime::runtime_profile_recharge_product_by_id(&order.product_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值商品不存在")
|
||||||
|
})?;
|
||||||
|
let offer_id = required_wechat_virtual_payment_config(
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_virtual_payment_offer_id
|
||||||
|
.as_deref(),
|
||||||
|
"微信虚拟支付 OfferId 未配置",
|
||||||
|
)?;
|
||||||
|
let mode = match product.kind {
|
||||||
|
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
|
||||||
|
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
|
||||||
|
};
|
||||||
|
let mut sign_data = serde_json::json!({
|
||||||
|
"offerId": offer_id,
|
||||||
|
"buyQuantity": 1,
|
||||||
|
"env": state.config.wechat_mini_program_virtual_payment_env,
|
||||||
|
"currencyType": "CNY",
|
||||||
|
"outTradeNo": order.order_id,
|
||||||
|
"attach": serde_json::json!({
|
||||||
|
"userId": order.user_id,
|
||||||
|
"productId": order.product_id,
|
||||||
|
"paymentChannel": order.payment_channel,
|
||||||
|
"openId": openid,
|
||||||
|
}).to_string(),
|
||||||
|
});
|
||||||
|
if product.kind == RuntimeProfileRechargeProductKind::Membership {
|
||||||
|
sign_data["productId"] = json!(product.product_id);
|
||||||
|
sign_data["goodsPrice"] = json!(product.price_cents);
|
||||||
|
}
|
||||||
|
let sign_data = sign_data.to_string();
|
||||||
|
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
|
||||||
|
let signature = calc_wechat_virtual_payment_signature_with_key(&session_key, &sign_data)?;
|
||||||
|
|
||||||
|
Ok(WechatMiniProgramVirtualPayParamsResponse {
|
||||||
|
mode: mode.to_string(),
|
||||||
|
sign_data,
|
||||||
|
pay_sig,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calc_wechat_virtual_payment_signature(
|
||||||
|
state: &AppState,
|
||||||
|
sign_data: &str,
|
||||||
|
use_sandbox_key: bool,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let env = state.config.wechat_mini_program_virtual_payment_env;
|
||||||
|
let app_key = if use_sandbox_key || env == 1 {
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_virtual_payment_sandbox_app_key
|
||||||
|
.as_deref()
|
||||||
|
.or(state.config.wechat_mini_program_virtual_payment_app_key.as_deref())
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.wechat_mini_program_virtual_payment_app_key
|
||||||
|
.as_deref()
|
||||||
|
}
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("微信虚拟支付 AppKey 未配置")
|
||||||
|
})?;
|
||||||
|
calc_wechat_virtual_payment_signature_with_key(app_key, sign_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_wechat_virtual_payment_config<'a>(
|
||||||
|
value: Option<&'a str>,
|
||||||
|
message: &str,
|
||||||
|
) -> Result<&'a str, AppError> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calc_wechat_virtual_payment_signature_with_key(
|
||||||
|
key: &str,
|
||||||
|
sign_data: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|_| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.with_message("微信虚拟支付签名密钥初始化失败")
|
||||||
|
})?;
|
||||||
|
mac.update(format!("requestVirtualPayment&{sign_data}").as_bytes());
|
||||||
|
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_lower_hex(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut output = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &byte in bytes {
|
||||||
|
output.push(char::from(HEX[(byte >> 4) as usize]));
|
||||||
|
output.push(char::from(HEX[(byte & 0x0f) as usize]));
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
||||||
order
|
order
|
||||||
.success_time
|
.success_time
|
||||||
@@ -1619,9 +1759,17 @@ fn build_profile_redeem_code_admin_response(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use module_runtime::RuntimeProfileWalletLedgerSourceType;
|
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
|
||||||
|
use module_runtime::{
|
||||||
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
||||||
|
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
|
||||||
|
RuntimeProfileRechargeProductKind, RuntimeProfileWalletLedgerSourceType,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
|
use super::{
|
||||||
|
build_wechat_virtual_pay_params, format_profile_wallet_ledger_source_type,
|
||||||
|
normalize_admin_invite_code_metadata,
|
||||||
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -2082,6 +2230,70 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wechat_virtual_pay_params_use_goods_mode_for_membership_products() {
|
||||||
|
let state = seed_authenticated_state_with_config(AppConfig {
|
||||||
|
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
|
||||||
|
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
|
||||||
|
wechat_mini_program_virtual_payment_env: 0,
|
||||||
|
..fast_spacetime_timeout_config()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let wechat_login = state
|
||||||
|
.wechat_auth_service()
|
||||||
|
.resolve_login(ResolveWechatLoginInput {
|
||||||
|
profile: WechatIdentityProfile {
|
||||||
|
provider_uid: "openid-user-00000001".to_string(),
|
||||||
|
provider_union_id: Some("union-user-00000001".to_string()),
|
||||||
|
display_name: Some("资料页用户".to_string()),
|
||||||
|
avatar_url: None,
|
||||||
|
session_key: Some("session-key-1".to_string()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("wechat identity should seed");
|
||||||
|
let user_id = wechat_login.user.id;
|
||||||
|
let order = RuntimeProfileRechargeOrderRecord {
|
||||||
|
order_id: "memberorder01".to_string(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
product_id: "member_month".to_string(),
|
||||||
|
product_title: "月卡".to_string(),
|
||||||
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||||
|
amount_cents: 2800,
|
||||||
|
status: RuntimeProfileRechargeOrderStatus::Pending,
|
||||||
|
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
|
||||||
|
.to_string(),
|
||||||
|
paid_at: None,
|
||||||
|
paid_at_micros: None,
|
||||||
|
provider_transaction_id: None,
|
||||||
|
created_at: "2026-05-26T10:00:00Z".to_string(),
|
||||||
|
created_at_micros: 1_779_756_000_000_000,
|
||||||
|
points_delta: 0,
|
||||||
|
membership_expires_at: None,
|
||||||
|
membership_expires_at_micros: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-00000001")
|
||||||
|
.expect("membership virtual pay params should build");
|
||||||
|
let sign_data: Value =
|
||||||
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||||
|
let attach: Value = serde_json::from_str(
|
||||||
|
sign_data["attach"]
|
||||||
|
.as_str()
|
||||||
|
.expect("attach should be string json"),
|
||||||
|
)
|
||||||
|
.expect("attach should decode");
|
||||||
|
|
||||||
|
assert_eq!(params.mode, "short_series_goods");
|
||||||
|
assert_eq!(sign_data["offerId"], "offer-1");
|
||||||
|
assert_eq!(sign_data["productId"], "member_month");
|
||||||
|
assert_eq!(sign_data["goodsPrice"], 2800);
|
||||||
|
assert_eq!(sign_data["outTradeNo"], "memberorder01");
|
||||||
|
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
|
||||||
|
assert!(!params.pay_sig.is_empty());
|
||||||
|
assert!(!params.signature.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn profile_feedback_requires_authentication() {
|
async fn profile_feedback_requires_authentication() {
|
||||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ fn map_wechat_profile_to_domain(
|
|||||||
provider_union_id: profile.provider_union_id,
|
provider_union_id: profile.provider_union_id,
|
||||||
display_name: profile.display_name,
|
display_name: profile.display_name,
|
||||||
avatar_url: profile.avatar_url,
|
avatar_url: profile.avatar_url,
|
||||||
|
session_key: profile.session_key,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ pub struct WechatIdentityProfile {
|
|||||||
pub provider_union_id: Option<String>,
|
pub provider_union_id: Option<String>,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
|
pub session_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已绑定微信身份快照。
|
/// 已绑定微信身份快照。
|
||||||
@@ -124,6 +125,7 @@ pub struct WechatIdentityRecord {
|
|||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub provider_uid: String,
|
pub provider_uid: String,
|
||||||
pub provider_union_id: Option<String>,
|
pub provider_union_id: Option<String>,
|
||||||
|
pub session_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 微信授权 state 快照。
|
/// 微信授权 state 快照。
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ struct StoredWechatIdentity {
|
|||||||
provider_union_id: Option<String>,
|
provider_union_id: Option<String>,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
avatar_url: Option<String>,
|
avatar_url: Option<String>,
|
||||||
|
session_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -1292,6 +1293,7 @@ impl InMemoryAuthStore {
|
|||||||
provider_union_id: normalize_optional_string(profile.provider_union_id),
|
provider_union_id: normalize_optional_string(profile.provider_union_id),
|
||||||
display_name: normalize_optional_string(profile.display_name),
|
display_name: normalize_optional_string(profile.display_name),
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
session_key: normalize_optional_string(profile.session_key),
|
||||||
};
|
};
|
||||||
if let Some(provider_union_id) = identity.provider_union_id.clone() {
|
if let Some(provider_union_id) = identity.provider_union_id.clone() {
|
||||||
state
|
state
|
||||||
@@ -1361,6 +1363,7 @@ impl InMemoryAuthStore {
|
|||||||
user_id: identity.user_id.clone(),
|
user_id: identity.user_id.clone(),
|
||||||
provider_uid: identity.provider_uid.clone(),
|
provider_uid: identity.provider_uid.clone(),
|
||||||
provider_union_id: identity.provider_union_id.clone(),
|
provider_union_id: identity.provider_union_id.clone(),
|
||||||
|
session_key: identity.session_key.clone(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1377,6 +1380,7 @@ impl InMemoryAuthStore {
|
|||||||
let next_display_name = normalize_optional_string(profile.display_name);
|
let next_display_name = normalize_optional_string(profile.display_name);
|
||||||
let next_avatar_url = normalize_optional_string(profile.avatar_url);
|
let next_avatar_url = normalize_optional_string(profile.avatar_url);
|
||||||
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
|
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
|
||||||
|
let next_session_key = normalize_optional_string(profile.session_key);
|
||||||
let next_provider_uid =
|
let next_provider_uid =
|
||||||
normalize_required_string(&profile.provider_uid).unwrap_or_default();
|
normalize_required_string(&profile.provider_uid).unwrap_or_default();
|
||||||
{
|
{
|
||||||
@@ -1398,6 +1402,9 @@ impl InMemoryAuthStore {
|
|||||||
identity.display_name = next_display_name.clone();
|
identity.display_name = next_display_name.clone();
|
||||||
identity.avatar_url = next_avatar_url;
|
identity.avatar_url = next_avatar_url;
|
||||||
identity.provider_union_id = next_provider_union_id.clone();
|
identity.provider_union_id = next_provider_union_id.clone();
|
||||||
|
if next_session_key.is_some() {
|
||||||
|
identity.session_key = next_session_key.clone();
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.wechat_identity_by_provider_uid
|
.wechat_identity_by_provider_uid
|
||||||
.insert(next_provider_uid.clone(), identity);
|
.insert(next_provider_uid.clone(), identity);
|
||||||
@@ -3193,6 +3200,7 @@ mod tests {
|
|||||||
provider_union_id: Some("wx-union-shared".to_string()),
|
provider_union_id: Some("wx-union-shared".to_string()),
|
||||||
display_name: Some("微信旅人甲".to_string()),
|
display_name: Some("微信旅人甲".to_string()),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
session_key: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -3211,6 +3219,7 @@ mod tests {
|
|||||||
provider_union_id: Some("wx-union-shared".to_string()),
|
provider_union_id: Some("wx-union-shared".to_string()),
|
||||||
display_name: Some("微信旅人乙".to_string()),
|
display_name: Some("微信旅人乙".to_string()),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
session_key: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -3258,6 +3267,7 @@ mod tests {
|
|||||||
provider_union_id: Some("wx-union-bind".to_string()),
|
provider_union_id: Some("wx-union-bind".to_string()),
|
||||||
display_name: Some("待绑定微信用户".to_string()),
|
display_name: Some("待绑定微信用户".to_string()),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
session_key: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -3303,6 +3313,7 @@ mod tests {
|
|||||||
provider_union_id: Some("wx-union-bind".to_string()),
|
provider_union_id: Some("wx-union-bind".to_string()),
|
||||||
display_name: Some("已归并微信用户".to_string()),
|
display_name: Some("已归并微信用户".to_string()),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
session_key: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
|||||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
|
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
|
||||||
|
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL: &str = "wechat_mp_virtual";
|
||||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
|
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
|
||||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
|
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
|
||||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
|
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ pub struct WechatIdentityProfile {
|
|||||||
pub provider_union_id: Option<String>,
|
pub provider_union_id: Option<String>,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
|
pub session_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -359,6 +360,7 @@ struct WechatUserInfoResponse {
|
|||||||
struct WechatJsCodeSessionResponse {
|
struct WechatJsCodeSessionResponse {
|
||||||
openid: Option<String>,
|
openid: Option<String>,
|
||||||
unionid: Option<String>,
|
unionid: Option<String>,
|
||||||
|
session_key: Option<String>,
|
||||||
errcode: Option<i64>,
|
errcode: Option<i64>,
|
||||||
errmsg: Option<String>,
|
errmsg: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -834,6 +836,7 @@ impl MockWechatProvider {
|
|||||||
provider_union_id: self.mock_union_id.clone(),
|
provider_union_id: self.mock_union_id.clone(),
|
||||||
display_name: Some(self.mock_display_name.clone()),
|
display_name: Some(self.mock_display_name.clone()),
|
||||||
avatar_url: self.mock_avatar_url.clone(),
|
avatar_url: self.mock_avatar_url.clone(),
|
||||||
|
session_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -975,6 +978,7 @@ impl RealWechatProvider {
|
|||||||
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
|
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
|
||||||
display_name: user_info_payload.nickname,
|
display_name: user_info_payload.nickname,
|
||||||
avatar_url: user_info_payload.headimgurl,
|
avatar_url: user_info_payload.headimgurl,
|
||||||
|
session_key: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,6 +1057,7 @@ impl RealWechatProvider {
|
|||||||
provider_union_id: payload.unionid,
|
provider_union_id: payload.unionid,
|
||||||
display_name: None,
|
display_name: None,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
session_key: payload.session_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,22 @@ pub struct WechatMiniProgramPayParamsResponse {
|
|||||||
pub pay_sign: String,
|
pub pay_sign: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WechatMiniProgramVirtualPayParamsResponse {
|
||||||
|
pub mode: String,
|
||||||
|
pub sign_data: String,
|
||||||
|
pub pay_sig: String,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum WechatMiniProgramPaymentParamsResponse {
|
||||||
|
Ordinary(WechatMiniProgramPayParamsResponse),
|
||||||
|
Virtual(WechatMiniProgramVirtualPayParamsResponse),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct WechatH5PaymentResponse {
|
pub struct WechatH5PaymentResponse {
|
||||||
@@ -283,7 +299,7 @@ pub struct CreateProfileRechargeOrderResponse {
|
|||||||
pub order: ProfileRechargeOrderResponse,
|
pub order: ProfileRechargeOrderResponse,
|
||||||
pub center: ProfileRechargeCenterResponse,
|
pub center: ProfileRechargeCenterResponse,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPaymentParamsResponse>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
|
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1451,6 +1467,67 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_profile_recharge_order_response_serializes_virtual_wechat_payloads() {
|
||||||
|
let order = ProfileRechargeOrderResponse {
|
||||||
|
order_id: "rcgtest002".to_string(),
|
||||||
|
product_id: "member_month".to_string(),
|
||||||
|
product_title: "月卡".to_string(),
|
||||||
|
kind: "membership".to_string(),
|
||||||
|
amount_cents: 2800,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
payment_channel: "wechat_mp_virtual".to_string(),
|
||||||
|
paid_at: None,
|
||||||
|
provider_transaction_id: None,
|
||||||
|
created_at: "2026-05-15T10:00:00Z".to_string(),
|
||||||
|
points_delta: 0,
|
||||||
|
membership_expires_at: Some("2026-06-15T10:00:00Z".to_string()),
|
||||||
|
};
|
||||||
|
let center = ProfileRechargeCenterResponse {
|
||||||
|
wallet_balance: 0,
|
||||||
|
membership: ProfileMembershipResponse {
|
||||||
|
status: "normal".to_string(),
|
||||||
|
tier: "normal".to_string(),
|
||||||
|
started_at: None,
|
||||||
|
expires_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
},
|
||||||
|
point_products: vec![],
|
||||||
|
membership_products: vec![],
|
||||||
|
benefits: vec![],
|
||||||
|
latest_order: None,
|
||||||
|
has_points_recharged: false,
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_value(CreateProfileRechargeOrderResponse {
|
||||||
|
order,
|
||||||
|
center,
|
||||||
|
wechat_mini_program_pay_params: Some(WechatMiniProgramPaymentParamsResponse::Virtual(
|
||||||
|
WechatMiniProgramVirtualPayParamsResponse {
|
||||||
|
mode: "short_series_goods".to_string(),
|
||||||
|
sign_data:
|
||||||
|
"{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}"
|
||||||
|
.to_string(),
|
||||||
|
pay_sig: "pay-sig".to_string(),
|
||||||
|
signature: "user-sig".to_string(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
wechat_h5_payment: None,
|
||||||
|
wechat_native_payment: None,
|
||||||
|
})
|
||||||
|
.expect("payload should serialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
payload["wechatMiniProgramPayParams"]["mode"],
|
||||||
|
json!("short_series_goods")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["wechatMiniProgramPayParams"]["signData"],
|
||||||
|
json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}")
|
||||||
|
);
|
||||||
|
assert_eq!(payload["wechatMiniProgramPayParams"]["paySig"], json!("pay-sig"));
|
||||||
|
assert_eq!(payload["wechatMiniProgramPayParams"]["signature"], json!("user-sig"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_feedback_response_uses_camel_case_fields() {
|
fn profile_feedback_response_uses_camel_case_fields() {
|
||||||
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
|
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ const {
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: '2026-04-25T10:01:00Z',
|
paidAt: '2026-04-25T10:01:00Z',
|
||||||
providerTransactionId: 'wx-transaction-1',
|
providerTransactionId: 'wx-transaction-1',
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -275,7 +275,7 @@ const {
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
providerTransactionId: 'wx-transaction-1',
|
providerTransactionId: 'wx-transaction-1',
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
paidAt: '2026-04-25T10:01:00Z',
|
paidAt: '2026-04-25T10:01:00Z',
|
||||||
@@ -1319,7 +1319,7 @@ test('profile recharge modal trusts per-product first bonus display after points
|
|||||||
expect(screen.getByText('60+60泥点')).toBeTruthy();
|
expect(screen.getByText('60+60泥点')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
test('profile recharge modal posts virtual payment params in mini program web-view', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onRechargeSuccess = vi.fn();
|
const onRechargeSuccess = vi.fn();
|
||||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
@@ -1339,7 +1339,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: null as string | null,
|
paidAt: null as string | null,
|
||||||
providerTransactionId: null,
|
providerTransactionId: null,
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -1362,11 +1362,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
hasPointsRecharged: false,
|
hasPointsRecharged: false,
|
||||||
},
|
},
|
||||||
wechatMiniProgramPayParams: {
|
wechatMiniProgramPayParams: {
|
||||||
timeStamp: '1777110165',
|
mode: 'short_series_coin',
|
||||||
nonceStr: 'nonce',
|
signData:
|
||||||
package: 'prepay_id=wx-prepay',
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-1","attach":"mud_points_60"}',
|
||||||
signType: 'RSA',
|
paySig: 'pay-sig',
|
||||||
paySign: 'signature',
|
signature: 'user-sig',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1377,7 +1377,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||||
'points_60',
|
'points_60',
|
||||||
'wechat_mp',
|
'wechat_mp_virtual',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(navigateTo).toHaveBeenCalledWith({
|
expect(navigateTo).toHaveBeenCalledWith({
|
||||||
@@ -1395,7 +1395,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
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('short_series_coin');
|
||||||
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
|
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
|
||||||
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
|
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
|
||||||
'points_60',
|
'points_60',
|
||||||
@@ -1409,6 +1409,82 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
|||||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('profile recharge modal posts membership goods virtual payment params in mini program web-view', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
|
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-member-virtual-1',
|
||||||
|
productId: 'member_month',
|
||||||
|
productTitle: '月卡',
|
||||||
|
kind: 'membership',
|
||||||
|
amountCents: 2800,
|
||||||
|
status: 'pending' as const,
|
||||||
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
|
paidAt: null as string | null,
|
||||||
|
providerTransactionId: null,
|
||||||
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
|
pointsDelta: 0,
|
||||||
|
membershipExpiresAt: '2026-06-25T10:00:00Z',
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
},
|
||||||
|
wechatMiniProgramPayParams: {
|
||||||
|
mode: 'short_series_goods',
|
||||||
|
signData:
|
||||||
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","productId":"member_month","goodsPrice":2800,"outTradeNo":"order-member-virtual-1","attach":"member_month"}',
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProfileView();
|
||||||
|
await openRechargeModal(user);
|
||||||
|
await user.click(screen.getByRole('button', { name: '会员卡' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /月卡/u }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||||
|
'member_month',
|
||||||
|
'wechat_mp_virtual',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||||
|
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
|
||||||
|
'requestId',
|
||||||
|
);
|
||||||
|
expect(requestId).toBeTruthy();
|
||||||
|
const payParams = JSON.parse(
|
||||||
|
new URL(`https://mini.test${navigateUrl}`).searchParams.get('payParams') ?? '{}',
|
||||||
|
);
|
||||||
|
const signData = JSON.parse(payParams.signData);
|
||||||
|
expect(payParams.mode).toBe('short_series_goods');
|
||||||
|
expect(signData.productId).toBe('member_month');
|
||||||
|
expect(signData.goodsPrice).toBe(2800);
|
||||||
|
expect(decodeURIComponent(navigateUrl)).toContain('"paySig":"pay-sig"');
|
||||||
|
});
|
||||||
|
|
||||||
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
|
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onRechargeSuccess = vi.fn();
|
const onRechargeSuccess = vi.fn();
|
||||||
@@ -1429,7 +1505,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: null as string | null,
|
paidAt: null as string | null,
|
||||||
providerTransactionId: null,
|
providerTransactionId: null,
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -1452,11 +1528,11 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
|
|||||||
hasPointsRecharged: false,
|
hasPointsRecharged: false,
|
||||||
},
|
},
|
||||||
wechatMiniProgramPayParams: {
|
wechatMiniProgramPayParams: {
|
||||||
timeStamp: '1777110165',
|
mode: 'short_series_coin',
|
||||||
nonceStr: 'nonce',
|
signData:
|
||||||
package: 'prepay_id=wx-prepay',
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-pending-then-paid","attach":"mud_points_60"}',
|
||||||
signType: 'RSA',
|
paySig: 'pay-sig',
|
||||||
paySign: 'signature',
|
signature: 'user-sig',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mockConfirmWechatRpgProfileRechargeOrder
|
mockConfirmWechatRpgProfileRechargeOrder
|
||||||
@@ -1468,7 +1544,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: null,
|
paidAt: null,
|
||||||
providerTransactionId: null,
|
providerTransactionId: null,
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -1499,7 +1575,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'paid' as const,
|
status: 'paid' as const,
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: '2026-04-25T10:01:00Z',
|
paidAt: '2026-04-25T10:01:00Z',
|
||||||
providerTransactionId: 'wx-transaction-2',
|
providerTransactionId: 'wx-transaction-2',
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -1563,7 +1639,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: null as string | null,
|
paidAt: null as string | null,
|
||||||
providerTransactionId: null,
|
providerTransactionId: null,
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -1586,11 +1662,11 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
|
|||||||
hasPointsRecharged: false,
|
hasPointsRecharged: false,
|
||||||
},
|
},
|
||||||
wechatMiniProgramPayParams: {
|
wechatMiniProgramPayParams: {
|
||||||
timeStamp: '1777110165',
|
mode: 'short_series_coin',
|
||||||
nonceStr: 'nonce',
|
signData:
|
||||||
package: 'prepay_id=wx-prepay',
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-sdk-1","attach":"mud_points_60"}',
|
||||||
signType: 'RSA',
|
paySig: 'pay-sig',
|
||||||
paySign: 'signature',
|
signature: 'user-sig',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1649,7 +1725,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
|
|||||||
kind: 'points',
|
kind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
paymentChannel: 'wechat_mp',
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
paidAt: null as string | null,
|
paidAt: null as string | null,
|
||||||
providerTransactionId: null,
|
providerTransactionId: null,
|
||||||
createdAt: '2026-04-25T10:00:00Z',
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
@@ -1672,11 +1748,11 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
|
|||||||
hasPointsRecharged: false,
|
hasPointsRecharged: false,
|
||||||
},
|
},
|
||||||
wechatMiniProgramPayParams: {
|
wechatMiniProgramPayParams: {
|
||||||
timeStamp: '1777110165',
|
mode: 'short_series_coin',
|
||||||
nonceStr: 'nonce',
|
signData:
|
||||||
package: 'prepay_id=wx-prepay-cancel',
|
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-cancel-1","attach":"mud_points_60"}',
|
||||||
signType: 'RSA',
|
paySig: 'pay-sig',
|
||||||
paySign: 'signature',
|
signature: 'user-sig',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1688,7 +1764,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||||
'points_60',
|
'points_60',
|
||||||
'wechat_mp',
|
'wechat_mp_virtual',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import type {
|
|||||||
ProfileWalletLedgerResponse,
|
ProfileWalletLedgerResponse,
|
||||||
RedeemProfileRewardCodeResponse,
|
RedeemProfileRewardCodeResponse,
|
||||||
WechatMiniProgramPayParams,
|
WechatMiniProgramPayParams,
|
||||||
|
WechatMiniProgramVirtualPayParams,
|
||||||
WechatNativePayment,
|
WechatNativePayment,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
|
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
|
||||||
@@ -89,10 +90,10 @@ import {
|
|||||||
} from '../../services/authService';
|
} from '../../services/authService';
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import {
|
import {
|
||||||
resolveProfileRechargePaymentChannel,
|
resolveProfileRechargeProductPaymentChannel,
|
||||||
shouldShowRechargeEntry,
|
shouldShowRechargeEntry,
|
||||||
WECHAT_H5_PAYMENT_CHANNEL,
|
WECHAT_H5_PAYMENT_CHANNEL,
|
||||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
|
||||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||||
} from '../../services/payment/paymentPlatform';
|
} from '../../services/payment/paymentPlatform';
|
||||||
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
|
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
|
||||||
@@ -2740,7 +2741,11 @@ function loadWechatJsSdk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestWechatMiniProgramPayment(
|
async function requestWechatMiniProgramPayment(
|
||||||
payload: WechatMiniProgramPayParams | null | undefined,
|
payload:
|
||||||
|
| WechatMiniProgramPayParams
|
||||||
|
| WechatMiniProgramVirtualPayParams
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -4664,14 +4669,17 @@ export function RpgEntryHomeView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentChannel = resolveProfileRechargePaymentChannel();
|
const paymentChannel = resolveProfileRechargeProductPaymentChannel(
|
||||||
|
{ kind: product.kind },
|
||||||
|
{},
|
||||||
|
);
|
||||||
setSubmittingRechargeProductId(product.productId);
|
setSubmittingRechargeProductId(product.productId);
|
||||||
setRechargeError(null);
|
setRechargeError(null);
|
||||||
setRechargePaymentResult(null);
|
setRechargePaymentResult(null);
|
||||||
setNativeWechatPayment(null);
|
setNativeWechatPayment(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_VIRTUAL_PAYMENT_CHANNEL) {
|
||||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||||
await requestWechatMiniProgramPayment(
|
await requestWechatMiniProgramPayment(
|
||||||
response.wechatMiniProgramPayParams,
|
response.wechatMiniProgramPayParams,
|
||||||
|
|||||||
@@ -2,20 +2,45 @@ import { describe, expect, test } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
resolveProfileRechargePaymentChannel,
|
resolveProfileRechargePaymentChannel,
|
||||||
|
resolveProfileRechargeProductPaymentChannel,
|
||||||
shouldShowRechargeEntry,
|
shouldShowRechargeEntry,
|
||||||
WECHAT_H5_PAYMENT_CHANNEL,
|
WECHAT_H5_PAYMENT_CHANNEL,
|
||||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
|
||||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||||
} from './paymentPlatform';
|
} from './paymentPlatform';
|
||||||
|
|
||||||
describe('resolveProfileRechargePaymentChannel', () => {
|
describe('resolveProfileRechargePaymentChannel', () => {
|
||||||
test('小程序运行态选择 wechat_mp', () => {
|
test('小程序运行态基础通道选择 wechat_mp_virtual', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveProfileRechargePaymentChannel({
|
resolveProfileRechargePaymentChannel({
|
||||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||||
}),
|
}),
|
||||||
).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL);
|
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点数商品在小程序运行态选择 wechat_mp_virtual', () => {
|
||||||
|
expect(
|
||||||
|
resolveProfileRechargeProductPaymentChannel(
|
||||||
|
{ kind: 'points' },
|
||||||
|
{
|
||||||
|
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||||
|
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('会员商品在小程序运行态也选择 wechat_mp_virtual', () => {
|
||||||
|
expect(
|
||||||
|
resolveProfileRechargeProductPaymentChannel(
|
||||||
|
{ kind: 'membership' },
|
||||||
|
{
|
||||||
|
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||||
|
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('移动网页选择 wechat_h5', () => {
|
test('移动网页选择 wechat_h5', () => {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
||||||
|
export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual';
|
||||||
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
|
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
|
||||||
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
|
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
|
||||||
export const MOCK_PAYMENT_CHANNEL = 'mock';
|
export const MOCK_PAYMENT_CHANNEL = 'mock';
|
||||||
|
|
||||||
export type ProfileRechargeWechatPaymentChannel =
|
export type ProfileRechargeWechatPaymentChannel =
|
||||||
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
||||||
|
| typeof WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL
|
||||||
| typeof WECHAT_H5_PAYMENT_CHANNEL
|
| typeof WECHAT_H5_PAYMENT_CHANNEL
|
||||||
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
|
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||||
|
|
||||||
|
export type ProfileRechargeProductPaymentMode = {
|
||||||
|
kind: 'points' | 'membership';
|
||||||
|
};
|
||||||
|
|
||||||
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
|
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
|
||||||
|
|
||||||
export type PaymentPlatformContext = {
|
export type PaymentPlatformContext = {
|
||||||
@@ -45,7 +51,7 @@ export function resolveProfileRechargePaymentChannel(
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
if (isWechatMiniProgramRuntime(location)) {
|
if (isWechatMiniProgramRuntime(location)) {
|
||||||
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
|
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
|
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
|
||||||
@@ -55,6 +61,13 @@ export function resolveProfileRechargePaymentChannel(
|
|||||||
return WECHAT_NATIVE_PAYMENT_CHANNEL;
|
return WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveProfileRechargeProductPaymentChannel(
|
||||||
|
_product: ProfileRechargeProductPaymentMode,
|
||||||
|
context: PaymentPlatformContext = {},
|
||||||
|
): ProfileRechargeWechatPaymentChannel {
|
||||||
|
return resolveProfileRechargePaymentChannel(context);
|
||||||
|
}
|
||||||
|
|
||||||
export function isManualMockPaymentChannel(paymentChannel: string) {
|
export function isManualMockPaymentChannel(paymentChannel: string) {
|
||||||
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
|
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user