收紧退款人工复核与重试契约

补齐退款事实对比字段与人工复核审计展示
人工复核重放严格校验管理员、原因和错误码
退款占用重试完整匹配管理员与归一化原因
统一退款原因持久化上限并修复刷新恢复
补充前后端回归测试、生成绑定与数据契约文档
This commit is contained in:
2026-07-14 19:20:25 +08:00
parent c70a33d682
commit a597fcd076
15 changed files with 480 additions and 36 deletions
+25 -13
View File
@@ -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',
}),
}),
);
+4
View File
@@ -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 {
@@ -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,
};
}
@@ -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<HTMLFormElement>) {
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({
<input
aria-label="退款原因"
disabled={isSubmitting || Boolean(resumableRefundRequest)}
maxLength={80}
value={refundReason}
onChange={(event) => setRefundReason(event.target.value)}
/>
@@ -967,6 +977,36 @@ export function AdminRechargeOrderPage({
<X size={18} aria-hidden="true" />
</button>
</div>
<dl className="admin-info-list" aria-label="退款冲突核对信息">
<div>
<dt>冲突类型</dt>
<dd>
{formatManualReviewErrorCode(
manualReviewTarget.refund.lastErrorCode,
)}
</dd>
</div>
<div>
<dt>本地微信交易单号</dt>
<dd>{manualReviewTarget.order.providerTransactionId || '-'}</dd>
</div>
<div>
<dt>退款微信交易单号</dt>
<dd>{manualReviewTarget.refund.providerTransactionId}</dd>
</div>
<div>
<dt>本地订单总额</dt>
<dd>{formatMoney(manualReviewTarget.order.amountCents)}</dd>
</div>
<div>
<dt>退款订单总额</dt>
<dd>{formatMoney(manualReviewTarget.refund.totalCents)}</dd>
</div>
<div>
<dt>本次退款金额</dt>
<dd>{formatMoney(manualReviewTarget.refund.refundCents)}</dd>
</div>
</dl>
<label className="admin-field">
<span>人工复核原因</span>
<textarea
@@ -1048,6 +1088,7 @@ function RechargeOrderRow({
}) {
const userName = order.user?.displayName || '未读取用户资料';
const manualReviewRefund = findResolvableManualReviewRefund(order);
const resolvedManualReviewRefund = findResolvedManualReviewRefund(order);
return (
<tr>
<td>
@@ -1089,6 +1130,25 @@ function RechargeOrderRow({
{order.activeHold ? (
<small>占用 {order.activeHold.heldPoints} 泥点</small>
) : null}
{resolvedManualReviewRefund ? (
<>
<small>
复核{' '}
{formatManualReviewErrorCode(
resolvedManualReviewRefund.manualReviewResolvedErrorCode,
)}{' '}
/{' '}
{resolvedManualReviewRefund.manualReviewResolvedByAdminUserId ||
'-'}
</small>
<small>
{resolvedManualReviewRefund.manualReviewResolutionReason || '-'} /{' '}
{formatMicros(
resolvedManualReviewRefund.manualReviewResolvedAtMicros ?? 0,
)}
</small>
</>
) : null}
</td>
<td>
<span>可消费 {order.wallet.spendableBalance}</span>
@@ -1322,6 +1382,19 @@ function formatRefundBlockReason(code?: string | null) {
return labels[code.toLowerCase()] ?? `当前不可退款:${code}`;
}
function formatManualReviewErrorCode(code?: string | null) {
const normalized = code?.trim().toLowerCase();
const labels: Record<string, string> = {
provider_transaction_id_mismatch: '微信交易单号不一致',
order_total_mismatch: '订单总额不一致',
};
return normalized ? (labels[normalized] ?? normalized) : '-';
}
function normalizeRefundReason(value: string) {
return Array.from(value.trim()).slice(0, 80).join('');
}
function datetimeLocalToRfc3339(value: string) {
const normalized = value.trim();
if (!normalized) {
@@ -1370,6 +1443,16 @@ function findResolvableManualReviewRefund(
);
}
function findResolvedManualReviewRefund(order: AdminRechargeOrderEntryPayload) {
return order.refunds.find(
(refund) =>
Boolean(refund.manualReviewResolvedErrorCode) &&
Boolean(refund.manualReviewResolvedByAdminUserId) &&
Boolean(refund.manualReviewResolutionReason) &&
Boolean(refund.manualReviewResolvedAtMicros),
);
}
function createRequestId() {
if (
typeof crypto !== 'undefined' &&