Files
Genarrative/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx
T
kdletters 1b05a1d05e
Project CI / AI game creator shell Rust smoke (push) Successful in 2m17s
Project CI / Backend tests (push) Successful in 4m59s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m29s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m40s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / AI game creator shell Rust crates (push) Successful in 9m16s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 4m4s
Project CI / Native shell tests (push) Successful in 7m1s
后台充值订单实付口径、发放泥点列、用户累计充值与兑换码单位
- 新增 AdminRechargeOrderEntryPayload.paidAmountCents:只有 paid_at 存在的订单才有实付,未支付 / 已关闭 / 已过期固定为 0
- 充值管理列表把「金额 / 泥点」拆成「实付」「发放泥点」两列,未支付行实付显示「未支付」并附订单金额小字;退款面板「订单实付」改读同一字段
- 用户详情充值订单表新增「发放泥点」列,商品列只保留商品名,实付同样按 paidAmountCents 展示
- 用户详情新增 cumulativeRechargedCents:api-server 按 user_id 读取 profile_recharge_order,只累加 paid_at 存在的订单金额(退款不回减),单次上限 500 行,读取失败或命中上限返回 null
- 用户详情身份区新增「累计充值」,读不到时显示「未知」,不用用户详情最多 20 条订单在 BFF 或前端近似重算
- 兑换码奖励单位收口为泥点:输入标签与列表列头改「奖励泥点」,单元格带「泥点」单位,避免被当成元
- 新增后端 2 条累计充值口径单测与前端 2 条用例(未支付不显示实付且发放泥点独立成列、累计充值未知态)
- 同步后端架构数据契约(实付口径 / 累计充值来源与上限 / 兑换码奖励单位)与决策记录
- 验证:cargo check -p api-server;cargo test -p api-server --bin api-server admin(138 passed / 0 failed / 1 ignored);npm run admin-web:typecheck;npx vitest run apps/admin-web/src(220 passed);cargo fmt --all --check、check:encoding、check:doc-index、git diff --check 通过
2026-09-23 20:10:20 +08:00

1644 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertTriangle,
FileCheck2,
FileInput,
RefreshCcw,
RotateCcw,
Search,
Undo2,
X,
} from 'lucide-react';
import { FormEvent, useEffect, useRef, useState } from 'react';
import {
executeAdminRechargeRefund,
formatAdminApiError,
isAdminApiError,
listAdminRechargeOrders,
previewAdminRechargeRefund,
registerAdminRechargeRefund,
resolveAdminRechargeRefundManualReview,
} from '../api/adminApiClient';
import type {
AdminRechargeOrderEntryPayload,
AdminRechargeOrderListQuery,
AdminRechargeRefundActionResponse,
AdminRechargeRefundPayload,
AdminRechargeRefundPreviewResponse,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminRechargeOrderPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
interface RechargeFilters {
orderId: string;
providerTransactionId: string;
userId: string;
publicUserCode: string;
paymentChannel: string;
status: string;
createdAfter: string;
createdBefore: string;
limit: string;
}
type RefundMode = 'remaining' | 'partial';
interface PersistedRefundRequestContext {
orderId: string;
amountCents: number;
requestId: string;
reason: string;
outRefundNo?: string;
createdAtMillis: number;
}
interface ManualReviewTarget {
order: AdminRechargeOrderEntryPayload;
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: '',
userId: '',
publicUserCode: '',
paymentChannel: '',
status: '',
createdAfter: '',
createdBefore: '',
limit: '100',
};
export function AdminRechargeOrderPage({
token,
onUnauthorized,
}: AdminRechargeOrderPageProps) {
const [filters, setFilters] = useState<RechargeFilters>(defaultFilters);
const [orders, setOrders] = useState<AdminRechargeOrderEntryPayload[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [listError, setListError] = useState('');
const [refundOrder, setRefundOrder] =
useState<AdminRechargeOrderEntryPayload | null>(null);
const [refundMode, setRefundMode] = useState<RefundMode>('remaining');
const [refundAmountYuan, setRefundAmountYuan] = useState('');
const [refundReason, setRefundReason] = useState('');
const [preview, setPreview] =
useState<AdminRechargeRefundPreviewResponse | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [refundError, setRefundError] = useState('');
const [providerStatusUnknown, setProviderStatusUnknown] = useState(false);
const [lastAction, setLastAction] =
useState<AdminRechargeRefundActionResponse | null>(null);
const [registerOpen, setRegisterOpen] = useState(false);
const [outRefundNo, setOutRefundNo] = useState('');
const [registerError, setRegisterError] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [refundRequestId, setRefundRequestId] = useState('');
const [resumableRefundRequest, setResumableRefundRequest] =
useState<PersistedRefundRequestContext | null>(null);
const [manualReviewTarget, setManualReviewTarget] =
useState<ManualReviewTarget | null>(null);
const [manualReviewReason, setManualReviewReason] = useState('');
const [manualReviewError, setManualReviewError] = useState('');
const [isResolvingManualReview, setIsResolvingManualReview] = useState(false);
const lastQueryRef = useRef<AdminRechargeOrderListQuery>({ limit: 100 });
const submitLockRef = useRef(false);
const registerLockRef = useRef(false);
const manualReviewLockRef = useRef(false);
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
const visibleDebtOrder = findDebtOrder(lastAction?.order, orders);
useEffect(() => {
void refreshOrders(lastQueryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (
refundOrder &&
refundMode === 'remaining' &&
!preview &&
!providerStatusUnknown
) {
setRefundAmountYuan(
formatCentsInput(refundOrder.remainingRefundableCents),
);
}
}, [preview, providerStatusUnknown, refundMode, refundOrder]);
async function refreshOrders(query = lastQueryRef.current) {
setIsLoading(true);
setListError('');
try {
const response = await listAdminRechargeOrders(token, query);
setOrders(response.entries);
setRefundOrder((current) =>
current
? (response.entries.find(
(entry) => entry.orderId === current.orderId,
) ?? current)
: current,
);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListError);
} finally {
setIsLoading(false);
}
}
async function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const query = buildRechargeOrderQuery(filters);
lastQueryRef.current = query;
await refreshOrders(query);
}
async function handleResetFilters() {
setFilters(defaultFilters);
const query = { limit: 100 };
lastQueryRef.current = query;
await refreshOrders(query);
}
function openRefund(order: AdminRechargeOrderEntryPayload) {
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(
amountCents === order.remainingRefundableCents ? 'remaining' : 'partial',
);
setRefundAmountYuan(formatCentsInput(amountCents));
setRefundReason(order.activeHold?.reason ?? persistedContext?.reason ?? '');
setPreview(null);
setRefundError('');
setProviderStatusUnknown(hasUncertainRequest);
setRefundRequestId(persistedContext?.requestId ?? createRequestId());
setResumableRefundRequest(
order.activeHold && persistedContext ? persistedContext : null,
);
}
function closeRefund() {
if (isSubmitting) {
return;
}
setRefundOrder(null);
setPreview(null);
setRefundError('');
setResumableRefundRequest(null);
}
function updateRefundAmount(nextValue: string) {
if (refundOrder) {
removePersistedRefundRequestContext(token, refundOrder.orderId);
}
setRefundAmountYuan(nextValue);
setPreview(null);
setRefundError('');
setProviderStatusUnknown(false);
setRefundRequestId(createRequestId());
setResumableRefundRequest(null);
}
function changeRefundMode(nextMode: RefundMode) {
if (!refundOrder) {
return;
}
setRefundMode(nextMode);
updateRefundAmount(
nextMode === 'remaining'
? formatCentsInput(refundOrder.remainingRefundableCents)
: '',
);
}
async function handlePreviewRefund() {
if (!refundOrder || isPreviewing || isSubmitting) {
return;
}
const amountCents = parseYuanToCents(refundAmountYuan);
if (!amountCents) {
setRefundError('请输入有效的退款金额');
return;
}
setIsPreviewing(true);
setRefundError('');
try {
const response = await previewAdminRechargeRefund(token, {
orderId: refundOrder.orderId,
refundAmountCents: amountCents,
});
setPreview(response);
setProviderStatusUnknown(false);
setRefundOrder(response.order);
upsertOrder(response.order);
if (!response.canSubmit) {
setRefundError(formatRefundBlockReason(response.blockReasonCode));
}
} catch (error: unknown) {
handleDialogError(error, setRefundError);
if (!(isAdminApiError(error) && error.status === 401)) {
void refreshOrders();
}
} finally {
setIsPreviewing(false);
}
}
async function handleExecuteRefund() {
if (
!refundOrder ||
(!preview?.canSubmit && !resumableRefundRequest) ||
submitLockRef.current ||
isSubmitting
) {
return;
}
const reason = normalizeRefundReason(refundReason);
if (!reason) {
setRefundError('请填写退款原因');
return;
}
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)}`,
});
if (!confirmed || submitLockRef.current) {
return;
}
submitLockRef.current = true;
setIsSubmitting(true);
setRefundError('');
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,
refundAmountCents: amountCents,
requestId,
reason,
});
setLastAction(response);
setRefundOrder(response.order);
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 {
removePersistedRefundRequestContext(
token,
refundOrder.orderId,
response.outRefundNo,
);
setProviderStatusUnknown(false);
setResumableRefundRequest(null);
}
} catch (error: unknown) {
if (!isAdminApiError(error)) {
setProviderStatusUnknown(true);
setPreview(null);
setResumableRefundRequest(requestContext);
setRefundError(unknownProviderStatusMessage);
} else {
removePersistedRefundRequestContext(token, refundOrder.orderId);
setResumableRefundRequest(null);
handleDialogError(error, setRefundError);
}
} finally {
submitLockRef.current = false;
setIsSubmitting(false);
}
}
async function handleRegisterRefund(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedOutRefundNo = outRefundNo.trim();
if (!normalizedOutRefundNo || registerLockRef.current) {
if (!normalizedOutRefundNo) {
setRegisterError('请填写商户退款单号 out_refund_no');
}
return;
}
const confirmed = await confirmWrite({
action: '登记商户平台退款',
target: normalizedOutRefundNo,
});
if (!confirmed || registerLockRef.current) {
return;
}
registerLockRef.current = true;
setIsRegistering(true);
setRegisterError('');
try {
const response = await registerAdminRechargeRefund(token, {
outRefundNo: normalizedOutRefundNo,
});
setLastAction(response);
upsertOrder(response.order);
removePersistedRefundRequestContext(
token,
response.order.orderId,
normalizedOutRefundNo,
);
setOutRefundNo('');
setRegisterOpen(false);
} catch (error: unknown) {
handleDialogError(error, setRegisterError);
} finally {
registerLockRef.current = false;
setIsRegistering(false);
}
}
function openManualReview(
order: AdminRechargeOrderEntryPayload,
refund: AdminRechargeRefundPayload,
) {
setManualReviewTarget({ order, refund });
setManualReviewReason('');
setManualReviewError('');
}
async function handleResolveManualReview(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const reason = manualReviewReason.trim();
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.order.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,
expectedErrorCode,
});
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,
) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setMessage(formatAdminApiError(error));
}
function upsertOrder(nextOrder: AdminRechargeOrderEntryPayload) {
setOrders((current) => {
const index = current.findIndex(
(entry) => entry.orderId === nextOrder.orderId,
);
if (index < 0) {
return [nextOrder, ...current];
}
return current.map((entry) =>
entry.orderId === nextOrder.orderId ? nextOrder : entry,
);
});
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading admin-recharge-page-heading">
<div>
<h2>充值管理</h2>
<p>充值订单、微信退款与泥点追回</p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
type="button"
onClick={() => {
setRegisterError('');
setRegisterOpen(true);
}}
>
<FileInput size={17} aria-hidden="true" />
<span>登记商户退款</span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void refreshOrders()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
{visibleDebtOrder ? (
<RefundDebtBanner order={visibleDebtOrder} />
) : lastAction ? (
<RefundActionBanner action={lastAction} />
) : null}
{listError ? (
<div className="admin-alert" role="status">
{listError}
</div>
) : null}
<form className="admin-panel admin-form" onSubmit={handleSearch}>
<div className="admin-recharge-filter-grid">
<FilterInput
label="订单号"
value={filters.orderId}
onChange={(value) => setFilters({ ...filters, orderId: value })}
/>
<FilterInput
label="微信交易单号"
value={filters.providerTransactionId}
onChange={(value) =>
setFilters({ ...filters, providerTransactionId: value })
}
/>
<FilterInput
label="内部用户 ID"
value={filters.userId}
onChange={(value) =>
setFilters({
...filters,
userId: value,
publicUserCode: value.trim() ? '' : filters.publicUserCode,
})
}
/>
<FilterInput
label="陶泥号"
value={filters.publicUserCode}
onChange={(value) =>
setFilters({
...filters,
publicUserCode: value,
userId: value.trim() ? '' : filters.userId,
})
}
/>
<label className="admin-field">
<span>支付渠道</span>
<select
value={filters.paymentChannel}
onChange={(event) =>
setFilters({ ...filters, paymentChannel: event.target.value })
}
>
<option value="">全部</option>
<option value="wechat_native">微信 Native</option>
<option value="wechat_jsapi">微信 JSAPI</option>
<option value="wechat_h5">微信 H5</option>
<option value="wechat_mp">微信小程序</option>
<option value="wechat_mp_virtual">微信小程序虚拟支付</option>
</select>
</label>
<label className="admin-field">
<span>订单状态</span>
<select
value={filters.status}
onChange={(event) =>
setFilters({ ...filters, status: event.target.value })
}
>
<option value="">全部</option>
<option value="pending">待支付</option>
<option value="paid">已支付</option>
<option value="refunded">已退款</option>
<option value="closed">已关闭</option>
<option value="failed">支付失败</option>
<option value="expired">已过期</option>
</select>
</label>
<label className="admin-field">
<span>创建时间起</span>
<input
type="datetime-local"
value={filters.createdAfter}
onChange={(event) =>
setFilters({ ...filters, createdAfter: event.target.value })
}
/>
</label>
<label className="admin-field">
<span>创建时间止</span>
<input
type="datetime-local"
value={filters.createdBefore}
onChange={(event) =>
setFilters({ ...filters, createdBefore: event.target.value })
}
/>
</label>
<label className="admin-field admin-field-compact">
<span>条数</span>
<input
min={1}
max={500}
step={1}
type="number"
value={filters.limit}
onChange={(event) =>
setFilters({ ...filters, limit: event.target.value })
}
/>
</label>
<div className="admin-action-row admin-recharge-filter-actions">
<button className="admin-secondary-button" type="submit">
<Search size={17} aria-hidden="true" />
<span>查询</span>
</button>
<button
className="admin-ghost-button admin-query-reset-button"
title="重置筛选"
type="button"
onClick={() => void handleResetFilters()}
>
<RotateCcw size={16} aria-hidden="true" />
<span>重置</span>
</button>
</div>
</div>
</form>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>充值订单</h3>
<span>{orders.length} </span>
</div>
{isLoading && orders.length === 0 ? (
<div className="admin-empty-state">正在读取充值订单</div>
) : orders.length === 0 ? (
<div className="admin-empty-state">暂无匹配订单</div>
) : (
<div className="admin-table-wrap">
<table className="admin-table admin-recharge-table">
<thead>
<tr>
<th>用户</th>
<th>订单</th>
<th>支付</th>
<th>实付</th>
<th>发放泥点</th>
<th>退款与追回</th>
<th>钱包</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<RechargeOrderRow
key={order.orderId}
order={order}
token={token}
onRefund={openRefund}
onManualReview={openManualReview}
onUnauthorized={onUnauthorized}
/>
))}
</tbody>
</table>
</div>
)}
</section>
{refundOrder ? (
<div
aria-modal="true"
className="admin-confirm-backdrop"
role="dialog"
aria-labelledby="admin-refund-dialog-title"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeRefund();
}
}}
>
<section className="admin-detail-panel admin-recharge-dialog">
<div className="admin-panel-heading">
<div>
<h3 id="admin-refund-dialog-title">退款处理</h3>
<span>{refundOrder.orderId}</span>
</div>
<button
aria-label="关闭退款面板"
className="admin-ghost-button"
disabled={isSubmitting}
title="关闭"
type="button"
onClick={closeRefund}
>
<X size={18} aria-hidden="true" />
</button>
</div>
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatMoney(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
value={formatMoney(refundOrder.cumulativeSuccessRefundCents)}
/>
<Metric
label="剩余可退"
value={formatMoney(refundOrder.remainingRefundableCents)}
/>
<Metric
label="永久泥点"
value={String(refundOrder.wallet.permanentPoints)}
/>
<Metric
label="占用泥点"
value={String(refundOrder.wallet.heldPoints)}
/>
<Metric
label="退款欠账"
value={String(refundOrder.wallet.refundDebtPoints)}
/>
</div>
<div
className="admin-segmented-control admin-refund-mode"
role="tablist"
>
<button
data-active={refundMode === 'remaining'}
disabled={providerStatusUnknown || isPreviewing || isSubmitting}
type="button"
onClick={() => changeRefundMode('remaining')}
>
退剩余全额
</button>
<button
data-active={refundMode === 'partial'}
disabled={providerStatusUnknown || isPreviewing || isSubmitting}
type="button"
onClick={() => changeRefundMode('partial')}
>
部分退款
</button>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span>退款金额(元)</span>
<input
aria-label="退款金额(元)"
disabled={
refundMode === 'remaining' ||
providerStatusUnknown ||
isPreviewing ||
isSubmitting
}
inputMode="decimal"
value={refundAmountYuan}
onChange={(event) => updateRefundAmount(event.target.value)}
/>
</label>
<label className="admin-field">
<span>退款原因</span>
<input
aria-label="退款原因"
disabled={isSubmitting || Boolean(resumableRefundRequest)}
maxLength={80}
value={refundReason}
onChange={(event) => setRefundReason(event.target.value)}
/>
</label>
</div>
{providerStatusUnknown ? (
<div className="admin-alert admin-alert-warning" role="status">
<AlertTriangle size={17} aria-hidden="true" />
<span>
{resumableRefundRequest
? resumableRefundRequestMessage
: unknownProviderStatusMessage}
</span>
</div>
) : null}
{refundError && !providerStatusUnknown ? (
<div className="admin-alert" role="status">
{refundError}
</div>
) : null}
{preview ? (
<section
className="admin-refund-preview"
aria-label="退款预检结果"
>
<div className="admin-panel-heading">
<h3>退款预检</h3>
<span>{preview.canSubmit ? '可以提交' : '已阻止'}</span>
</div>
<dl className="admin-info-list">
<div>
<dt>微信账单核验</dt>
<dd>
{preview.paymentCheck.verified ? '已核验' : '未核验'} /{' '}
{preview.paymentCheck.tradeState || '-'}
</dd>
</div>
<div>
<dt>本次应追回</dt>
<dd>{preview.incrementalRecoveryPoints} 泥点</dd>
</div>
<div>
<dt>刷新退款单</dt>
<dd>{preview.paymentCheck.knownRefundsRefreshed} </dd>
</div>
<div>
<dt>预检后剩余可退</dt>
<dd>{formatMoney(preview.remainingRefundableCents)}</dd>
</div>
</dl>
</section>
) : null}
<div className="admin-confirm-actions">
<button
className="admin-secondary-button"
disabled={
isPreviewing ||
isSubmitting ||
Boolean(resumableRefundRequest)
}
type="button"
onClick={() => void handlePreviewRefund()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isPreviewing ? '核验中' : '核验支付账单'}</span>
</button>
<button
className="admin-danger-button"
disabled={
(!preview?.canSubmit && !resumableRefundRequest) ||
!refundReason.trim() ||
isSubmitting ||
isPreviewing
}
type="button"
onClick={() => void handleExecuteRefund()}
>
<Undo2 size={17} aria-hidden="true" />
<span>
{isSubmitting
? '提交中'
: resumableRefundRequest
? '继续核对退款'
: '确认退款'}
</span>
</button>
</div>
</section>
</div>
) : null}
{registerOpen ? (
<div
aria-modal="true"
className="admin-confirm-backdrop"
role="dialog"
aria-labelledby="admin-register-refund-title"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !isRegistering) {
setRegisterOpen(false);
}
}}
>
<form className="admin-confirm-panel" onSubmit={handleRegisterRefund}>
<div className="admin-panel-heading">
<div>
<h3 id="admin-register-refund-title">登记商户平台退款</h3>
<span>填写退款详情中的 out_refund_no</span>
</div>
<button
aria-label="关闭登记面板"
className="admin-ghost-button"
disabled={isRegistering}
title="关闭"
type="button"
onClick={() => setRegisterOpen(false)}
>
<X size={18} aria-hidden="true" />
</button>
</div>
<label className="admin-field">
<span>商户退款单号(out_refund_no</span>
<input
autoFocus
aria-label="商户退款单号 out_refund_no"
placeholder="从退款详情复制,不能填写微信退款单号 refund_id"
value={outRefundNo}
onChange={(event) => setOutRefundNo(event.target.value)}
/>
</label>
{registerError ? (
<div className="admin-alert" role="status">
{registerError}
</div>
) : null}
<div className="admin-confirm-actions">
<button
className="admin-secondary-button"
disabled={isRegistering}
type="button"
onClick={() => setRegisterOpen(false)}
>
取消
</button>
<button
className="admin-danger-button"
disabled={isRegistering || !outRefundNo.trim()}
type="submit"
>
{isRegistering ? '登记中' : '查询并登记'}
</button>
</div>
</form>
</div>
) : null}
{manualReviewTarget ? (
<div
aria-modal="true"
className="admin-confirm-backdrop"
role="dialog"
aria-labelledby="admin-refund-manual-review-title"
onMouseDown={(event) => {
if (
event.target === event.currentTarget &&
!isResolvingManualReview
) {
setManualReviewTarget(null);
}
}}
>
<form
className="admin-confirm-panel"
onSubmit={handleResolveManualReview}
>
<div className="admin-panel-heading">
<div>
<h3 id="admin-refund-manual-review-title">确认退款人工复核</h3>
<span>{manualReviewTarget.refund.outRefundNo}</span>
</div>
<button
aria-label="关闭人工复核面板"
className="admin-ghost-button"
disabled={isResolvingManualReview}
title="关闭"
type="button"
onClick={() => setManualReviewTarget(null)}
>
<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
autoFocus
aria-label="人工复核原因"
disabled={isResolvingManualReview}
rows={3}
value={manualReviewReason}
onChange={(event) => setManualReviewReason(event.target.value)}
/>
</label>
{manualReviewError ? (
<div className="admin-alert" role="status">
{manualReviewError}
</div>
) : null}
<div className="admin-confirm-actions">
<button
className="admin-secondary-button"
disabled={isResolvingManualReview}
type="button"
onClick={() => setManualReviewTarget(null)}
>
取消
</button>
<button
className="admin-danger-button"
disabled={isResolvingManualReview || !manualReviewReason.trim()}
type="submit"
>
{isResolvingManualReview ? '处理中' : '确认并追回'}
</button>
</div>
</form>
</div>
) : null}
{confirmDialog}
</section>
);
}
function FilterInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="admin-field">
<span>{label}</span>
<input
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</label>
);
}
function RechargeOrderRow({
order,
token,
onRefund,
onManualReview,
onUnauthorized,
}: {
order: AdminRechargeOrderEntryPayload;
token: string;
onRefund: (order: AdminRechargeOrderEntryPayload) => void;
onManualReview: (
order: AdminRechargeOrderEntryPayload,
refund: AdminRechargeRefundPayload,
) => void;
onUnauthorized: (message?: string) => void;
}) {
const userName = order.user?.displayName || '未读取用户资料';
const manualReviewRefund = findResolvableManualReviewRefund(order);
const resolvedManualReviewRefund = findResolvedManualReviewRefund(order);
return (
<tr>
<td>
<div className="admin-inline-identity">
<div>
<strong>{userName}</strong>
<small>{order.user?.publicUserCode || order.userId}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={order.userId}
publicUserCode={order.user?.publicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<small>{formatMicros(order.createdAtMicros)}</small>
<small>{order.productTitle || order.productId}</small>
</td>
<td>
<span>{formatPaymentChannel(order.paymentChannel)}</span>
<small>{order.providerTransactionId || '暂无微信交易单号'}</small>
<small>
{order.paidAtMicros ? formatMicros(order.paidAtMicros) : '-'}
</small>
</td>
<td>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small>订单 {formatMoney(order.amountCents)}</small>
)}
</td>
<td>
<span>{order.pointsDelta} 泥点</span>
</td>
<td>
<span>累计 {formatMoney(order.cumulativeSuccessRefundCents)}</span>
<small>
应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints}
</small>
<small>欠账 {order.unrecoveredPoints} 泥点</small>
{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>
<small>
永久 {order.wallet.permanentPoints} / 占用 {order.wallet.heldPoints}
</small>
<div className="admin-tag-list admin-recharge-tags">
{order.wallet.manualFrozen ? (
<span className="admin-tag">人工冻结</span>
) : null}
{order.wallet.refundDebtFrozen ? (
<span className="admin-tag">退款欠账限制</span>
) : null}
</div>
</td>
<td>
<span>{formatOrderStatus(order.status)}</span>
<small>{formatRecoveryStatus(order.recoveryStatus)}</small>
</td>
<td>
<div className="admin-stack">
<button
className="admin-danger-button admin-recharge-refund-button"
disabled={
!order.activeHold &&
(!order.refundEligible || order.remainingRefundableCents <= 0)
}
title={
order.activeHold
? '继续核对进行中的退款'
: order.refundEligible
? '发起退款'
: formatRefundBlockReason(order.refundBlockReasonCode)
}
type="button"
onClick={() => onRefund(order)}
>
<Undo2 size={16} aria-hidden="true" />
<span>{order.activeHold ? '核对退款' : '退款'}</span>
</button>
{manualReviewRefund ? (
<button
aria-label="人工复核"
className="admin-icon-button"
title="人工复核"
type="button"
onClick={() => onManualReview(order, manualReviewRefund)}
>
<FileCheck2 size={16} aria-hidden="true" />
</button>
) : null}
</div>
</td>
</tr>
);
}
function RefundActionBanner({
action,
}: {
action: AdminRechargeRefundActionResponse;
}) {
if (
action.providerStatusUnknown ||
['manual_review', 'abnormal', 'closed'].includes(action.resultCode)
) {
return (
<div
className="admin-alert admin-alert-warning admin-refund-result-banner"
role="status"
>
<AlertTriangle size={18} aria-hidden="true" />
<div>
<strong>退款仍需核对</strong>
<span>
{action.outRefundNo} / {action.providerStatus} / {action.resultCode}
</span>
</div>
</div>
);
}
return (
<div
className="admin-alert admin-alert-success admin-refund-result-banner"
role="status"
>
<div>
<strong>退款处理已受理</strong>
<span>
{action.outRefundNo} / {action.providerStatus} / {action.resultCode}
</span>
</div>
</div>
);
}
function RefundDebtBanner({
order,
}: {
order: AdminRechargeOrderEntryPayload;
}) {
return (
<div
className="admin-alert admin-alert-warning admin-refund-result-banner"
role="status"
>
<AlertTriangle size={18} aria-hidden="true" />
<div>
<strong>
退款异常欠账{' '}
{Math.max(order.wallet.refundDebtPoints, order.unrecoveredPoints)}{' '}
泥点
</strong>
<span>
支付侧退款已记录,当前泥点不足,已限制继续消费并进入人工处理。
</span>
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="admin-recharge-metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function buildRechargeOrderQuery(
filters: RechargeFilters,
): AdminRechargeOrderListQuery {
const limit = Number.parseInt(filters.limit, 10);
return {
orderId: filters.orderId,
providerTransactionId: filters.providerTransactionId,
userId: filters.userId,
publicUserCode: filters.publicUserCode,
paymentChannel: filters.paymentChannel,
status: filters.status,
createdAfter: datetimeLocalToRfc3339(filters.createdAfter),
createdBefore: datetimeLocalToRfc3339(filters.createdBefore),
limit: Number.isFinite(limit) ? Math.min(500, Math.max(1, limit)) : 100,
};
}
function parseYuanToCents(value: string) {
const normalized = value.trim();
const match = /^(\d+)(?:\.(\d{1,2}))?$/u.exec(normalized);
if (!match) {
return null;
}
const yuan = Number.parseInt(match[1] ?? '0', 10);
const decimal = (match[2] ?? '').padEnd(2, '0');
const cents = yuan * 100 + Number.parseInt(decimal || '0', 10);
return Number.isSafeInteger(cents) && cents > 0 ? cents : null;
}
function formatMoney(cents: number) {
return ${(cents / 100).toFixed(2)}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付';
}
function formatCentsInput(cents: number) {
return (cents / 100).toFixed(2);
}
function formatMicros(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', {
hour12: false,
});
}
function formatOrderStatus(status: string) {
const labels: Record<string, string> = {
pending: '待支付',
paid: '已支付',
refunded: '已退款',
closed: '已关闭',
failed: '支付失败',
expired: '已过期',
};
return labels[status.toLowerCase()] ?? status;
}
function formatPaymentChannel(channel: string) {
const labels: Record<string, string> = {
wechat_native: '微信 Native',
wechat_jsapi: '微信 JSAPI',
wechat_h5: '微信 H5',
wechat_mp: '微信小程序',
wechat_mp_virtual: '微信小程序虚拟支付',
};
return labels[channel.toLowerCase()] ?? channel;
}
function formatRecoveryStatus(status?: string | null) {
if (!status) {
return '无退款追回';
}
const labels: Record<string, string> = {
pending: '等待追回',
applied: '已完成追回',
shortfall: '退款异常欠账',
manual_review: '人工处理中',
not_applicable: '无需追回',
};
return labels[status.toLowerCase()] ?? status;
}
function formatRefundBlockReason(code?: string | null) {
if (!code) {
return '当前订单不可退款';
}
const labels: Record<string, string> = {
order_not_paid: '订单尚未支付',
payment_channel_not_supported: '首期仅支持普通微信 V3 充值退款',
membership_not_supported: '首期不支持会员或虚拟支付退款',
refund_in_progress: '当前订单已有退款泥点占用,请先完成对账',
insufficient_permanent_points: '用户永久泥点不足,本次不会向微信发起退款',
provider_transaction_missing: '充值订单缺少微信支付交易单号',
fully_refunded: '订单已无剩余可退款金额',
wechat_order_not_paid: '微信支付账单尚未确认可退款',
wechat_refund_not_reconciled: '微信侧已有退款,但本地尚未完成登记或对账',
refund_precondition_failed: '退款前置条件未满足,请人工核对',
invalid_refund_amount: '退款金额必须大于 0 且不超过剩余可退金额',
};
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) {
return '';
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString();
}
function findDebtOrder(
actionOrder: AdminRechargeOrderEntryPayload | undefined,
orders: AdminRechargeOrderEntryPayload[],
) {
if (
actionOrder &&
(actionOrder.wallet.refundDebtPoints > 0 ||
actionOrder.unrecoveredPoints > 0)
) {
return actionOrder;
}
return orders.find(
(order) => order.wallet.refundDebtPoints > 0 || order.unrecoveredPoints > 0,
);
}
function findResolvableManualReviewRefund(
order: AdminRechargeOrderEntryPayload,
) {
if (order.productKind.trim().toLowerCase() !== 'points') {
return undefined;
}
const resolvableErrorCodes = new Set([
'provider_transaction_id_mismatch',
'order_total_mismatch',
]);
return order.refunds.find(
(refund) =>
refund.providerStatus.trim().toUpperCase() === 'SUCCESS' &&
refund.recoveryStatus.trim().toLowerCase() === 'manual_review' &&
resolvableErrorCodes.has(
refund.lastErrorCode?.trim().toLowerCase() ?? '',
) &&
!refund.manualReviewResolvedByAdminUserId &&
!refund.manualReviewResolutionReason &&
!refund.manualReviewResolvedAtMicros,
);
}
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' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `admin-refund-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function findPersistedRefundRequestContext(
order: AdminRechargeOrderEntryPayload,
token: string,
) {
const context = readPersistedRefundRequestContexts(token)[order.orderId];
if (!context) {
return null;
}
const activeHold = order.activeHold;
if (activeHold) {
if (
context.amountCents !== activeHold.refundCents ||
context.reason.trim() !== activeHold.reason.trim() ||
(context.outRefundNo && context.outRefundNo !== activeHold.outRefundNo)
) {
return null;
}
const reconciledContext = {
...context,
amountCents: activeHold.refundCents,
reason: activeHold.reason,
outRefundNo: activeHold.outRefundNo,
};
persistRefundRequestContext(token, reconciledContext);
return reconciledContext;
}
return context.amountCents > 0 &&
context.amountCents <= order.remainingRefundableCents
? context
: null;
}
function readPersistedRefundRequestContexts(token: string) {
if (typeof sessionStorage === 'undefined') {
return {} as Record<string, PersistedRefundRequestContext>;
}
try {
const storageKey = refundRequestContextStorageKey(token);
const rawValue = sessionStorage.getItem(storageKey);
if (!rawValue) {
return {};
}
const parsedValue: unknown = JSON.parse(rawValue);
if (!parsedValue || typeof parsedValue !== 'object') {
return {};
}
const parsedEntries = Object.entries(parsedValue);
const contexts = Object.fromEntries(
Object.entries(parsedValue).filter(
(entry): entry is [string, PersistedRefundRequestContext] =>
isPersistedRefundRequestContext(entry[1]) &&
isPersistedRefundRequestContextFresh(entry[1]),
),
);
if (Object.keys(contexts).length !== parsedEntries.length) {
if (Object.keys(contexts).length === 0) {
sessionStorage.removeItem(storageKey);
} else {
sessionStorage.setItem(storageKey, JSON.stringify(contexts));
}
}
return contexts;
} catch {
return {};
}
}
function persistRefundRequestContext(
token: string,
context: PersistedRefundRequestContext,
) {
if (typeof sessionStorage === 'undefined') {
return;
}
try {
const storageKey = refundRequestContextStorageKey(token);
sessionStorage.setItem(
storageKey,
JSON.stringify({
...readPersistedRefundRequestContexts(token),
[context.orderId]: context,
}),
);
} catch {
// The in-memory dialog state remains usable when storage is unavailable.
}
}
function removePersistedRefundRequestContext(
token: string,
orderId: string,
outRefundNo?: string,
) {
if (typeof sessionStorage === 'undefined') {
return;
}
try {
const storageKey = refundRequestContextStorageKey(token);
const contexts = readPersistedRefundRequestContexts(token);
const context = contexts[orderId];
if (
!context ||
(outRefundNo &&
context.outRefundNo &&
context.outRefundNo !== outRefundNo)
) {
return;
}
delete contexts[orderId];
if (Object.keys(contexts).length === 0) {
sessionStorage.removeItem(storageKey);
} else {
sessionStorage.setItem(storageKey, JSON.stringify(contexts));
}
} catch {
// Ignore unavailable or externally corrupted session storage.
}
}
function isPersistedRefundRequestContext(
value: unknown,
): value is PersistedRefundRequestContext {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<PersistedRefundRequestContext>;
return (
typeof candidate.orderId === 'string' &&
candidate.orderId.length > 0 &&
typeof candidate.amountCents === 'number' &&
Number.isSafeInteger(candidate.amountCents) &&
candidate.amountCents > 0 &&
typeof candidate.requestId === 'string' &&
candidate.requestId.length > 0 &&
typeof candidate.reason === 'string' &&
typeof candidate.createdAtMillis === 'number' &&
Number.isSafeInteger(candidate.createdAtMillis) &&
candidate.createdAtMillis > 0 &&
(typeof candidate.outRefundNo === 'undefined' ||
typeof candidate.outRefundNo === 'string')
);
}
function isPersistedRefundRequestContextFresh(
context: PersistedRefundRequestContext,
) {
const ageMillis = Date.now() - context.createdAtMillis;
return ageMillis >= 0 && ageMillis <= REFUND_REQUEST_CONTEXT_TTL_MILLIS;
}
function refundRequestContextStorageKey(token: string) {
let hash = 2_166_136_261;
for (let index = 0; index < token.length; index += 1) {
hash ^= token.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return `${REFUND_REQUEST_CONTEXT_STORAGE_KEY}.${(hash >>> 0).toString(36)}`;
}
const unknownProviderStatusMessage =
'微信侧退款状态未知,请勿直接重复提交;请重新核验支付账单后再使用原请求继续处理。';
const resumableRefundRequestMessage =
'微信侧退款状态未知,已恢复原退款请求;请使用原请求继续核对,不能新建退款。';