fix wechat virtual payment coin flow
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
## 2026-05-26 微信小程序充值全面接入虚拟支付
|
## 2026-05-26 微信小程序充值全面接入虚拟支付
|
||||||
|
|
||||||
- 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。
|
- 背景:泥点和会员都属于小程序内由 Genarrative 控制的虚拟资产/权益,继续走普通小程序支付不符合微信虚拟支付接入口径。
|
||||||
- 决策:小程序 WebView 内充值商品全部使用渠道 `wechat_mp_virtual` 并由 `miniprogram/pages/wechat-pay` 调用 `wx.requestVirtualPayment`;泥点使用 `short_series_coin`,会员使用 `short_series_goods`,会员 `signData` 必须带 `productId` 与 `goodsPrice`。后端保存微信小程序 `session_key`,仅用于生成 `signature`,不下发客户端。客户端 success 只作为支付页返回信号,最终到账仍由后端微信通知或查询确认后写订单。
|
- 决策:小程序 WebView 内充值商品全部使用渠道 `wechat_mp_virtual` 并由 `miniprogram/pages/wechat-pay` 调用 `wx.requestVirtualPayment`;泥点属于代币(coin),使用 `short_series_coin`,`buyQuantity` 必须取当前充值中心商品快照里的 `points_amount`;会员和后台新增道具类商品使用 `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`、微信登录态存储。
|
- 影响范围:`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` 与支付相关前端测试通过。
|
- 验证方式:泥点和会员商品在小程序运行态都请求 `wechat_mp_virtual`;小程序页能按 payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment`;`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关前端测试通过。
|
||||||
- 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
- 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ 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`。
|
微信小程序虚拟支付使用 `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` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `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` 指向可发布的本地库。
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
|
|||||||
- `signData`:传给 `wx.requestVirtualPayment` 的订单数据。
|
- `signData`:传给 `wx.requestVirtualPayment` 的订单数据。
|
||||||
- `paySig`:`HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)` 的小写 hex。
|
- `paySig`:`HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)` 的小写 hex。
|
||||||
- `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。
|
- `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。
|
||||||
|
- 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。
|
||||||
- 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。
|
- 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。
|
||||||
|
|
||||||
## 验收命令
|
## 验收命令
|
||||||
@@ -54,7 +55,9 @@ npm run check:encoding
|
|||||||
|
|
||||||
- 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView,但虚拟支付会继续由后端拦截并提示重新登录。
|
- 旧微信登录快照可能没有 `session_key`;小程序 WebView 会在普通进入时静默刷新一次微信登录态,刷新失败时仍允许匿名打开 WebView,但虚拟支付会继续由后端拦截并提示重新登录。
|
||||||
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。
|
- 小程序充值商品全部映射到虚拟支付;泥点使用 `short_series_coin`,会员使用 `short_series_goods`。
|
||||||
|
- `short_series_coin` 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 `points_amount` 并写入 `buyQuantity`;不要把 coin 商品当成道具,也不要把 `buyQuantity` 固定为 1。
|
||||||
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
|
- 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。
|
||||||
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。
|
- 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。
|
||||||
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
||||||
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
|
||||||
|
- Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ function requestOrdinaryPayment(payParams) {
|
|||||||
function requestVirtualPayment(payParams) {
|
function requestVirtualPayment(payParams) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') {
|
if (!canUseVirtualPayment() || typeof wx.requestVirtualPayment !== 'function') {
|
||||||
|
console.error('[wechat-pay] requestVirtualPayment unavailable', {
|
||||||
|
canUseVirtualPayment: canUseVirtualPayment(),
|
||||||
|
hasRequestVirtualPayment: typeof wx.requestVirtualPayment === 'function',
|
||||||
|
});
|
||||||
resolve({
|
resolve({
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
errorMessage: '当前微信基础库不支持 requestVirtualPayment',
|
errorMessage: '当前微信基础库不支持 requestVirtualPayment',
|
||||||
@@ -120,6 +124,7 @@ function requestVirtualPayment(payParams) {
|
|||||||
resolve({ status: 'success', errorMessage: '' });
|
resolve({ status: 'success', errorMessage: '' });
|
||||||
},
|
},
|
||||||
fail(error) {
|
fail(error) {
|
||||||
|
console.error('[wechat-pay] requestVirtualPayment failed', error);
|
||||||
resolve({
|
resolve({
|
||||||
status: resolvePayStatus(error),
|
status: resolvePayStatus(error),
|
||||||
errorMessage: normalizePayError(error),
|
errorMessage: normalizePayError(error),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
|
|
||||||
describe('wechat-pay mini program payment bridge', () => {
|
describe('wechat-pay mini program payment bridge', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
globalThis.wx = {
|
globalThis.wx = {
|
||||||
requestPayment: vi.fn(),
|
requestPayment: vi.fn(),
|
||||||
requestVirtualPayment: vi.fn(),
|
requestVirtualPayment: vi.fn(),
|
||||||
@@ -100,8 +101,12 @@ describe('wechat-pay mini program payment bridge', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('maps virtual payment cancel errCode to cancel result', async () => {
|
test('maps virtual payment cancel errCode to cancel result', async () => {
|
||||||
|
const payError = {
|
||||||
|
errCode: -2,
|
||||||
|
errMsg: 'requestVirtualPayment:fail cancel',
|
||||||
|
};
|
||||||
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
|
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
|
||||||
options.fail?.({ errCode: -2, errMsg: 'requestVirtualPayment:fail cancel' });
|
options.fail?.(payError);
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -118,6 +123,10 @@ describe('wechat-pay mini program payment bridge', () => {
|
|||||||
errMsg: 'requestVirtualPayment:fail cancel',
|
errMsg: 'requestVirtualPayment:fail cancel',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
'[wechat-pay] requestVirtualPayment failed',
|
||||||
|
payError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('page notifies previous web-view after virtual payment', async () => {
|
test('page notifies previous web-view after virtual payment', async () => {
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ pub async fn create_profile_recharge_order(
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||||
Some(
|
Some(
|
||||||
build_wechat_virtual_pay_params(&state, &order, &openid)
|
build_wechat_virtual_pay_params(&state, ¢er, &order, &openid)
|
||||||
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
|
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
|
||||||
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
|
||||||
)
|
)
|
||||||
@@ -1169,9 +1169,28 @@ async fn resolve_wechat_identity_for_payment(
|
|||||||
|
|
||||||
fn build_wechat_virtual_pay_params(
|
fn build_wechat_virtual_pay_params(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
center: &RuntimeProfileRechargeCenterRecord,
|
||||||
order: &RuntimeProfileRechargeOrderRecord,
|
order: &RuntimeProfileRechargeOrderRecord,
|
||||||
openid: &str,
|
openid: &str,
|
||||||
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
|
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
|
||||||
|
let product = match order.kind {
|
||||||
|
RuntimeProfileRechargeProductKind::Points => center
|
||||||
|
.point_products
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.product_id == order.product_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("当前充值商品不存在,请刷新后再试")
|
||||||
|
})?,
|
||||||
|
RuntimeProfileRechargeProductKind::Membership => center
|
||||||
|
.membership_products
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.product_id == order.product_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("当前充值商品不存在,请刷新后再试")
|
||||||
|
})?,
|
||||||
|
};
|
||||||
let identity = state
|
let identity = state
|
||||||
.wechat_auth_service()
|
.wechat_auth_service()
|
||||||
.get_identity_by_user_id(&order.user_id)
|
.get_identity_by_user_id(&order.user_id)
|
||||||
@@ -1198,9 +1217,13 @@ fn build_wechat_virtual_pay_params(
|
|||||||
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
|
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
|
||||||
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
|
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
|
||||||
};
|
};
|
||||||
|
let buy_quantity = match product.kind {
|
||||||
|
RuntimeProfileRechargeProductKind::Points => product.points_amount,
|
||||||
|
RuntimeProfileRechargeProductKind::Membership => 1,
|
||||||
|
};
|
||||||
let mut sign_data = serde_json::json!({
|
let mut sign_data = serde_json::json!({
|
||||||
"offerId": offer_id,
|
"offerId": offer_id,
|
||||||
"buyQuantity": 1,
|
"buyQuantity": buy_quantity,
|
||||||
"env": state.config.wechat_mini_program_virtual_payment_env,
|
"env": state.config.wechat_mini_program_virtual_payment_env,
|
||||||
"currencyType": "CNY",
|
"currencyType": "CNY",
|
||||||
"outTradeNo": order.order_id,
|
"outTradeNo": order.order_id,
|
||||||
@@ -1772,8 +1795,11 @@ mod tests {
|
|||||||
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
|
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
|
||||||
use module_runtime::{
|
use module_runtime::{
|
||||||
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
|
||||||
|
RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus,
|
||||||
|
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
|
||||||
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
|
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
|
||||||
RuntimeProfileRechargeProductKind, RuntimeProfileWalletLedgerSourceType,
|
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord,
|
||||||
|
RuntimeProfileWalletLedgerSourceType,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -2284,7 +2310,39 @@ mod tests {
|
|||||||
membership_expires_at_micros: None,
|
membership_expires_at_micros: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-00000001")
|
let center = RuntimeProfileRechargeCenterRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
wallet_balance: 0,
|
||||||
|
membership: RuntimeProfileMembershipRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
status: RuntimeProfileMembershipStatus::Normal,
|
||||||
|
tier: RuntimeProfileMembershipTier::Normal,
|
||||||
|
started_at: None,
|
||||||
|
started_at_micros: None,
|
||||||
|
expires_at: None,
|
||||||
|
expires_at_micros: None,
|
||||||
|
updated_at: None,
|
||||||
|
updated_at_micros: None,
|
||||||
|
},
|
||||||
|
point_products: vec![],
|
||||||
|
membership_products: vec![RuntimeProfileRechargeProductRecord {
|
||||||
|
product_id: "member_month".to_string(),
|
||||||
|
title: "月卡".to_string(),
|
||||||
|
price_cents: 2800,
|
||||||
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||||
|
points_amount: 0,
|
||||||
|
bonus_points: 0,
|
||||||
|
duration_days: 30,
|
||||||
|
badge_label: String::new(),
|
||||||
|
description: "30天会员".to_string(),
|
||||||
|
tier: RuntimeProfileMembershipTier::Month,
|
||||||
|
}],
|
||||||
|
benefits: vec![],
|
||||||
|
latest_order: None,
|
||||||
|
has_points_recharged: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001")
|
||||||
.expect("membership virtual pay params should build");
|
.expect("membership virtual pay params should build");
|
||||||
let sign_data: Value =
|
let sign_data: Value =
|
||||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||||
@@ -2296,6 +2354,7 @@ mod tests {
|
|||||||
.expect("attach should decode");
|
.expect("attach should decode");
|
||||||
|
|
||||||
assert_eq!(params.mode, "short_series_goods");
|
assert_eq!(params.mode, "short_series_goods");
|
||||||
|
assert_eq!(sign_data["buyQuantity"], 1);
|
||||||
assert_eq!(sign_data["offerId"], "offer-1");
|
assert_eq!(sign_data["offerId"], "offer-1");
|
||||||
assert_eq!(sign_data["productId"], "member_month");
|
assert_eq!(sign_data["productId"], "member_month");
|
||||||
assert_eq!(sign_data["goodsPrice"], 2800);
|
assert_eq!(sign_data["goodsPrice"], 2800);
|
||||||
@@ -2305,6 +2364,106 @@ mod tests {
|
|||||||
assert!(!params.signature.is_empty());
|
assert!(!params.signature.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wechat_virtual_pay_params_use_coin_quantity_for_points_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-points-60".to_string(),
|
||||||
|
provider_union_id: Some("union-user-points-60".to_string()),
|
||||||
|
display_name: Some("资料页用户".to_string()),
|
||||||
|
avatar_url: None,
|
||||||
|
session_key: Some("session-key-points-60".to_string()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("wechat identity should seed");
|
||||||
|
let user_id = wechat_login.user.id.clone();
|
||||||
|
let order = RuntimeProfileRechargeOrderRecord {
|
||||||
|
order_id: "pointsorder60".to_string(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
product_id: "points_60".to_string(),
|
||||||
|
product_title: "60泥点".to_string(),
|
||||||
|
kind: RuntimeProfileRechargeProductKind::Points,
|
||||||
|
amount_cents: 600,
|
||||||
|
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-30T10:00:00Z".to_string(),
|
||||||
|
created_at_micros: 1_780_000_000_000_000,
|
||||||
|
points_delta: 0,
|
||||||
|
membership_expires_at: None,
|
||||||
|
membership_expires_at_micros: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let center = RuntimeProfileRechargeCenterRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
wallet_balance: 0,
|
||||||
|
membership: RuntimeProfileMembershipRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
status: RuntimeProfileMembershipStatus::Normal,
|
||||||
|
tier: RuntimeProfileMembershipTier::Normal,
|
||||||
|
started_at: None,
|
||||||
|
started_at_micros: None,
|
||||||
|
expires_at: None,
|
||||||
|
expires_at_micros: None,
|
||||||
|
updated_at: None,
|
||||||
|
updated_at_micros: None,
|
||||||
|
},
|
||||||
|
point_products: vec![RuntimeProfileRechargeProductRecord {
|
||||||
|
product_id: "points_60".to_string(),
|
||||||
|
title: "60泥点".to_string(),
|
||||||
|
price_cents: 600,
|
||||||
|
kind: RuntimeProfileRechargeProductKind::Points,
|
||||||
|
points_amount: 60,
|
||||||
|
bonus_points: 60,
|
||||||
|
duration_days: 0,
|
||||||
|
badge_label: "首充双倍".to_string(),
|
||||||
|
description: "60+60泥点".to_string(),
|
||||||
|
tier: RuntimeProfileMembershipTier::Normal,
|
||||||
|
}],
|
||||||
|
membership_products: vec![],
|
||||||
|
benefits: vec![],
|
||||||
|
latest_order: None,
|
||||||
|
has_points_recharged: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = build_wechat_virtual_pay_params(
|
||||||
|
&state,
|
||||||
|
¢er,
|
||||||
|
&order,
|
||||||
|
"openid-user-points-60",
|
||||||
|
)
|
||||||
|
.expect("points 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_coin");
|
||||||
|
assert_eq!(sign_data["buyQuantity"], 60);
|
||||||
|
assert_eq!(sign_data["offerId"], "offer-1");
|
||||||
|
assert_eq!(sign_data["outTradeNo"], "pointsorder60");
|
||||||
|
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
|
||||||
|
assert!(!params.pay_sig.is_empty());
|
||||||
|
assert!(!params.signature.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
|
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
|
||||||
let state = seed_authenticated_state_with_config(AppConfig {
|
let state = seed_authenticated_state_with_config(AppConfig {
|
||||||
@@ -2327,9 +2486,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("wechat identity should seed");
|
.expect("wechat identity should seed");
|
||||||
|
let user_id = wechat_login.user.id.clone();
|
||||||
let order = RuntimeProfileRechargeOrderRecord {
|
let order = RuntimeProfileRechargeOrderRecord {
|
||||||
order_id: "item01order01".to_string(),
|
order_id: "item01order01".to_string(),
|
||||||
user_id: wechat_login.user.id,
|
user_id: user_id.clone(),
|
||||||
product_id: "item01".to_string(),
|
product_id: "item01".to_string(),
|
||||||
product_title: "测试道具".to_string(),
|
product_title: "测试道具".to_string(),
|
||||||
kind: RuntimeProfileRechargeProductKind::Membership,
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||||
@@ -2347,7 +2507,39 @@ mod tests {
|
|||||||
membership_expires_at_micros: None,
|
membership_expires_at_micros: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-item01")
|
let center = RuntimeProfileRechargeCenterRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
wallet_balance: 0,
|
||||||
|
membership: RuntimeProfileMembershipRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
status: RuntimeProfileMembershipStatus::Normal,
|
||||||
|
tier: RuntimeProfileMembershipTier::Normal,
|
||||||
|
started_at: None,
|
||||||
|
started_at_micros: None,
|
||||||
|
expires_at: None,
|
||||||
|
expires_at_micros: None,
|
||||||
|
updated_at: None,
|
||||||
|
updated_at_micros: None,
|
||||||
|
},
|
||||||
|
point_products: vec![],
|
||||||
|
membership_products: vec![RuntimeProfileRechargeProductRecord {
|
||||||
|
product_id: "item01".to_string(),
|
||||||
|
title: "测试道具".to_string(),
|
||||||
|
price_cents: 100,
|
||||||
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
||||||
|
points_amount: 0,
|
||||||
|
bonus_points: 0,
|
||||||
|
duration_days: 30,
|
||||||
|
badge_label: String::new(),
|
||||||
|
description: "30天会员".to_string(),
|
||||||
|
tier: RuntimeProfileMembershipTier::Month,
|
||||||
|
}],
|
||||||
|
benefits: vec![],
|
||||||
|
latest_order: None,
|
||||||
|
has_points_recharged: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-item01")
|
||||||
.expect("custom membership virtual pay params should build");
|
.expect("custom membership virtual pay params should build");
|
||||||
let sign_data: Value =
|
let sign_data: Value =
|
||||||
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json");
|
||||||
@@ -2404,9 +2596,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("wechat identity should seed");
|
.expect("wechat identity should seed");
|
||||||
|
let user_id = wechat_login.user.id.clone();
|
||||||
let order = RuntimeProfileRechargeOrderRecord {
|
let order = RuntimeProfileRechargeOrderRecord {
|
||||||
order_id: "sandboxorder01".to_string(),
|
order_id: "sandboxorder01".to_string(),
|
||||||
user_id: wechat_login.user.id,
|
user_id: user_id.clone(),
|
||||||
product_id: "points_60".to_string(),
|
product_id: "points_60".to_string(),
|
||||||
product_title: "60泥点".to_string(),
|
product_title: "60泥点".to_string(),
|
||||||
kind: RuntimeProfileRechargeProductKind::Points,
|
kind: RuntimeProfileRechargeProductKind::Points,
|
||||||
@@ -2424,7 +2617,39 @@ mod tests {
|
|||||||
membership_expires_at_micros: None,
|
membership_expires_at_micros: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let error = build_wechat_virtual_pay_params(&state, &order, "openid-sandbox-1")
|
let center = RuntimeProfileRechargeCenterRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
wallet_balance: 0,
|
||||||
|
membership: RuntimeProfileMembershipRecord {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
status: RuntimeProfileMembershipStatus::Normal,
|
||||||
|
tier: RuntimeProfileMembershipTier::Normal,
|
||||||
|
started_at: None,
|
||||||
|
started_at_micros: None,
|
||||||
|
expires_at: None,
|
||||||
|
expires_at_micros: None,
|
||||||
|
updated_at: None,
|
||||||
|
updated_at_micros: None,
|
||||||
|
},
|
||||||
|
point_products: vec![RuntimeProfileRechargeProductRecord {
|
||||||
|
product_id: "points_60".to_string(),
|
||||||
|
title: "60泥点".to_string(),
|
||||||
|
price_cents: 600,
|
||||||
|
kind: RuntimeProfileRechargeProductKind::Points,
|
||||||
|
points_amount: 60,
|
||||||
|
bonus_points: 60,
|
||||||
|
duration_days: 0,
|
||||||
|
badge_label: "首充双倍".to_string(),
|
||||||
|
description: "60+60泥点".to_string(),
|
||||||
|
tier: RuntimeProfileMembershipTier::Normal,
|
||||||
|
}],
|
||||||
|
membership_products: vec![],
|
||||||
|
benefits: vec![],
|
||||||
|
latest_order: None,
|
||||||
|
has_points_recharged: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-sandbox-1")
|
||||||
.expect_err("sandbox pay params should reject missing sandbox app key");
|
.expect_err("sandbox pay params should reject missing sandbox app key");
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("沙箱 AppKey 未配置"),
|
error.to_string().contains("沙箱 AppKey 未配置"),
|
||||||
|
|||||||
@@ -1594,6 +1594,211 @@ test('profile recharge modal releases submitting state and shows virtual payment
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('profile recharge modal eventually shows error text even when hashchange is not dispatched', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
|
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-wechat-delayed-fail',
|
||||||
|
productId: 'points_60',
|
||||||
|
productTitle: '60泥点',
|
||||||
|
kind: 'points',
|
||||||
|
amountCents: 600,
|
||||||
|
status: 'pending' as const,
|
||||||
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
|
paidAt: null as string | null,
|
||||||
|
providerTransactionId: null,
|
||||||
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
|
pointsDelta: 0,
|
||||||
|
membershipExpiresAt: null,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
},
|
||||||
|
wechatMiniProgramPayParams: {
|
||||||
|
mode: 'short_series_coin',
|
||||||
|
signData:
|
||||||
|
'{"offerId":"offer-1","buyQuantity":1,"env":1,"currencyType":"CNY","outTradeNo":"order-wechat-delayed-fail","attach":"mud_points_60"}',
|
||||||
|
paySig: 'sandbox-pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProfileView();
|
||||||
|
await openRechargeModal(user);
|
||||||
|
const buyButton = await screen.findByRole('button', { name: /60泥点/u });
|
||||||
|
await user.click(buyButton);
|
||||||
|
|
||||||
|
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||||
|
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
|
||||||
|
'requestId',
|
||||||
|
);
|
||||||
|
expect(requestId).toBeTruthy();
|
||||||
|
window.location.hash = `wx_pay_result=${requestId}:fail:order-wechat-delayed-fail:${encodeURIComponent('{"errCode":-1,"errMsg":"requestVirtualPayment:fail delayed"}')}`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('dialog', { name: '支付未完成' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByText(/requestVirtualPayment:fail delayed/u)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile recharge modal keeps polling long enough for late success result', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRechargeSuccess = vi.fn();
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
|
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-wechat-late-success',
|
||||||
|
productId: 'points_60',
|
||||||
|
productTitle: '60泥点',
|
||||||
|
kind: 'points',
|
||||||
|
amountCents: 600,
|
||||||
|
status: 'pending' as const,
|
||||||
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
|
paidAt: null as string | null,
|
||||||
|
providerTransactionId: null,
|
||||||
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
|
pointsDelta: 0,
|
||||||
|
membershipExpiresAt: null,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
},
|
||||||
|
wechatMiniProgramPayParams: {
|
||||||
|
mode: 'short_series_coin',
|
||||||
|
signData:
|
||||||
|
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-late-success","attach":"mud_points_60"}',
|
||||||
|
paySig: 'pay-sig',
|
||||||
|
signature: 'user-sig',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockConfirmWechatRpgProfileRechargeOrder
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-wechat-late-success',
|
||||||
|
productId: 'points_60',
|
||||||
|
productTitle: '60泥点',
|
||||||
|
kind: 'points',
|
||||||
|
amountCents: 600,
|
||||||
|
status: 'pending' as const,
|
||||||
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
|
paidAt: null,
|
||||||
|
providerTransactionId: null,
|
||||||
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
|
pointsDelta: 0,
|
||||||
|
membershipExpiresAt: null,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-wechat-late-success',
|
||||||
|
productId: 'points_60',
|
||||||
|
productTitle: '60泥点',
|
||||||
|
kind: 'points',
|
||||||
|
amountCents: 600,
|
||||||
|
status: 'paid' as const,
|
||||||
|
paymentChannel: 'wechat_mp_virtual',
|
||||||
|
paidAt: '2026-04-25T10:01:00Z',
|
||||||
|
providerTransactionId: 'wx-transaction-late',
|
||||||
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
|
pointsDelta: 120,
|
||||||
|
membershipExpiresAt: null,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
walletBalance: 120,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProfileView(onRechargeSuccess);
|
||||||
|
await openRechargeModal(user);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
|
||||||
|
|
||||||
|
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||||
|
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
|
||||||
|
'requestId',
|
||||||
|
);
|
||||||
|
expect(requestId).toBeTruthy();
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 2600));
|
||||||
|
act(() => {
|
||||||
|
window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-late-success`;
|
||||||
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
|
||||||
|
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
}, 12000);
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -337,6 +337,8 @@ type RechargePaymentResult = {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
|
||||||
|
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
|
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
|
||||||
const maybeDetector = (globalThis as unknown as {
|
const maybeDetector = (globalThis as unknown as {
|
||||||
@@ -4588,7 +4590,7 @@ export function RpgEntryHomeView({
|
|||||||
const handleWechatPayResult = useCallback(() => {
|
const handleWechatPayResult = useCallback(() => {
|
||||||
const payResult = readWechatPayResultFromHash();
|
const payResult = readWechatPayResultFromHash();
|
||||||
if (!payResult) {
|
if (!payResult) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -4596,7 +4598,7 @@ export function RpgEntryHomeView({
|
|||||||
payResult.orderId &&
|
payResult.orderId &&
|
||||||
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
|
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmittingRechargeProductId(null);
|
setSubmittingRechargeProductId(null);
|
||||||
@@ -4661,6 +4663,7 @@ export function RpgEntryHomeView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearWechatPayResultHash();
|
clearWechatPayResultHash();
|
||||||
|
return true;
|
||||||
}, [onRechargeSuccess, refreshRechargeState]);
|
}, [onRechargeSuccess, refreshRechargeState]);
|
||||||
const openRechargeModal = () => {
|
const openRechargeModal = () => {
|
||||||
if (!authUi?.user) {
|
if (!authUi?.user) {
|
||||||
@@ -4823,6 +4826,39 @@ export function RpgEntryHomeView({
|
|||||||
document.removeEventListener('visibilitychange', handleResume);
|
document.removeEventListener('visibilitychange', handleResume);
|
||||||
};
|
};
|
||||||
}, [handleWechatPayResult]);
|
}, [handleWechatPayResult]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
rechargePaymentResult?.kind !== 'pending' ||
|
||||||
|
rechargePaymentResult.title !== '正在支付'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let timer: number | null = null;
|
||||||
|
const pollPayResult = () => {
|
||||||
|
if (handleWechatPayResult()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(
|
||||||
|
pollPayResult,
|
||||||
|
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = window.setTimeout(
|
||||||
|
pollPayResult,
|
||||||
|
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
if (timer !== null) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [handleWechatPayResult, rechargePaymentResult?.kind, rechargePaymentResult?.title]);
|
||||||
const loadTaskCenter = useCallback(() => {
|
const loadTaskCenter = useCallback(() => {
|
||||||
const requestId = ++taskCenterRequestIdRef.current;
|
const requestId = ++taskCenterRequestIdRef.current;
|
||||||
setTaskCenterError(null);
|
setTaskCenterError(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user