收紧退款人工复核与重试契约
补齐退款事实对比字段与人工复核审计展示 人工复核重放严格校验管理员、原因和错误码 退款占用重试完整匹配管理员与归一化原因 统一退款原因持久化上限并修复刷新恢复 补充前后端回归测试、生成绑定与数据契约文档
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user