feat(billing): 支付 P5.3 —— 掉单补偿定时器 + admin 订单流 + 日终对账

支付线封口。此前 pending 单只在「用户开着账单页轮询」时才查单确认——用户扫完码
关页面,钱付了、积分永不到账。

- 掉单补偿定时器(payment_reconcile.go):gateway 内每分钟扫 pending 微信单,
  逐单 reconcileOrder 主动查单落态。把「用户在不在场」从入账链路摘掉。
  reconcileOrder 从 BillingOrderStatus 抽出、前端轮询与定时器共用一份幂等
  落态逻辑(不重蹈 GenerateReport/SubmitTask 的漂移)。渠道未配置时空转不炸。
- admin 订单流 GET /admin/orders(状态计数+全平台订单,可筛)。
- 日终对账 GET /admin/orders/reconcile:paid 单 ↔ 账本 grant 分录逐单比对,
  抓 order_without_ledger(钱到了积分没给,最严重)/ ledger_without_paid_order。
- admin 计费页「充值订单与对账」块:计数卡片+订单流+一键对账。

⚠️ live 抓到并修掉一个真 bug:OrderStats 复用同一个 gorm.DB 链式 Count 三次,
WHERE 累加成 status=A AND status=B → 恒 0(订单流显示 2 单但计数全 0)。
改成每次起新 query builder。—— 又一次只有 live 才暴露的。

验证:go 6 包测试+tsc+41 vitest 全绿;live 造差异单对账正确抓出
order_without_ledger、清账后回零差异;补偿器启动日志+渠道未配置空转不炸;
浏览器验订单流卡片+一键对账绿条。TTL 过期路径需真渠道触发,部署后自然覆盖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 11:25:37 +08:00
parent 3767c78ee8
commit 3db2de1ef6
7 changed files with 433 additions and 16 deletions
+39
View File
@@ -262,6 +262,45 @@ export async function saveWechatPay(body: {
return { enabled: !!d.enabled, reason: d.reason ?? "" };
}
// ---- 充值订单流 + 对账(P5.3----
export interface PayOrder {
id: string;
tenant_id: string;
tenant_name: string;
amount_fen: number;
credits_micro: number;
channel: string;
status: string;
created_at: string;
}
export interface OrderStats {
pending: number;
paid: number;
expired: number;
paid_fen_total: number;
}
export interface ReconcileDiff {
order_id: string;
tenant_id: string;
credits_micro: number;
issue: string;
}
export async function adminOrders(status = ""): Promise<{ orders: PayOrder[]; stats: OrderStats }> {
const q = status ? `?status=${status}` : "";
const res = guard(await fetch(`${ADMIN}/orders${q}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { orders?: PayOrder[]; stats?: OrderStats; error?: string };
if (!res.ok) throw new Error(d.error ?? `orders failed: ${res.status}`);
return { orders: d.orders ?? [], stats: d.stats ?? { pending: 0, paid: 0, expired: 0, paid_fen_total: 0 } };
}
export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: boolean }> {
const res = guard(await fetch(`${ADMIN}/orders/reconcile`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { diffs?: ReconcileDiff[]; ok?: boolean; error?: string };
if (!res.ok) throw new Error(d.error ?? `reconcile failed: ${res.status}`);
return { diffs: d.diffs ?? [], ok: !!d.ok };
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useState } from "react";
import { adminOrders, adminReconcile, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
// - 状态计数卡片(pending/paid/expired + 累计到账额)
// - 订单流(可按状态筛)
// - 一键对账:paid 单 ↔ 账本 grant 分录逐单比对,正常应零差异
const MICRO = 1_000_000;
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const yuan = (fen: number) => `¥${(fen / 100).toFixed(2)}`;
const STATUS_BADGE: Record<string, string> = {
paid: "bg-emerald-50 text-emerald-600",
pending: "bg-amber-50 text-amber-600",
expired: "bg-gray-100 text-gray-500",
failed: "bg-rose-50 text-rose-500",
refunded: "bg-gray-100 text-gray-500",
};
const STATUS_LABEL: Record<string, string> = { paid: "已入账", pending: "待支付", expired: "已过期", failed: "失败", refunded: "已退款" };
const CHANNEL_LABEL: Record<string, string> = { redeem: "兑换码", wechat: "微信支付" };
export function OrderStream() {
const [orders, setOrders] = useState<PayOrder[]>([]);
const [stats, setStats] = useState<OrderStats>({ pending: 0, paid: 0, expired: 0, paid_fen_total: 0 });
const [filter, setFilter] = useState("");
const [err, setErr] = useState("");
// 对账结果:null=未跑;[]=零差异;有元素=有差异
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
const [checking, setChecking] = useState(false);
const load = useCallback(() => {
adminOrders(filter)
.then((r) => {
setOrders(r.orders);
setStats(r.stats);
setErr("");
})
.catch((e) => setErr((e as Error).message));
}, [filter]);
useEffect(load, [load]);
const reconcile = async () => {
setChecking(true);
try {
const r = await adminReconcile();
setDiffs(r.diffs);
} catch (e) {
setErr((e as Error).message);
} finally {
setChecking(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400"> + </span>
</div>
{/* 状态计数 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat label="待支付" value={String(stats.pending)} tone="amber" />
<Stat label="已入账" value={String(stats.paid)} tone="emerald" />
<Stat label="已过期" value={String(stats.expired)} tone="gray" />
<Stat label="累计到账" value={yuan(stats.paid_fen_total)} tone="violet" sub="真渠道实付合计" />
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
{[
["", "全部"],
["paid", "已入账"],
["pending", "待支付"],
["expired", "已过期"],
].map(([v, label]) => (
<button key={v} onClick={() => setFilter(v)}
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
{label}
</button>
))}
</div>
<button onClick={() => void reconcile()} disabled={checking}
className="rounded-lg border border-violet-200 px-3 py-1.5 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40">
{checking ? "核对中…" : "一键对账"}
</button>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
{/* 对账结果条 */}
{diffs !== null && (
diffs.length === 0 ? (
<div className="mb-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
</div>
) : (
<div className="mb-3 rounded-lg border border-rose-200 bg-rose-50 p-3">
<div className="mb-1.5 text-xs font-medium text-rose-700"> {diffs.length} </div>
<div className="space-y-0.5 font-mono text-[11px] text-rose-900">
{diffs.map((d) => (
<div key={d.order_id + d.issue}>
{d.order_id} · {credits(d.credits_micro)} · {d.issue === "order_without_ledger" ? "订单已付但账本无入账(钱到了积分没给)" : "账本有入账但订单非已付(状态错乱)"}
</div>
))}
</div>
</div>
)
)}
<div className="max-h-72 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="border-b border-gray-50 last:border-0">
<td className="py-1.5 pr-3 text-xs text-gray-500">{new Date(o.created_at).toLocaleString("zh-CN")}</td>
<td className="py-1.5 pr-3 text-gray-800">{o.tenant_name || o.tenant_id}</td>
<td className="py-1.5 pr-3 text-xs text-gray-500">{CHANNEL_LABEL[o.channel] ?? o.channel}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(o.credits_micro)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-500">{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"}</td>
<td className="py-1.5">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_BADGE[o.status] ?? "bg-gray-100 text-gray-500"}`}>
{STATUS_LABEL[o.status] ?? o.status}
</span>
</td>
</tr>
))}
{orders.length === 0 && (
<tr>
<td colSpan={6} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const TONE: Record<string, string> = { amber: "text-amber-600", emerald: "text-emerald-600", gray: "text-gray-600", violet: "text-violet-600" };
function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
</div>
);
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
import { BillingRules } from "../components/BillingRules";
import { TopupChannels } from "../components/TopupChannels";
import { OrderStream } from "../components/OrderStream";
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
@@ -98,9 +99,12 @@ export function UsagePage() {
{/* 配置端:计费规则(改规则即对后续任务生效) */}
<BillingRules onSaved={() => void load()} />
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价P5.1 */}
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价 + 微信配置,P5.1/P5.2 */}
<TopupChannels />
{/* 观测端:充值订单流 + 对账(P5.3) */}
<OrderStream />
{/* 观测端:用量结果 */}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>