From e95c2d73bff257b0cc3e7245c11b5af770bbcb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 15:32:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=97=B6=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=E5=86=99?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为充值下单、邀请码兑换和奖励码兑换绑定账号生命周期中止信号 补充切号、卸载、401 与 503 重试回归测试 按 Prettier 3.3.3 修复分支变更文件格式并更新工程约束 --- .../tests/appSurface/home.suite.ts | 4 +- .../shared-memory/decision-log.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 1 + ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- src/components/auth/AuthGate.tsx | 38 +++--- ...mProfileCenterController.recharge.test.tsx | 22 ++++ ...rofileCenterController.redemption.test.tsx | 9 ++ .../usePlatformProfileCenterController.ts | 44 ++++++- src/services/apiClient.test.ts | 110 ++++++++++++++++-- .../platformProfileClient.test.ts | 38 +++++- 10 files changed, 232 insertions(+), 39 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e718dc847..9a6bb3945 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -369,9 +369,7 @@ export function registerClientHomeTests() { source: { kind: 'generated' }, }, ]; - let resolveStaleRefresh!: ( - manifest: typeof staleFirstManifest, - ) => void; + let resolveStaleRefresh!: (manifest: typeof staleFirstManifest) => void; const staleRefresh = new Promise((resolve) => { resolveStaleRefresh = resolve; }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7762a385b..7a44d2302 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6298,6 +6298,7 @@ - 影响范围:`/editor/canvas` 的 `audio-background-music` 面板撤销按钮与其定向测试;不改变单层交换快照语义、canonicalization、提交锁、Suno 契约或后端 Prompt 助手,也不修改状态模型字段,`temporaryPromptSnapshot` 已在公开 dialog 状态中且只在 `completing` / `simplifying` 期间非空。本条不适用于 SFX,V1.0 不改动 SFX 的一键优化与撤销行为。 - 验证方式:按矩阵逐行覆盖初始隐藏、首次与再次 AI 处理期间显示并禁用、成功启用、失败隐藏、手动编辑后仍启用、点击预设隐藏、`submitting` 有无快照的两种表现、解除锁定后恢复,以及连续撤销互换保持启用;并断言处理期间按钮仍在可访问树中且为真实禁用态。 - 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + ## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果 - 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 @@ -6479,7 +6480,7 @@ - 并发与账号边界:同一 owner generation 同时只执行一个余额读取;读取期间的新变化通知在当前请求结束后补读,直到覆盖最后一次通知。切换或退出账号立即清空快照、提升 generation、中止并脱离旧 generation 的 active 请求,新 owner 不得等待旧 transport 收束;不响应 abort 的旧 transport 可以在后台结束,但其结果必须忽略。消费端在 owner 绑定 effect 提交前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,不能让旧余额与新账号身份同屏。充值中心读取或 mutation 响应必须携带请求开始时捕获的 owner,owner 不符时忽略。刷新失败保留已有快照;响应缺少 `mudPointBalance` 时进入错误状态,不用总额反推分桶。 - UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。 - 2026-08-05 审查补充:主站 transport adapter 必须通过既有请求 options 真实透传 `AbortSignal`;页面恢复时相邻的 `visibilitychange / focus` 合并为一次余额通知,并在卸载时清理待执行任务。Store 的 owner 输入统一在边界 trim,活动请求清理同时观察 Promise 成功与失败,不能用无人接收的 `finally` 派生 Promise。 -- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 +- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。充值下单、邀请码兑换和奖励码兑换三条写请求还必须共享账号生命周期 `AbortController`,在切号 effect cleanup 与卸载时中止旧 signal,使 `fetchWithApiAuth` 的 refresh 等待和写请求退避立即结束,禁止旧 POST 重试重新读取新账号 Token。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。 - 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,该字段不能生成分桶。直接余额快照附带单调 operation sequence;充值中心、支付确认和 watch 等异步响应都在请求发起前捕获快照,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。 - 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。 - 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b5159fae8..f5ce1bc21 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4332,6 +4332,7 @@ - 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。 - 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。 - 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。 + ## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05) - 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index e16195102..6e2c68477 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -66,7 +66,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 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` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。 -8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 +8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。 9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 ## 唯一后端路线 diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index e9cb49d95..0af9b7027 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -118,10 +118,7 @@ function normalizeAvailableLoginMethods( // 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。 return Array.from( - new Set([ - ...REQUIRED_LOGIN_METHODS, - ...normalizedMethods, - ]), + new Set([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]), ); } @@ -193,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) { } const markAuthStateReloadIfChanged = useCallback( - ( - nextUser: AuthUser | null, - options: { reloadOnChange?: boolean } = {}, - ) => { + (nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => { const nextHasUser = Boolean(nextUser); const previousHasUser = lastStableAuthPresenceRef.current; if (previousHasUser === null) { @@ -205,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) { } lastStableAuthPresenceRef.current = nextHasUser; - if ( - previousHasUser !== nextHasUser && - options.reloadOnChange !== false - ) { + if (previousHasUser !== nextHasUser && options.reloadOnChange !== false) { pendingAuthStateReloadRef.current = true; } }, [], ); - const activateReadyUser = useCallback((nextUser: AuthUser) => { - // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 - authHydrateVersionRef.current += 1; - markAuthStateReloadIfChanged(nextUser); - setUser(nextUser); - setStatus('ready'); - }, [markAuthStateReloadIfChanged]); + const activateReadyUser = useCallback( + (nextUser: AuthUser) => { + // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 + authHydrateVersionRef.current += 1; + markAuthStateReloadIfChanged(nextUser); + setUser(nextUser); + setStatus('ready'); + }, + [markAuthStateReloadIfChanged], + ); const clearLocalAuthenticatedState = useCallback( (options: { reloadOnChange?: boolean } = {}) => { @@ -975,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) { const registrationInviteCode = pendingInviteCode || readInviteCodeFromLocation(); const response = registrationInviteCode - ? await loginWithPhoneCode(phone, code, registrationInviteCode) + ? await loginWithPhoneCode( + phone, + code, + registrationInviteCode, + ) : await loginWithPhoneCode(phone, code); const autoRedeemedInvite = response.referral?.ok === true; setStoredLastLoginPhone(phone); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index b846c6b67..5b222cc95 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -174,12 +174,17 @@ describe('usePlatformProfileCenterController recharge fallback', () => { const { result, rerender } = renderController(userA); act(() => result.current.buyRechargeProduct(rechargeProduct)); + const oldAccountSignal = profileClientMocks + .createPlatformProfileRechargeOrder.mock.calls[0]?.[2] + ?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); expect(result.current.submittingRechargeProductId).toBe('points-60'); act(() => { rerender({ user: userB }); usePlatformWalletStore.getState().setWalletOwner('user-b'); }); + expect(oldAccountSignal.aborted).toBe(true); act(() => result.current.buyRechargeProduct(rechargeProduct)); await waitFor(() => { expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); @@ -200,6 +205,23 @@ describe('usePlatformProfileCenterController recharge fallback', () => { }); }); + test('aborts an in-flight recharge write when the controller unmounts', () => { + profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue( + new Promise(() => undefined), + ); + usePlatformWalletStore.getState().setWalletOwner('user-a'); + const { result, unmount } = renderController(userA); + + act(() => result.current.buyRechargeProduct(rechargeProduct)); + const signal = profileClientMocks.createPlatformProfileRechargeOrder.mock + .calls[0]?.[2]?.signal as AbortSignal; + expect(signal.aborted).toBe(false); + + unmount(); + + expect(signal.aborted).toBe(true); + }); + test('ignores an old owner native confirmation after the account changes', async () => { const pendingConfirmation = deferred<{ order: ReturnType['order']; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx index 8109a67bf..dbbb10029 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx @@ -34,8 +34,12 @@ describe('usePlatformProfileCenterController redemption lifecycle', () => { const { result, rerender } = renderController(userA, onRechargeSuccess); act(() => result.current.setRewardCodeInput('reward-code')); act(() => result.current.submitRewardCode()); + const oldAccountSignal = profileClientMocks.redeemPlatformProfileRewardCode + .mock.calls[0]?.[1]?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); rerender({ user: userB }); + expect(oldAccountSignal.aborted).toBe(true); await act(async () => { resolveReward({ walletBalance: 100, @@ -67,8 +71,13 @@ describe('usePlatformProfileCenterController redemption lifecycle', () => { const { result, rerender } = renderController(userA, onRechargeSuccess); act(() => result.current.setReferralRedeemCode('invite-code')); act(() => result.current.submitReferralRedeemCode()); + const oldAccountSignal = profileClientMocks + .redeemPlatformProfileReferralInviteCode.mock.calls[0]?.[1] + ?.signal as AbortSignal; + expect(oldAccountSignal.aborted).toBe(false); rerender({ user: userB }); + expect(oldAccountSignal.aborted).toBe(true); await act(async () => { resolveReferral({ center: undefined as never, diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 666797531..03a5f423d 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -61,7 +61,11 @@ type WechatPayResult = { }; type RechargePaymentResultKind = - 'success' | 'pending' | 'cancel' | 'failed' | 'expired'; + | 'success' + | 'pending' + | 'cancel' + | 'failed' + | 'expired'; export type RechargePaymentResult = { kind: RechargePaymentResultKind; @@ -425,6 +429,7 @@ export function usePlatformProfileCenterController({ const confirmingWechatRechargeOrderRef = useRef(null); const rechargeSubmissionRef = useRef(null); + const accountWriteAbortControllerRef = useRef(new AbortController()); const rechargeCenterReadRevisionRef = useRef(0); const accountLifecycleRevisionRef = useRef(0); const walletLedgerReadRevisionRef = useRef(0); @@ -522,6 +527,9 @@ export function usePlatformProfileCenterController({ : null; useEffect(() => { + accountWriteAbortControllerRef.current.abort(); + const accountWriteAbortController = new AbortController(); + accountWriteAbortControllerRef.current = accountWriteAbortController; rechargeCenterReadRevisionRef.current += 1; accountLifecycleRevisionRef.current += 1; walletLedgerReadRevisionRef.current += 1; @@ -562,6 +570,9 @@ export function usePlatformProfileCenterController({ setIsSubmittingReferralRedeem(false); setReferralError(null); setReferralSuccess(null); + return () => { + accountWriteAbortController.abort(); + }; }, [abortRechargeOrders, currentUserId]); const rechargeCenterReadAbortControllerRef = useRef( null, @@ -1027,6 +1038,11 @@ export function usePlatformProfileCenterController({ } const submissionToken = Symbol('profile-recharge-submission'); rechargeSubmissionRef.current = submissionToken; + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + rechargeSubmissionRef.current = null; + return; + } const walletSnapshot = captureWalletBalanceSnapshot(account.ownerUserId); let createdOrder: WechatRechargeOrderLifecycle | null = null; @@ -1039,7 +1055,13 @@ export function usePlatformProfileCenterController({ setRechargePaymentResult(null); setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); - void createPlatformProfileRechargeOrder(product.productId, paymentChannel) + void createPlatformProfileRechargeOrder( + product.productId, + paymentChannel, + { + signal: requestSignal, + }, + ) .then(async (response) => { if (!isAccountLifecycleCurrent(account)) { return; @@ -1709,7 +1731,14 @@ export function usePlatformProfileCenterController({ setReferralSuccess(null); const snapshotOwnerUserId = currentUserId; const accountRevision = accountLifecycleRevisionRef.current; - void redeemPlatformProfileReferralInviteCode(inviteCode) + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + setIsSubmittingReferralRedeem(false); + return; + } + void redeemPlatformProfileReferralInviteCode(inviteCode, { + signal: requestSignal, + }) .then((response) => { if ( accountRevision !== accountLifecycleRevisionRef.current || @@ -1760,7 +1789,14 @@ export function usePlatformProfileCenterController({ setRewardCodeSuccess(null); const snapshotOwnerUserId = currentUserId; const accountRevision = accountLifecycleRevisionRef.current; - void redeemPlatformProfileRewardCode(rewardCodeInput) + const requestSignal = accountWriteAbortControllerRef.current.signal; + if (requestSignal.aborted) { + setIsSubmittingRewardCode(false); + return; + } + void redeemPlatformProfileRewardCode(rewardCodeInput, { + signal: requestSignal, + }) .then((response: RedeemProfileRewardCodeResponse) => { if ( accountRevision !== accountLifecycleRevisionRef.current || diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 8bd1ba54a..2ef89976e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -662,6 +662,91 @@ describe('apiClient', () => { ); }); + it('does not retry an unsafe write with a new token after its account signal aborts', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 503 })) + .mockResolvedValueOnce(createResponseMock({ status: 200 })); + const abortController = new AbortController(); + + const request = requestJson( + '/api/profile/redeem-codes/redeem', + { + method: 'POST', + body: JSON.stringify({ code: 'REWARD01' }), + signal: abortController.signal, + }, + '兑换失败', + { + retry: { + maxRetries: 1, + baseDelayMs: 1000, + maxDelayMs: 1000, + retryUnsafeMethods: true, + }, + }, + ); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + setStoredAccessToken('account-b-token', { emit: false }); + abortController.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + (fetchMock.mock.calls[0]?.[1]?.headers as Record) + .Authorization, + ).toBe('Bearer account-a-token'); + }); + + it('does not replay a 401 unsafe write after its account signal aborts during refresh', async () => { + setStoredAccessToken('account-a-token', { emit: false }); + const refreshResponse = + createDeferred>(); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockImplementationOnce(() => refreshResponse.promise) + .mockResolvedValueOnce(createResponseMock({ status: 200 })); + const abortController = new AbortController(); + + const request = requestJson( + '/api/profile/redeem-codes/redeem', + { + method: 'POST', + body: JSON.stringify({ code: 'REWARD01' }), + signal: abortController.signal, + }, + '兑换失败', + ); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + setStoredAccessToken('account-b-token', { emit: false }); + abortController.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + refreshResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-refresh-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await refreshResponse.promise; + await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/profile/redeem-codes/redeem', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh'); + }); + it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( @@ -700,9 +785,8 @@ describe('apiClient', () => { it.each(['missing-token', 'unauthorized'] as const)( 'bounds the %s refresh wait with an absolute request deadline', async (refreshMode) => { - const refreshResponse = createDeferred< - ReturnType - >(); + const refreshResponse = + createDeferred>(); if (refreshMode === 'unauthorized') { setStoredAccessToken('expired-token', { emit: false }); fetchMock @@ -853,9 +937,13 @@ describe('apiClient', () => { ); await expect( - requestJson('/api/runtime/puzzle/agent/sessions/test/actions', { - method: 'POST', - }, '执行拼图操作失败。'), + requestJson( + '/api/runtime/puzzle/agent/sessions/test/actions', + { + method: 'POST', + }, + '执行拼图操作失败。', + ), ).rejects.toMatchObject({ message: '拼图图片生成失败:请求参数不合法', status: 400, @@ -895,9 +983,13 @@ describe('apiClient', () => { ); await expect( - requestJson('/api/runtime/puzzle/agent/sessions/test/actions', { - method: 'POST', - }, '执行拼图操作失败。'), + requestJson( + '/api/runtime/puzzle/agent/sessions/test/actions', + { + method: 'POST', + }, + '执行拼图操作失败。', + ), ).rejects.toMatchObject({ message: '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', diff --git a/src/services/platform-entry/platformProfileClient.test.ts b/src/services/platform-entry/platformProfileClient.test.ts index a4f2e24e7..f36b4a5fc 100644 --- a/src/services/platform-entry/platformProfileClient.test.ts +++ b/src/services/platform-entry/platformProfileClient.test.ts @@ -7,7 +7,12 @@ const apiClientMocks = vi.hoisted(() => ({ vi.mock('../apiClient', () => apiClientMocks); -import { getPlatformProfileRechargeCenter } from './platformProfileClient'; +import { + createPlatformProfileRechargeOrder, + getPlatformProfileRechargeCenter, + redeemPlatformProfileReferralInviteCode, + redeemPlatformProfileRewardCode, +} from './platformProfileClient'; describe('platformProfileClient', () => { beforeEach(() => { @@ -34,4 +39,35 @@ describe('platformProfileClient', () => { }), ); }); + + test.each([ + [ + '充值下单', + (signal: AbortSignal) => + createPlatformProfileRechargeOrder('points-60', 'wechat_native', { + signal, + }), + ], + [ + '邀请码兑换', + (signal: AbortSignal) => + redeemPlatformProfileReferralInviteCode('INVITE01', { signal }), + ], + [ + '奖励码兑换', + (signal: AbortSignal) => + redeemPlatformProfileRewardCode('REWARD01', { signal }), + ], + ])('%s write forwards its account abort signal', async (_label, request) => { + const abortController = new AbortController(); + + await request(abortController.signal); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + expect.stringMatching(/^\/api\/profile\//u), + expect.objectContaining({ signal: abortController.signal }), + expect.any(String), + expect.objectContaining({ retry: expect.any(Object) }), + ); + }); });