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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user