diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 840846b4..68968483 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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 使用静态资源与本地运行态 - 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。 diff --git a/.hermes/shared-memory/document-map.md b/.hermes/shared-memory/document-map.md index 512c5949..8cd29c75 100644 --- a/.hermes/shared-memory/document-map.md +++ b/.hermes/shared-memory/document-map.md @@ -12,6 +12,7 @@ | 后端、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-26.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | ## 阅读顺序 diff --git a/docs/README.md b/docs/README.md index 39af032a..47f6ed8b 100644 --- a/docs/README.md +++ b/docs/README.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)。 +微信小程序虚拟支付接入、`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)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 62a24ea8..baa5cdd3 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.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` 配置默认关闭该开关。 +微信小程序虚拟支付使用 `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` 指向可发布的本地库。 本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如: diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md new file mode 100644 index 00000000..afe1ff24 --- /dev/null +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -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`。 diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js index 332849ca..ad188c92 100644 --- a/miniprogram/pages/wechat-pay/index.js +++ b/miniprogram/pages/wechat-pay/index.js @@ -1,90 +1,3 @@ -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; - } -} +const { createWechatPayPage } = require('./index.shared'); -function requestPayment(payParams) { - return new Promise((resolve) => { - wx.requestPayment({ - timeStamp: String(payParams.timeStamp || ''), - nonceStr: String(payParams.nonceStr || ''), - package: String(payParams.package || ''), - signType: payParams.signType || 'RSA', - paySign: String(payParams.paySign || ''), - success() { - resolve('success'); - }, - fail(error) { - const errMsg = error && error.errMsg ? error.errMsg : ''; - resolve(/cancel/i.test(errMsg) ? 'cancel' : 'fail'); - }, - }); - }); -} - -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(); - }, -}); +Page(createWechatPayPage()); diff --git a/miniprogram/pages/wechat-pay/index.shared.js b/miniprogram/pages/wechat-pay/index.shared.js new file mode 100644 index 00000000..cdf73eb3 --- /dev/null +++ b/miniprogram/pages/wechat-pay/index.shared.js @@ -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, +}; diff --git a/miniprogram/pages/wechat-pay/index.test.js b/miniprogram/pages/wechat-pay/index.test.js new file mode 100644 index 00000000..f4096260 --- /dev/null +++ b/miniprogram/pages/wechat-pay/index.test.js @@ -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', + ); + }); +}); diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 784728f0..8158b592 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -147,6 +147,13 @@ export type WechatMiniProgramPayParams = { paySign: string; }; +export type WechatMiniProgramVirtualPayParams = { + mode: 'short_series_coin' | 'short_series_goods'; + signData: string; + paySig: string; + signature: string; +}; + export type WechatH5Payment = { h5Url: string; }; @@ -163,7 +170,10 @@ export type CreateProfileRechargeOrderRequest = { export type CreateProfileRechargeOrderResponse = { order: ProfileRechargeOrder; center: ProfileRechargeCenterResponse; - wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null; + wechatMiniProgramPayParams?: + | WechatMiniProgramPayParams + | WechatMiniProgramVirtualPayParams + | null; wechatH5Payment?: WechatH5Payment | null; wechatNativePayment?: WechatNativePayment | null; }; diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 2844c4da..052a8277 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -38,6 +38,7 @@ platform-image = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } +hmac = { workspace = true } ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -64,7 +65,6 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ [dev-dependencies] base64 = { workspace = true } -hmac = { workspace = true } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } tower = { workspace = true, features = ["util"] } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 079f7b5a..433a67bd 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -96,6 +96,10 @@ pub struct AppConfig { pub wechat_pay_api_v3_key: Option, pub wechat_pay_notify_url: Option, pub wechat_pay_jsapi_endpoint: String, + pub wechat_mini_program_virtual_payment_offer_id: Option, + pub wechat_mini_program_virtual_payment_app_key: Option, + pub wechat_mini_program_virtual_payment_sandbox_app_key: Option, + pub wechat_mini_program_virtual_payment_env: u8, pub oss_bucket: Option, pub oss_endpoint: Option, pub oss_access_key_id: Option, @@ -240,6 +244,10 @@ impl Default for AppConfig { wechat_pay_notify_url: None, wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi" .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_endpoint: None, oss_access_key_id: None, @@ -590,6 +598,18 @@ impl AppConfig { { 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_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_API_V3_KEY"); 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_PROVIDER", "real"); std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109"); @@ -1394,6 +1418,19 @@ mod tests { "WECHAT_PAY_NOTIFY_URL", "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(); @@ -1416,6 +1453,21 @@ mod tests { config.wechat_pay_platform_serial_no.as_deref(), 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 { 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_API_V3_KEY"); 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"); } } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 345c2b89..d604bc18 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -8,6 +8,7 @@ use module_runtime::{ AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, @@ -23,6 +24,8 @@ use module_runtime::{ }; use serde::Deserialize; use serde_json::{Value, json}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; use shared_contracts::runtime::{ ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, @@ -59,7 +62,8 @@ use shared_contracts::runtime::{ RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, 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 spacetime_client::SpacetimeClientError; @@ -78,6 +82,8 @@ use crate::{ }, }; +type HmacSha256 = Hmac; + pub async fn get_profile_dashboard( State(state): State, Extension(request_context): Extension, @@ -231,7 +237,7 @@ pub async fn create_profile_recharge_order( let identity = resolve_wechat_identity_for_payment(&state, &order.user_id) .await .map_err(|error| runtime_profile_error_response(&request_context, error))?; - Some( + Some(WechatMiniProgramPaymentParamsResponse::Ordinary( state .wechat_pay_client() .create_mini_program_order(build_wechat_payment_request( @@ -244,6 +250,15 @@ pub async fn create_profile_recharge_order( .map_err(|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 { None @@ -1059,6 +1074,9 @@ fn validate_recharge_device_for_payment_channel( PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => { 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_NATIVE => claims.is_desktop_wechat_browser_device(), _ => false, @@ -1106,6 +1124,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool { matches!( payment_channel, 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_NATIVE ) @@ -1148,6 +1167,127 @@ async fn resolve_wechat_identity_for_payment( .with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")) } +fn build_wechat_virtual_pay_params( + state: &AppState, + order: &RuntimeProfileRechargeOrderRecord, + openid: &str, +) -> Result { + 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 { + 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 { + 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 { order .success_time @@ -1619,9 +1759,17 @@ fn build_profile_redeem_code_admin_response( #[cfg(test)] 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::{ 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] async fn profile_feedback_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index d7381253..d7dfc858 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -385,6 +385,7 @@ fn map_wechat_profile_to_domain( provider_union_id: profile.provider_union_id, display_name: profile.display_name, avatar_url: profile.avatar_url, + session_key: profile.session_key, } } diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index d8057f81..a7999183 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -116,6 +116,7 @@ pub struct WechatIdentityProfile { pub provider_union_id: Option, pub display_name: Option, pub avatar_url: Option, + pub session_key: Option, } /// 已绑定微信身份快照。 @@ -124,6 +125,7 @@ pub struct WechatIdentityRecord { pub user_id: String, pub provider_uid: String, pub provider_union_id: Option, + pub session_key: Option, } /// 微信授权 state 快照。 diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 3d628ec1..0bbc7f00 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -97,6 +97,7 @@ struct StoredWechatIdentity { provider_union_id: Option, display_name: Option, avatar_url: Option, + session_key: Option, } #[derive(Clone, Debug)] @@ -1292,6 +1293,7 @@ impl InMemoryAuthStore { provider_union_id: normalize_optional_string(profile.provider_union_id), display_name: normalize_optional_string(profile.display_name), avatar_url, + session_key: normalize_optional_string(profile.session_key), }; if let Some(provider_union_id) = identity.provider_union_id.clone() { state @@ -1361,6 +1363,7 @@ impl InMemoryAuthStore { user_id: identity.user_id.clone(), provider_uid: identity.provider_uid.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_avatar_url = normalize_optional_string(profile.avatar_url); 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 = normalize_required_string(&profile.provider_uid).unwrap_or_default(); { @@ -1398,6 +1402,9 @@ impl InMemoryAuthStore { identity.display_name = next_display_name.clone(); identity.avatar_url = next_avatar_url; identity.provider_union_id = next_provider_union_id.clone(); + if next_session_key.is_some() { + identity.session_key = next_session_key.clone(); + } state .wechat_identity_by_provider_uid .insert(next_provider_uid.clone(), identity); @@ -3193,6 +3200,7 @@ mod tests { provider_union_id: Some("wx-union-shared".to_string()), display_name: Some("微信旅人甲".to_string()), avatar_url: None, + session_key: None, }, }) .await @@ -3211,6 +3219,7 @@ mod tests { provider_union_id: Some("wx-union-shared".to_string()), display_name: Some("微信旅人乙".to_string()), avatar_url: None, + session_key: None, }, }) .await @@ -3258,6 +3267,7 @@ mod tests { provider_union_id: Some("wx-union-bind".to_string()), display_name: Some("待绑定微信用户".to_string()), avatar_url: None, + session_key: None, }, }) .await @@ -3303,6 +3313,7 @@ mod tests { provider_union_id: Some("wx-union-bind".to_string()), display_name: Some("已归并微信用户".to_string()), avatar_url: None, + session_key: None, }, }) .await diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index bc7dff76..68bf33bf 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -34,6 +34,7 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp"; +pub const PROFILE_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_NATIVE: &str = "wechat_native"; pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10; diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 9e2a657a..4615ae59 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -225,6 +225,7 @@ pub struct WechatIdentityProfile { pub provider_union_id: Option, pub display_name: Option, pub avatar_url: Option, + pub session_key: Option, } #[derive(Clone, Debug)] @@ -359,6 +360,7 @@ struct WechatUserInfoResponse { struct WechatJsCodeSessionResponse { openid: Option, unionid: Option, + session_key: Option, errcode: Option, errmsg: Option, } @@ -834,6 +836,7 @@ impl MockWechatProvider { provider_union_id: self.mock_union_id.clone(), display_name: Some(self.mock_display_name.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), display_name: user_info_payload.nickname, avatar_url: user_info_payload.headimgurl, + session_key: None, }) } @@ -1053,6 +1057,7 @@ impl RealWechatProvider { provider_union_id: payload.unionid, display_name: None, avatar_url: None, + session_key: payload.session_key, }) } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 2d2fc722..d88c07f8 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -245,6 +245,22 @@ pub struct WechatMiniProgramPayParamsResponse { 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)] #[serde(rename_all = "camelCase")] pub struct WechatH5PaymentResponse { @@ -283,7 +299,7 @@ pub struct CreateProfileRechargeOrderResponse { pub order: ProfileRechargeOrderResponse, pub center: ProfileRechargeCenterResponse, #[serde(default)] - pub wechat_mini_program_pay_params: Option, + pub wechat_mini_program_pay_params: Option, #[serde(default)] pub wechat_h5_payment: Option, #[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] fn profile_feedback_response_uses_camel_case_fields() { let payload = serde_json::to_value(SubmitProfileFeedbackResponse { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f27b62c7..ebc0acb5 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -236,7 +236,7 @@ const { kind: 'points', amountCents: 600, status: 'paid', - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-transaction-1', createdAt: '2026-04-25T10:00:00Z', @@ -275,7 +275,7 @@ const { kind: 'points', amountCents: 600, status: 'paid', - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', providerTransactionId: 'wx-transaction-1', createdAt: '2026-04-25T10:00: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(); }); -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 onRechargeSuccess = vi.fn(); 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', amountCents: 600, status: 'pending' as const, - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', @@ -1362,11 +1362,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie hasPointsRecharged: false, }, wechatMiniProgramPayParams: { - timeStamp: '1777110165', - nonceStr: 'nonce', - package: 'prepay_id=wx-prepay', - signType: 'RSA', - paySign: 'signature', + mode: 'short_series_coin', + signData: + '{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-1","attach":"mud_points_60"}', + paySig: 'pay-sig', + signature: 'user-sig', }, }); @@ -1377,7 +1377,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', - 'wechat_mp', + 'wechat_mp_virtual', ); }); expect(navigateTo).toHaveBeenCalledWith({ @@ -1395,7 +1395,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie window.dispatchEvent(new HashChangeEvent('hashchange')); }); 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(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith( 'points_60', @@ -1409,6 +1409,82 @@ test('profile recharge modal posts requestPayment params in mini program web-vie 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 () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); @@ -1429,7 +1505,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb kind: 'points', amountCents: 600, status: 'pending' as const, - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', @@ -1452,11 +1528,11 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb hasPointsRecharged: false, }, wechatMiniProgramPayParams: { - timeStamp: '1777110165', - nonceStr: 'nonce', - package: 'prepay_id=wx-prepay', - signType: 'RSA', - paySign: 'signature', + mode: 'short_series_coin', + signData: + '{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-pending-then-paid","attach":"mud_points_60"}', + paySig: 'pay-sig', + signature: 'user-sig', }, }); mockConfirmWechatRpgProfileRechargeOrder @@ -1468,7 +1544,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb kind: 'points', amountCents: 600, status: 'pending' as const, - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', @@ -1499,7 +1575,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb kind: 'points', amountCents: 600, status: 'paid' as const, - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-transaction-2', 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', amountCents: 600, status: 'pending' as const, - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: null as string | null, providerTransactionId: null, 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, }, wechatMiniProgramPayParams: { - timeStamp: '1777110165', - nonceStr: 'nonce', - package: 'prepay_id=wx-prepay', - signType: 'RSA', - paySign: 'signature', + mode: 'short_series_coin', + signData: + '{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-sdk-1","attach":"mud_points_60"}', + paySig: 'pay-sig', + signature: 'user-sig', }, }); @@ -1649,7 +1725,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa kind: 'points', amountCents: 600, status: 'pending' as const, - paymentChannel: 'wechat_mp', + paymentChannel: 'wechat_mp_virtual', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', @@ -1672,11 +1748,11 @@ test('profile recharge modal releases submitting state after cancelled wechat pa hasPointsRecharged: false, }, wechatMiniProgramPayParams: { - timeStamp: '1777110165', - nonceStr: 'nonce', - package: 'prepay_id=wx-prepay-cancel', - signType: 'RSA', - paySign: 'signature', + mode: 'short_series_coin', + signData: + '{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-cancel-1","attach":"mud_points_60"}', + paySig: 'pay-sig', + signature: 'user-sig', }, }); @@ -1688,7 +1764,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', - 'wechat_mp', + 'wechat_mp_virtual', ); }); expect( diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index ab466e98..636ff874 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -76,6 +76,7 @@ import type { ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, WechatMiniProgramPayParams, + WechatMiniProgramVirtualPayParams, WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard'; @@ -89,10 +90,10 @@ import { } from '../../services/authService'; import { copyTextToClipboard } from '../../services/clipboard'; import { - resolveProfileRechargePaymentChannel, + resolveProfileRechargeProductPaymentChannel, shouldShowRechargeEntry, WECHAT_H5_PAYMENT_CHANNEL, - WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL, + WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL, WECHAT_NATIVE_PAYMENT_CHANNEL, } from '../../services/payment/paymentPlatform'; import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect'; @@ -2740,7 +2741,11 @@ function loadWechatJsSdk() { } async function requestWechatMiniProgramPayment( - payload: WechatMiniProgramPayParams | null | undefined, + payload: + | WechatMiniProgramPayParams + | WechatMiniProgramVirtualPayParams + | null + | undefined, orderId: string, ): Promise { if (!payload) { @@ -4664,14 +4669,17 @@ export function RpgEntryHomeView({ return; } - const paymentChannel = resolveProfileRechargePaymentChannel(); + const paymentChannel = resolveProfileRechargeProductPaymentChannel( + { kind: product.kind }, + {}, + ); setSubmittingRechargeProductId(product.productId); setRechargeError(null); setRechargePaymentResult(null); setNativeWechatPayment(null); void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { - if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { + if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; await requestWechatMiniProgramPayment( response.wechatMiniProgramPayParams, diff --git a/src/services/payment/paymentPlatform.test.ts b/src/services/payment/paymentPlatform.test.ts index f26cf332..10f82284 100644 --- a/src/services/payment/paymentPlatform.test.ts +++ b/src/services/payment/paymentPlatform.test.ts @@ -2,20 +2,45 @@ import { describe, expect, test } from 'vitest'; import { resolveProfileRechargePaymentChannel, + resolveProfileRechargeProductPaymentChannel, shouldShowRechargeEntry, WECHAT_H5_PAYMENT_CHANNEL, - WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL, + WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL, WECHAT_NATIVE_PAYMENT_CHANNEL, } from './paymentPlatform'; describe('resolveProfileRechargePaymentChannel', () => { - test('小程序运行态选择 wechat_mp', () => { + test('小程序运行态基础通道选择 wechat_mp_virtual', () => { expect( resolveProfileRechargePaymentChannel({ location: { search: '?clientRuntime=wechat_mini_program' }, 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', () => { diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts index 93b26b8a..28f15a27 100644 --- a/src/services/payment/paymentPlatform.ts +++ b/src/services/payment/paymentPlatform.ts @@ -1,13 +1,19 @@ 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_NATIVE_PAYMENT_CHANNEL = 'wechat_native'; export const MOCK_PAYMENT_CHANNEL = 'mock'; export type ProfileRechargeWechatPaymentChannel = | typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL + | typeof WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL | typeof WECHAT_H5_PAYMENT_CHANNEL | typeof WECHAT_NATIVE_PAYMENT_CHANNEL; +export type ProfileRechargeProductPaymentMode = { + kind: 'points' | 'membership'; +}; + type PaymentPlatformNavigator = Pick; export type PaymentPlatformContext = { @@ -45,7 +51,7 @@ export function resolveProfileRechargePaymentChannel( : null); if (isWechatMiniProgramRuntime(location)) { - return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL; + return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL; } if (isMobileWebRuntime(navigatorLike, matchMedia)) { @@ -55,6 +61,13 @@ export function resolveProfileRechargePaymentChannel( return WECHAT_NATIVE_PAYMENT_CHANNEL; } +export function resolveProfileRechargeProductPaymentChannel( + _product: ProfileRechargeProductPaymentMode, + context: PaymentPlatformContext = {}, +): ProfileRechargeWechatPaymentChannel { + return resolveProfileRechargePaymentChannel(context); +} + export function isManualMockPaymentChannel(paymentChannel: string) { return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL; }