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 通过
1644 lines
52 KiB
TypeScript
1644 lines
52 KiB
TypeScript
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 =
|
||
'微信侧退款状态未知,已恢复原退款请求;请使用原请求继续核对,不能新建退款。';
|