隔离账号切换后的充值异步链
为下单到确认和 watch 全链路绑定 owner 与账号生命周期版本 pending、confirming、二维码和提交状态仅由所属充值链更新 在 catch、finally、成功回调及清 token 前拒绝旧账号副作用 覆盖旧下单、旧确认和特殊错误不影响新账号 同步更新充值生命周期基线
This commit is contained in:
@@ -65,7 +65,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
||||
4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
|
||||
5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。
|
||||
6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
|
||||
7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。
|
||||
7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。
|
||||
8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。
|
||||
9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。
|
||||
|
||||
|
||||
@@ -3,18 +3,77 @@
|
||||
import { act, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import {
|
||||
apiClientMocks,
|
||||
hostBridgeMocks,
|
||||
paymentPlatformMocks,
|
||||
profileClientMocks,
|
||||
renderController,
|
||||
userA,
|
||||
userB,
|
||||
} from './usePlatformProfileCenterController.testSupport';
|
||||
|
||||
const rechargeProduct = {
|
||||
productId: 'points-60',
|
||||
kind: 'points',
|
||||
} as ProfileRechargeProduct;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function mudPointBalance(totalPoints: number) {
|
||||
return {
|
||||
totalPoints,
|
||||
permanentPoints: totalPoints,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 0,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-07T00:00:00+08:00',
|
||||
};
|
||||
}
|
||||
|
||||
function nativeRechargeResponse(totalPoints = 80, orderId = 'order-a') {
|
||||
return {
|
||||
order: {
|
||||
orderId,
|
||||
productTitle: '60 泥点',
|
||||
amountCents: 600,
|
||||
status: 'pending',
|
||||
expirationCheckedAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: totalPoints,
|
||||
mudPointBalance: mudPointBalance(totalPoints),
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: `weixin://wxpay/${orderId}`,
|
||||
expiresAt: '2099-08-07T00:00:00+08:00',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePlatformProfileCenterController recharge fallback', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/profile');
|
||||
usePlatformWalletStore.getState().resetWalletBalance();
|
||||
vi.clearAllMocks();
|
||||
paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue(
|
||||
'wechat_native',
|
||||
);
|
||||
hostBridgeMocks.getHostRuntime.mockReturnValue({ kind: 'browser' });
|
||||
profileClientMocks.watchWechatPlatformProfileRechargeOrder.mockReturnValue(
|
||||
new Promise(() => undefined),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the response legacy balance when no shared snapshot exists', async () => {
|
||||
@@ -36,4 +95,129 @@ describe('usePlatformProfileCenterController recharge fallback', () => {
|
||||
expect(result.current.rechargeCenter?.walletBalance).toBe(37);
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores an old owner order response after the account changes', async () => {
|
||||
const pendingOrder = deferred<ReturnType<typeof nativeRechargeResponse>>();
|
||||
profileClientMocks.createPlatformProfileRechargeOrder
|
||||
.mockReturnValueOnce(pendingOrder.promise)
|
||||
.mockResolvedValueOnce(nativeRechargeResponse(90, 'order-b'));
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-a');
|
||||
const { result, rerender } = renderController(userA);
|
||||
|
||||
act(() => result.current.buyRechargeProduct(rechargeProduct));
|
||||
expect(result.current.submittingRechargeProductId).toBe('points-60');
|
||||
|
||||
act(() => {
|
||||
rerender({ user: userB });
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-b');
|
||||
});
|
||||
act(() => result.current.buyRechargeProduct(rechargeProduct));
|
||||
await waitFor(() => {
|
||||
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
|
||||
});
|
||||
await act(async () => {
|
||||
pendingOrder.resolve(nativeRechargeResponse());
|
||||
await pendingOrder.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.submittingRechargeProductId).toBeNull();
|
||||
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
|
||||
expect(result.current.rechargePaymentResult).toBeNull();
|
||||
expect(result.current.rechargeCenter?.walletBalance).toBe(90);
|
||||
expect(usePlatformWalletStore.getState()).toMatchObject({
|
||||
ownerUserId: 'user-b',
|
||||
mudPointBalance: mudPointBalance(90),
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores an old owner native confirmation after the account changes', async () => {
|
||||
const pendingConfirmation = deferred<{
|
||||
order: ReturnType<typeof nativeRechargeResponse>['order'];
|
||||
center: ReturnType<typeof nativeRechargeResponse>['center'];
|
||||
}>();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue(
|
||||
nativeRechargeResponse(),
|
||||
);
|
||||
profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue(
|
||||
pendingConfirmation.promise,
|
||||
);
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-a');
|
||||
const { result, rerender } = renderController(userA, onRechargeSuccess);
|
||||
|
||||
act(() => result.current.buyRechargeProduct(rechargeProduct));
|
||||
await waitFor(() => {
|
||||
expect(result.current.nativeWechatPayment?.orderId).toBe('order-a');
|
||||
});
|
||||
act(() => result.current.confirmNativeWechatPayment());
|
||||
|
||||
act(() => {
|
||||
rerender({ user: userB });
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-b');
|
||||
});
|
||||
await act(async () => {
|
||||
pendingConfirmation.resolve({
|
||||
order: {
|
||||
...nativeRechargeResponse().order,
|
||||
status: 'paid',
|
||||
},
|
||||
center: nativeRechargeResponse(100).center,
|
||||
});
|
||||
await pendingConfirmation.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.nativeWechatPayment).toBeNull();
|
||||
expect(result.current.rechargePaymentResult).toBeNull();
|
||||
expect(result.current.wechatRechargeOrderConfirmationState).toBeNull();
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
expect(usePlatformWalletStore.getState()).toMatchObject({
|
||||
ownerUserId: 'user-b',
|
||||
mudPointBalance: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('does not clear the new owner token from an old owner payment error', async () => {
|
||||
const pendingOrder = deferred<never>();
|
||||
paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue(
|
||||
'wechat_mp_virtual',
|
||||
);
|
||||
hostBridgeMocks.getHostRuntime.mockReturnValue({
|
||||
kind: 'wechat_mini_program',
|
||||
});
|
||||
profileClientMocks.createPlatformProfileRechargeOrder
|
||||
.mockReturnValueOnce(pendingOrder.promise)
|
||||
.mockResolvedValueOnce(nativeRechargeResponse(70, 'order-b'));
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-a');
|
||||
const { result, rerender } = renderController(userA);
|
||||
|
||||
act(() => result.current.buyRechargeProduct(rechargeProduct));
|
||||
act(() => {
|
||||
rerender({ user: userB });
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-b');
|
||||
});
|
||||
paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue(
|
||||
'wechat_native',
|
||||
);
|
||||
act(() => result.current.buyRechargeProduct(rechargeProduct));
|
||||
await waitFor(() => {
|
||||
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
|
||||
});
|
||||
await act(async () => {
|
||||
pendingOrder.reject(new Error('当前登录设备不支持充值'));
|
||||
await pendingOrder.promise.catch(() => undefined);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled();
|
||||
expect(hostBridgeMocks.requestHostLogin).not.toHaveBeenCalled();
|
||||
expect(result.current.rechargeError).toBeNull();
|
||||
expect(result.current.rechargePaymentResult).toBeNull();
|
||||
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
|
||||
expect(usePlatformWalletStore.getState()).toMatchObject({
|
||||
ownerUserId: 'user-b',
|
||||
mudPointBalance: mudPointBalance(70),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,52 @@ const profileClientMocks = vi.hoisted(() => ({
|
||||
watchWechatPlatformProfileRechargeOrder: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/platform-entry/platformProfileClient', () =>
|
||||
profileClientMocks,
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
clearStoredAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const hostBridgeMocks = vi.hoisted(() => ({
|
||||
getHostRuntime: vi.fn(() => ({ kind: 'browser' })),
|
||||
requestHostLogin: vi.fn(),
|
||||
requestHostPayment: vi.fn(),
|
||||
}));
|
||||
|
||||
const paymentPlatformMocks = vi.hoisted(() => ({
|
||||
resolveProfileRechargeProductPaymentChannel: vi.fn(() => 'wechat_native'),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'../../services/platform-entry/platformProfileClient',
|
||||
() => profileClientMocks,
|
||||
);
|
||||
|
||||
export { profileClientMocks };
|
||||
vi.mock('../../services/apiClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/apiClient')
|
||||
>('../../services/apiClient');
|
||||
return { ...actual, ...apiClientMocks };
|
||||
});
|
||||
|
||||
vi.mock('../../services/host-bridge/hostBridge', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/host-bridge/hostBridge')
|
||||
>('../../services/host-bridge/hostBridge');
|
||||
return { ...actual, ...hostBridgeMocks };
|
||||
});
|
||||
|
||||
vi.mock('../../services/payment/paymentPlatform', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/payment/paymentPlatform')
|
||||
>('../../services/payment/paymentPlatform');
|
||||
return { ...actual, ...paymentPlatformMocks };
|
||||
});
|
||||
|
||||
export {
|
||||
apiClientMocks,
|
||||
hostBridgeMocks,
|
||||
paymentPlatformMocks,
|
||||
profileClientMocks,
|
||||
};
|
||||
|
||||
import { usePlatformProfileCenterController } from './usePlatformProfileCenterController';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user