From 0baa8cf8ccc804e89945fa3816a0eac6ce79c510 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 16:31:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E4=B8=8E=E5=8F=91=E5=B8=83=E5=AE=89=E5=85=A8=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐退款人工复核、刷新恢复、会话隔离和旧冷备导入兼容 强化真实微信退款对账开关、重复环境变量和生产发布门禁 优化公开作品资产授权查询并保持实时撤销语义 统一后台微信渠道、退款追回流水和画布正式资源测试契约 补齐生成与素材侧栏回归测试 --- apps/admin-web/src/api/adminApiClient.test.ts | 26 + apps/admin-web/src/api/adminApiClient.ts | 11 + apps/admin-web/src/api/adminApiTypes.ts | 8 + .../src/pages/AdminRechargeOrderPage.test.tsx | 323 ++++++++- .../src/pages/AdminRechargeOrderPage.tsx | 506 ++++++++++++-- deploy/env/api-server.env.example | 2 + .../shared-memory/decision-log.md | 5 +- docs/project-memory/shared-memory/pitfalls.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 14 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + packages/shared/src/contracts/runtime.ts | 1 + scripts/check-production-api-deploy.mjs | 49 ++ scripts/check-production-ops-guardrails.mjs | 10 + scripts/deploy/production-api-deploy.sh | 22 +- .../crates/api-server/src/admin_recharge.rs | 50 +- .../crates/api-server/src/modules/admin.rs | 8 +- .../profile_recharge_refund_reconciliation.rs | 11 +- .../crates/module-runtime/src/commands.rs | 40 ++ server-rs/crates/module-runtime/src/domain.rs | 11 + .../crates/shared-contracts/src/admin.rs | 26 +- .../src/mapper/runtime_profile.rs | 15 + .../spacetime-client/src/module_bindings.rs | 4 + .../profile_recharge_refund_type.rs | 21 + ...fund_manual_review_and_return_procedure.rs | 62 ++ ...refund_manual_review_resolve_input_type.rs | 17 + ...e_profile_recharge_refund_snapshot_type.rs | 3 + .../crates/spacetime-client/src/runtime.rs | 32 + .../crates/spacetime-module/src/migration.rs | 64 ++ .../src/public_asset_access.rs | 622 ++++++++++++++---- .../spacetime-module/src/runtime/profile.rs | 172 ++++- ...mageCanvasEditorAssetsIntegration.test.tsx | 8 +- ...CanvasEditorGenerationIntegration.test.tsx | 80 ++- .../rpgEntryProfileFundsViewModel.test.ts | 22 +- .../rpgEntryProfileFundsViewModel.ts | 1 + 34 files changed, 2047 insertions(+), 205 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_profile_recharge_refund_manual_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_manual_review_resolve_input_type.rs diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 9ec75345e..b2127d332 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -4,6 +4,7 @@ import { executeAdminRechargeRefund, getAdminUserDetail, listAdminRechargeOrders, + resolveAdminRechargeRefundManualReview, } from './adminApiClient'; afterEach(() => { @@ -89,3 +90,28 @@ test('退款执行使用独立 execute 管理员路由', async () => { }), ); }); + +test('退款人工复核使用独立 resolve 管理员路由', async () => { + 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: '已核对微信商户平台原始账单', + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + '/admin/api/profile/recharge-refunds/manual-review/resolve', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + outRefundNo: 'refund-1', + reason: '已核对微信商户平台原始账单', + }), + }), + ); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9f50ff9c0..89a9bf649 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,6 +30,7 @@ import type { AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest, + AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest, @@ -641,6 +642,16 @@ export function registerAdminRechargeRefund( ); } +export function resolveAdminRechargeRefundManualReview( + token: string, + payload: AdminRechargeRefundManualReviewResolveRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/manual-review/resolve', + {method: 'POST', token, body: payload}, + ); +} + export function updateAdminWalletRestriction( token: string, payload: AdminWalletRestrictionRequest, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index b26fc064a..1afe7f04f 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -797,6 +797,9 @@ export interface AdminRechargeRefundPayload { unrecoveredPoints: number; recoveryStatus: string; lastErrorCode?: string | null; + manualReviewResolvedByAdminUserId?: string | null; + manualReviewResolutionReason?: string | null; + manualReviewResolvedAtMicros?: number | null; } export interface AdminRechargeRefundHoldPayload { @@ -876,6 +879,11 @@ export interface AdminRechargeRefundRegisterRequest { outRefundNo: string; } +export interface AdminRechargeRefundManualReviewResolveRequest { + outRefundNo: string; + reason: string; +} + export interface AdminWalletRestrictionRequest { userId: string; frozen: boolean; diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 83470581f..92224d082 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -12,13 +12,16 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { executeAdminRechargeRefund, + isAdminApiError, listAdminRechargeOrders, previewAdminRechargeRefund, registerAdminRechargeRefund, + resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { AdminRechargeOrderEntryPayload, AdminRechargeRefundActionResponse, + AdminRechargeRefundPayload, AdminRechargeRefundPreviewResponse, } from '../api/adminApiTypes'; import { AdminRechargeOrderPage } from './AdminRechargeOrderPage'; @@ -32,6 +35,7 @@ vi.mock('../api/adminApiClient', () => ({ listAdminRechargeOrders: vi.fn(), previewAdminRechargeRefund: vi.fn(), registerAdminRechargeRefund: vi.fn(), + resolveAdminRechargeRefundManualReview: vi.fn(), })); vi.mock('../components/AdminUserReferenceButton', () => ({ @@ -110,6 +114,8 @@ const allowedPreview: AdminRechargeRefundPreviewResponse = { beforeEach(() => { vi.clearAllMocks(); + sessionStorage.clear(); + vi.mocked(isAdminApiError).mockReturnValue(false); vi.mocked(listAdminRechargeOrders).mockResolvedValue({ entries: [baseOrder], }); @@ -118,6 +124,9 @@ beforeEach(() => { vi.mocked(registerAdminRechargeRefund).mockResolvedValue( actionFor(baseOrder), ); + vi.mocked(resolveAdminRechargeRefundManualReview).mockResolvedValue( + actionFor(baseOrder), + ); }); test('充值订单查询传递全部筛选字段', async () => { @@ -161,6 +170,46 @@ test('充值订单查询传递全部筛选字段', async () => { }); }); +test.each([ + ['wechat_mp', '微信小程序'], + ['wechat_mp_virtual', '微信小程序虚拟支付'], +])('充值订单可按真实微信渠道 %s 查询', async (paymentChannel) => { + const user = userEvent.setup(); + renderPage(); + await screen.findByText('order-1001'); + + await user.selectOptions(screen.getByLabelText('支付渠道'), paymentChannel); + await user.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => { + expect(listAdminRechargeOrders).toHaveBeenLastCalledWith( + 'admin-token', + expect.objectContaining({ paymentChannel }), + ); + }); +}); + +test('充值订单展示两种微信小程序渠道中文名称', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { ...baseOrder, orderId: 'order-mp', paymentChannel: 'wechat_mp' }, + { + ...baseOrder, + orderId: 'order-mp-virtual', + paymentChannel: 'wechat_mp_virtual', + }, + ], + }); + renderPage(); + + const mpRow = (await screen.findByText('order-mp')).closest('tr'); + const virtualRow = screen.getByText('order-mp-virtual').closest('tr'); + expect(mpRow && within(mpRow).getByText('微信小程序')).toBeTruthy(); + expect( + virtualRow && within(virtualRow).getByText('微信小程序虚拟支付'), + ).toBeTruthy(); +}); + test('修改部分退款金额会作废已有预检', async () => { const user = userEvent.setup(); renderPage(); @@ -252,6 +301,233 @@ test('网络结果不明后重新预检会复用稳定 requestId 且双击只提 ).toBe(firstRequestId); }); +test('刷新后根据 active hold 恢复原退款请求并复用 requestId', async () => { + const user = userEvent.setup(); + 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('退款原因'), '用户申请'); + 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; + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByLabelText('退款金额(元)')).toHaveProperty( + 'value', + '3.00', + ); + expect(screen.getByLabelText('退款原因')).toHaveProperty('value', '用户申请'); + expect( + screen + .getByRole('button', { name: '核验支付账单' }) + .hasAttribute('disabled'), + ).toBe(true); + await user.click(screen.getByRole('button', { name: '继续核对退款' })); + + await waitFor(() => + expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2), + ); + expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + orderId: 'order-1001', + refundAmountCents: 300, + reason: '用户申请', + requestId: firstRequestId, + }), + ); + expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1); +}); + +test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => { + const user = userEvent.setup(); + vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( + new Error('network disconnected'), + ); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + + const storageKey = sessionStorage.key(0); + expect(storageKey).toBeTruthy(); + const contexts = JSON.parse( + sessionStorage.getItem(storageKey ?? '') ?? '{}', + ) as Record; + const persistedContext = contexts['order-1001']; + expect(persistedContext).toBeTruthy(); + if (!persistedContext) { + throw new Error('退款请求上下文未写入 sessionStorage'); + } + persistedContext.createdAtMillis = Date.now() - 3 * 60 * 60 * 1_000; + sessionStorage.setItem(storageKey ?? '', JSON.stringify(contexts)); + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '退款处理' })).toBeNull(); +}); + +test('切换管理员会话后不会复用上一会话的退款 requestId', async () => { + const user = userEvent.setup(); + vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( + new Error('network disconnected'), + ); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage('other-admin-token'); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy(); + expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1); +}); + +test('服务端明确拒绝退款后清理恢复上下文', async () => { + const user = userEvent.setup(); + vi.mocked(isAdminApiError).mockReturnValue(true); + vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( + new Error('退款前置条件不满足'), + ); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + expect(await screen.findByText('退款前置条件不满足')).toBeTruthy(); + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy(); +}); + +test('active hold 缺少原 requestId 时只开放预填退款单号的查单登记', async () => { + const user = userEvent.setup(); + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' }); + expect( + within(dialog).getByRole('textbox', { + name: '商户退款单号 out_refund_no', + }), + ).toHaveProperty('value', 'refund-1001'); + expect(screen.queryByRole('dialog', { name: '退款处理' })).toBeNull(); + expect(executeAdminRechargeRefund).not.toHaveBeenCalled(); + + await user.click(within(dialog).getByRole('button', { name: '查询并登记' })); + await waitFor(() => { + expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', { + outRefundNo: 'refund-1001', + }); + }); +}); + +test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( + '允许处理可确认的退款人工复核错误 %s', + async (lastErrorCode) => { + const user = userEvent.setup(); + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + refunds: [manualReviewRefund({ lastErrorCode })], + }, + ], + }); + renderPage(); + + await user.click(await screen.findByRole('button', { name: '人工复核' })); + const dialog = screen.getByRole('dialog', { name: '确认退款人工复核' }); + const submitButton = within(dialog).getByRole('button', { + name: '确认并追回', + }); + expect(submitButton.hasAttribute('disabled')).toBe(true); + await user.type( + within(dialog).getByRole('textbox', { name: '人工复核原因' }), + '已核对微信商户平台原始账单', + ); + await user.click(submitButton); + + await waitFor(() => { + expect(resolveAdminRechargeRefundManualReview).toHaveBeenCalledWith( + 'admin-token', + { + outRefundNo: 'refund-manual-1', + reason: '已核对微信商户平台原始账单', + }, + ); + }); + }, +); + +test('非白名单、已处理和会员退款不显示人工复核按钮', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-non-whitelist', + refunds: [manualReviewRefund({ lastErrorCode: 'other_error' })], + }, + { + ...baseOrder, + orderId: 'order-resolved', + refunds: [ + manualReviewRefund({ manualReviewResolvedAtMicros: 1_720_000 }), + ], + }, + { + ...baseOrder, + orderId: 'order-membership', + productKind: 'membership', + refunds: [manualReviewRefund()], + }, + ], + }); + renderPage(); + + await screen.findByText('order-membership'); + expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull(); +}); + test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => { const user = userEvent.setup(); const debtOrder = { @@ -321,9 +597,9 @@ test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断 }); }); -function renderPage() { +function renderPage(token = 'admin-token') { return render( - , + , ); } @@ -344,3 +620,46 @@ function actionFor( order, }; } + +function orderWithActiveHold(): AdminRechargeOrderEntryPayload { + return { + ...baseOrder, + activeHold: { + outRefundNo: 'refund-1001', + refundCents: 300, + heldPoints: 30, + status: 'pending', + adminUserId: 'admin-1', + reason: '用户申请', + createdAtMicros: 1_720_000_000_000_000, + updatedAtMicros: 1_720_000_000_000_000, + }, + refundEligible: false, + refundBlockReasonCode: 'refund_in_progress', + }; +} + +function manualReviewRefund( + overrides: Partial = {}, +): AdminRechargeRefundPayload { + return { + outRefundNo: 'refund-manual-1', + providerRefundId: 'provider-refund-1', + providerStatus: 'SUCCESS', + refundCents: 300, + payerRefundCents: 300, + successAtMicros: 1_720_000_000_000_000, + firstObservedAtMicros: 1_720_000_000_000_000, + updatedAtMicros: 1_720_000_000_000_000, + observationSource: 'wechat_query', + targetRecoveryPoints: 30, + recoveredPoints: 0, + unrecoveredPoints: 0, + recoveryStatus: 'manual_review', + lastErrorCode: 'provider_transaction_id_mismatch', + manualReviewResolvedByAdminUserId: null, + manualReviewResolutionReason: null, + manualReviewResolvedAtMicros: null, + ...overrides, + }; +} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 8d272b8a0..5ad5a1a25 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, + FileCheck2, FileInput, RefreshCcw, RotateCcw, @@ -16,11 +17,13 @@ import { listAdminRechargeOrders, previewAdminRechargeRefund, registerAdminRechargeRefund, + resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeRefundActionResponse, + AdminRechargeRefundPayload, AdminRechargeRefundPreviewResponse, } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; @@ -46,6 +49,24 @@ interface RechargeFilters { type RefundMode = 'remaining' | 'partial'; +interface PersistedRefundRequestContext { + orderId: string; + amountCents: number; + requestId: string; + reason: string; + outRefundNo?: string; + createdAtMillis: number; +} + +interface ManualReviewTarget { + orderId: string; + refund: AdminRechargeRefundPayload; +} + +const REFUND_REQUEST_CONTEXT_STORAGE_KEY = + 'genarrative.admin.recharge-refund-contexts.v2'; +const REFUND_REQUEST_CONTEXT_TTL_MILLIS = 2 * 60 * 60 * 1_000; + const defaultFilters: RechargeFilters = { orderId: '', providerTransactionId: '', @@ -83,15 +104,18 @@ export function AdminRechargeOrderPage({ const [outRefundNo, setOutRefundNo] = useState(''); const [registerError, setRegisterError] = useState(''); const [isRegistering, setIsRegistering] = useState(false); + const [refundRequestId, setRefundRequestId] = useState(''); + const [resumableRefundRequest, setResumableRefundRequest] = + useState(null); + const [manualReviewTarget, setManualReviewTarget] = + useState(null); + const [manualReviewReason, setManualReviewReason] = useState(''); + const [manualReviewError, setManualReviewError] = useState(''); + const [isResolvingManualReview, setIsResolvingManualReview] = useState(false); const lastQueryRef = useRef({ limit: 100 }); - const requestIdRef = useRef(''); - const uncertainRequestRef = useRef<{ - orderId: string; - amountCents: number; - requestId: string; - } | null>(null); const submitLockRef = useRef(false); const registerLockRef = useRef(false); + const manualReviewLockRef = useRef(false); const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); const visibleDebtOrder = findDebtOrder(lastAction?.order, orders); @@ -148,23 +172,32 @@ export function AdminRechargeOrderPage({ } function openRefund(order: AdminRechargeOrderEntryPayload) { - const amountCents = order.remainingRefundableCents; - const uncertain = uncertainRequestRef.current; + const persistedContext = findPersistedRefundRequestContext(order, token); + if (order.activeHold && !persistedContext) { + setOutRefundNo(order.activeHold.outRefundNo); + setRegisterError(''); + setRegisterOpen(true); + return; + } + + const amountCents = + order.activeHold?.refundCents ?? + persistedContext?.amountCents ?? + order.remainingRefundableCents; + const hasUncertainRequest = Boolean(order.activeHold || persistedContext); setRefundOrder(order); - setRefundMode('remaining'); + setRefundMode( + amountCents === order.remainingRefundableCents ? 'remaining' : 'partial', + ); setRefundAmountYuan(formatCentsInput(amountCents)); - setRefundReason(''); + setRefundReason(order.activeHold?.reason ?? persistedContext?.reason ?? ''); setPreview(null); setRefundError(''); - setProviderStatusUnknown( - uncertain?.orderId === order.orderId && - uncertain.amountCents === amountCents, + setProviderStatusUnknown(hasUncertainRequest); + setRefundRequestId(persistedContext?.requestId ?? createRequestId()); + setResumableRefundRequest( + order.activeHold && persistedContext ? persistedContext : null, ); - requestIdRef.current = - uncertain?.orderId === order.orderId && - uncertain.amountCents === amountCents - ? uncertain.requestId - : createRequestId(); } function closeRefund() { @@ -174,14 +207,19 @@ export function AdminRechargeOrderPage({ setRefundOrder(null); setPreview(null); setRefundError(''); + setResumableRefundRequest(null); } function updateRefundAmount(nextValue: string) { + if (refundOrder) { + removePersistedRefundRequestContext(token, refundOrder.orderId); + } setRefundAmountYuan(nextValue); setPreview(null); setRefundError(''); setProviderStatusUnknown(false); - requestIdRef.current = createRequestId(); + setRefundRequestId(createRequestId()); + setResumableRefundRequest(null); } function changeRefundMode(nextMode: RefundMode) { @@ -233,7 +271,7 @@ export function AdminRechargeOrderPage({ async function handleExecuteRefund() { if ( !refundOrder || - !preview?.canSubmit || + (!preview?.canSubmit && !resumableRefundRequest) || submitLockRef.current || isSubmitting ) { @@ -244,9 +282,14 @@ export function AdminRechargeOrderPage({ setRefundError('请填写退款原因'); return; } - const amountCents = preview.refundAmountCents; - const requestId = requestIdRef.current || createRequestId(); - requestIdRef.current = requestId; + const amountCents = + resumableRefundRequest?.amountCents ?? preview?.refundAmountCents; + if (!amountCents) { + return; + } + const requestId = + resumableRefundRequest?.requestId || refundRequestId || createRequestId(); + setRefundRequestId(requestId); const confirmed = await confirmWrite({ action: '发起微信退款并追回泥点', target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`, @@ -258,11 +301,15 @@ export function AdminRechargeOrderPage({ submitLockRef.current = true; setIsSubmitting(true); setRefundError(''); - uncertainRequestRef.current = { + const requestContext: PersistedRefundRequestContext = { orderId: refundOrder.orderId, amountCents, requestId, + reason, + outRefundNo: refundOrder.activeHold?.outRefundNo, + createdAtMillis: resumableRefundRequest?.createdAtMillis ?? Date.now(), }; + persistRefundRequestContext(token, requestContext); try { const response = await executeAdminRechargeRefund(token, { orderId: refundOrder.orderId, @@ -275,11 +322,24 @@ export function AdminRechargeOrderPage({ upsertOrder(response.order); setPreview(null); if (response.providerStatusUnknown) { + const pendingRequestContext = { + ...requestContext, + outRefundNo: response.outRefundNo, + }; + persistRefundRequestContext(token, pendingRequestContext); + setResumableRefundRequest( + response.order.activeHold ? pendingRequestContext : null, + ); setProviderStatusUnknown(true); setRefundError(unknownProviderStatusMessage); } else { - uncertainRequestRef.current = null; + removePersistedRefundRequestContext( + token, + refundOrder.orderId, + response.outRefundNo, + ); setProviderStatusUnknown(false); + setResumableRefundRequest(null); } } catch (error: unknown) { if (!isAdminApiError(error)) { @@ -287,6 +347,8 @@ export function AdminRechargeOrderPage({ setPreview(null); setRefundError(unknownProviderStatusMessage); } else { + removePersistedRefundRequestContext(token, refundOrder.orderId); + setResumableRefundRequest(null); handleDialogError(error, setRefundError); } } finally { @@ -321,6 +383,11 @@ export function AdminRechargeOrderPage({ }); setLastAction(response); upsertOrder(response.order); + removePersistedRefundRequestContext( + token, + response.order.orderId, + normalizedOutRefundNo, + ); setOutRefundNo(''); setRegisterOpen(false); } catch (error: unknown) { @@ -331,6 +398,52 @@ export function AdminRechargeOrderPage({ } } + function openManualReview( + order: AdminRechargeOrderEntryPayload, + refund: AdminRechargeRefundPayload, + ) { + setManualReviewTarget({ orderId: order.orderId, refund }); + setManualReviewReason(''); + setManualReviewError(''); + } + + async function handleResolveManualReview(event: FormEvent) { + event.preventDefault(); + const reason = manualReviewReason.trim(); + if (!manualReviewTarget || manualReviewLockRef.current || !reason) { + if (!reason) { + setManualReviewError('请填写人工复核原因'); + } + return; + } + const confirmed = await confirmWrite({ + action: '确认微信退款并追回泥点', + target: `${manualReviewTarget.orderId} / ${manualReviewTarget.refund.outRefundNo}`, + }); + if (!confirmed || manualReviewLockRef.current) { + return; + } + + manualReviewLockRef.current = true; + setIsResolvingManualReview(true); + setManualReviewError(''); + try { + const response = await resolveAdminRechargeRefundManualReview(token, { + outRefundNo: manualReviewTarget.refund.outRefundNo, + reason, + }); + setLastAction(response); + upsertOrder(response.order); + setManualReviewTarget(null); + setManualReviewReason(''); + } catch (error: unknown) { + handleDialogError(error, setManualReviewError); + } finally { + manualReviewLockRef.current = false; + setIsResolvingManualReview(false); + } + } + function handleDialogError( error: unknown, setMessage: (message: string) => void, @@ -446,7 +559,8 @@ export function AdminRechargeOrderPage({ - + +