Files
Genarrative/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx
T
kdletters 0baa8cf8cc 修复退款复核与发布安全边界
补齐退款人工复核、刷新恢复、会话隔离和旧冷备导入兼容
强化真实微信退款对账开关、重复环境变量和生产发布门禁
优化公开作品资产授权查询并保持实时撤销语义
统一后台微信渠道、退款追回流水和画布正式资源测试契约
补齐生成与素材侧栏回归测试
2026-07-14 16:31:22 +08:00

1547 lines
48 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 {
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: '',
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 = refundReason.trim();
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);
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({ orderId: order.orderId, refund });
setManualReviewReason('');
setManualReviewError('');
}
async function handleResolveManualReview(event: FormEvent<HTMLFormElement>) {
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,
) {
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>
</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.amountCents)}
/>
<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)}
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>
<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);
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>{formatMoney(order.amountCents)}</strong>
<small>发放 {order.pointsDelta} 泥点</small>
</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}
</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 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 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 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 =
'微信侧退款状态未知,已恢复原退款请求;请使用原请求继续核对,不能新建退款。';