From a597fcd076e5fccdc5df53a8ddb5246c0835e162 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 19:20:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E9=80=80=E6=AC=BE=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E5=A4=8D=E6=A0=B8=E4=B8=8E=E9=87=8D=E8=AF=95=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐退款事实对比字段与人工复核审计展示 人工复核重放严格校验管理员、原因和错误码 退款占用重试完整匹配管理员与归一化原因 统一退款原因持久化上限并修复刷新恢复 补充前后端回归测试、生成绑定与数据契约文档 --- apps/admin-web/src/api/adminApiClient.test.ts | 38 ++++-- apps/admin-web/src/api/adminApiTypes.ts | 4 + .../src/pages/AdminRechargeOrderPage.test.tsx | 99 +++++++++++++- .../src/pages/AdminRechargeOrderPage.tsx | 93 ++++++++++++- .../shared-memory/decision-log.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- .../crates/api-server/src/admin_recharge.rs | 122 +++++++++++++++++- .../profile_recharge_refund_reconciliation.rs | 1 + .../crates/module-runtime/src/commands.rs | 27 ++++ server-rs/crates/module-runtime/src/domain.rs | 2 + .../crates/shared-contracts/src/admin.rs | 13 +- .../src/mapper/runtime_profile.rs | 2 + ...refund_manual_review_resolve_input_type.rs | 1 + ...e_profile_recharge_refund_snapshot_type.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 101 +++++++++++++++ 15 files changed, 480 insertions(+), 36 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index b2127d332..a673e0e01 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -1,4 +1,4 @@ -import {afterEach, expect, test, vi} from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import { executeAdminRechargeRefund, @@ -13,9 +13,9 @@ afterEach(() => { test('充值订单查询按后台契约序列化筛选参数', async () => { const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({entries: []}), { + new Response(JSON.stringify({ entries: [] }), { status: 200, - headers: {'content-type': 'application/json'}, + headers: { 'content-type': 'application/json' }, }), ); vi.stubGlobal('fetch', fetchMock); @@ -47,12 +47,14 @@ test('充值订单查询按后台契约序列化筛选参数', async () => { }); test('用户详情只发送实际提供的用户定位字段', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({userId: 'user-1'}), {status: 200}), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }), + ); vi.stubGlobal('fetch', fetchMock); - await getAdminUserDetail('token-1', {publicUserCode: 'TN1001'}); + await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' }); const requestUrl = String(fetchMock.mock.calls[0]?.[0]); const parsed = new URL(requestUrl, 'http://admin.local'); @@ -63,9 +65,13 @@ test('用户详情只发送实际提供的用户定位字段', async () => { }); test('退款执行使用独立 execute 管理员路由', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await executeAdminRechargeRefund('token-1', { @@ -92,14 +98,19 @@ test('退款执行使用独立 execute 管理员路由', async () => { }); test('退款人工复核使用独立 resolve 管理员路由', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await resolveAdminRechargeRefundManualReview('token-1', { outRefundNo: 'refund-1', reason: '已核对微信商户平台原始账单', + expectedErrorCode: 'provider_transaction_id_mismatch', }); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( @@ -111,6 +122,7 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => { body: JSON.stringify({ outRefundNo: 'refund-1', reason: '已核对微信商户平台原始账单', + expectedErrorCode: 'provider_transaction_id_mismatch', }), }), ); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 1afe7f04f..31afc2d78 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -785,7 +785,9 @@ export interface AdminProfileWalletPayload { export interface AdminRechargeRefundPayload { outRefundNo: string; providerRefundId: string; + providerTransactionId: string; providerStatus: string; + totalCents: number; refundCents: number; payerRefundCents: number; successAtMicros?: number | null; @@ -800,6 +802,7 @@ export interface AdminRechargeRefundPayload { manualReviewResolvedByAdminUserId?: string | null; manualReviewResolutionReason?: string | null; manualReviewResolvedAtMicros?: number | null; + manualReviewResolvedErrorCode?: string | null; } export interface AdminRechargeRefundHoldPayload { @@ -882,6 +885,7 @@ export interface AdminRechargeRefundRegisterRequest { export interface AdminRechargeRefundManualReviewResolveRequest { outRefundNo: string; reason: string; + expectedErrorCode: string; } export interface AdminWalletRestrictionRequest { diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 450949062..81e4fcbde 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -355,6 +355,56 @@ test('刷新后根据 active hold 恢复原退款请求并复用 requestId', asy expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1); }); +test('超长退款原因按服务端上限持久化并可在刷新后恢复', async () => { + const user = userEvent.setup(); + const normalizedReason = '退'.repeat(80); + vi.mocked(executeAdminRechargeRefund) + .mockRejectedValueOnce(new Error('network disconnected')) + .mockResolvedValueOnce(actionFor(baseOrder)); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), `${normalizedReason}多`); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock + .calls[0]?.[1].requestId; + expect(vi.mocked(executeAdminRechargeRefund).mock.calls[0]?.[1].reason).toBe( + normalizedReason, + ); + firstRender.unmount(); + + const activeHoldOrder = orderWithActiveHold(); + activeHoldOrder.activeHold = { + ...activeHoldOrder.activeHold!, + reason: normalizedReason, + }; + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [activeHoldOrder], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '退款处理' })).toBeTruthy(); + expect(screen.getByLabelText('退款原因')).toHaveProperty( + 'value', + normalizedReason, + ); + await user.click(screen.getByRole('button', { name: '继续核对退款' })); + + await waitFor(() => + expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2), + ); + expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + reason: normalizedReason, + requestId: firstRequestId, + }), + ); +}); + test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => { const user = userEvent.setup(); vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( @@ -474,7 +524,16 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( entries: [ { ...baseOrder, - refunds: [manualReviewRefund({ lastErrorCode })], + refunds: [ + manualReviewRefund({ + lastErrorCode, + providerTransactionId: + lastErrorCode === 'provider_transaction_id_mismatch' + ? 'wx-transaction-other' + : 'wx-transaction-1', + totalCents: lastErrorCode === 'order_total_mismatch' ? 700 : 600, + }), + ], }, ], }); @@ -485,6 +544,29 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( const submitButton = within(dialog).getByRole('button', { name: '确认并追回', }); + expect( + within(dialog).getByText( + lastErrorCode === 'provider_transaction_id_mismatch' + ? '微信交易单号不一致' + : '订单总额不一致', + ), + ).toBeTruthy(); + expect( + within(dialog).getByText('本地微信交易单号').parentElement?.textContent, + ).toContain('wx-transaction-1'); + expect( + within(dialog).getByText('退款微信交易单号').parentElement?.textContent, + ).toContain( + lastErrorCode === 'provider_transaction_id_mismatch' + ? 'wx-transaction-other' + : 'wx-transaction-1', + ); + expect( + within(dialog).getByText('本地订单总额').parentElement?.textContent, + ).toContain('¥6.00'); + expect( + within(dialog).getByText('退款订单总额').parentElement?.textContent, + ).toContain(lastErrorCode === 'order_total_mismatch' ? '¥7.00' : '¥6.00'); expect(submitButton.hasAttribute('disabled')).toBe(true); await user.type( within(dialog).getByRole('textbox', { name: '人工复核原因' }), @@ -498,6 +580,7 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( { outRefundNo: 'refund-manual-1', reason: '已核对微信商户平台原始账单', + expectedErrorCode: lastErrorCode, }, ); }); @@ -516,7 +599,12 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async ...baseOrder, orderId: 'order-resolved', refunds: [ - manualReviewRefund({ manualReviewResolvedAtMicros: 1_720_000 }), + manualReviewRefund({ + manualReviewResolvedAtMicros: 1_720_000, + manualReviewResolvedByAdminUserId: 'admin-1', + manualReviewResolutionReason: '已核对商户平台', + manualReviewResolvedErrorCode: 'provider_transaction_id_mismatch', + }), ], }, { @@ -531,6 +619,10 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async await screen.findByText('order-membership'); expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull(); + expect(screen.getByText(/复核 微信交易单号不一致/).textContent).toContain( + 'admin-1', + ); + expect(screen.getByText(/已核对商户平台/)).toBeTruthy(); }); test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => { @@ -650,7 +742,9 @@ function manualReviewRefund( return { outRefundNo: 'refund-manual-1', providerRefundId: 'provider-refund-1', + providerTransactionId: 'wx-transaction-other', providerStatus: 'SUCCESS', + totalCents: 600, refundCents: 300, payerRefundCents: 300, successAtMicros: 1_720_000_000_000_000, @@ -665,6 +759,7 @@ function manualReviewRefund( manualReviewResolvedByAdminUserId: null, manualReviewResolutionReason: null, manualReviewResolvedAtMicros: null, + manualReviewResolvedErrorCode: null, ...overrides, }; } diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 26a5f32d7..d1f4848b0 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -59,7 +59,7 @@ interface PersistedRefundRequestContext { } interface ManualReviewTarget { - orderId: string; + order: AdminRechargeOrderEntryPayload; refund: AdminRechargeRefundPayload; } @@ -277,7 +277,7 @@ export function AdminRechargeOrderPage({ ) { return; } - const reason = refundReason.trim(); + const reason = normalizeRefundReason(refundReason); if (!reason) { setRefundError('请填写退款原因'); return; @@ -403,7 +403,7 @@ export function AdminRechargeOrderPage({ order: AdminRechargeOrderEntryPayload, refund: AdminRechargeRefundPayload, ) { - setManualReviewTarget({ orderId: order.orderId, refund }); + setManualReviewTarget({ order, refund }); setManualReviewReason(''); setManualReviewError(''); } @@ -411,15 +411,23 @@ export function AdminRechargeOrderPage({ async function handleResolveManualReview(event: FormEvent) { event.preventDefault(); const reason = manualReviewReason.trim(); - if (!manualReviewTarget || manualReviewLockRef.current || !reason) { + const expectedErrorCode = manualReviewTarget?.refund.lastErrorCode?.trim(); + if ( + !manualReviewTarget || + manualReviewLockRef.current || + !reason || + !expectedErrorCode + ) { if (!reason) { setManualReviewError('请填写人工复核原因'); + } else if (!expectedErrorCode) { + setManualReviewError('退款人工复核状态已变化,请刷新后重试'); } return; } const confirmed = await confirmWrite({ action: '确认微信退款并追回泥点', - target: `${manualReviewTarget.orderId} / ${manualReviewTarget.refund.outRefundNo}`, + target: `${manualReviewTarget.order.orderId} / ${manualReviewTarget.refund.outRefundNo}`, }); if (!confirmed || manualReviewLockRef.current) { return; @@ -432,6 +440,7 @@ export function AdminRechargeOrderPage({ const response = await resolveAdminRechargeRefundManualReview(token, { outRefundNo: manualReviewTarget.refund.outRefundNo, reason, + expectedErrorCode, }); setLastAction(response); upsertOrder(response.order); @@ -773,6 +782,7 @@ export function AdminRechargeOrderPage({ setRefundReason(event.target.value)} /> @@ -967,6 +977,36 @@ export function AdminRechargeOrderPage({