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 ?? "" };
|
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 探活(不受鉴权影响)。
|
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
||||||
export async function gatewayOnline(): Promise<boolean> {
|
export async function gatewayOnline(): Promise<boolean> {
|
||||||
try {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|||||||
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||||
import { BillingRules } from "../components/BillingRules";
|
import { BillingRules } from "../components/BillingRules";
|
||||||
import { TopupChannels } from "../components/TopupChannels";
|
import { TopupChannels } from "../components/TopupChannels";
|
||||||
|
import { OrderStream } from "../components/OrderStream";
|
||||||
|
|
||||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
||||||
@@ -98,9 +99,12 @@ export function UsagePage() {
|
|||||||
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
||||||
<BillingRules onSaved={() => void load()} />
|
<BillingRules onSaved={() => void load()} />
|
||||||
|
|
||||||
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价,P5.1) */}
|
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价 + 微信配置,P5.1/P5.2) */}
|
||||||
<TopupChannels />
|
<TopupChannels />
|
||||||
|
|
||||||
|
{/* 观测端:充值订单流 + 对账(P5.3) */}
|
||||||
|
<OrderStream />
|
||||||
|
|
||||||
{/* 观测端:用量结果 */}
|
{/* 观测端:用量结果 */}
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -93,7 +94,22 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if wc := h.pay.Current(); o.Status == store.OrderPending && wc != nil {
|
updated, mismatch := h.reconcileOrder(ctx, o)
|
||||||
|
if mismatch {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"order": updated, "warn": "支付金额与订单不符,已挂起待人工核对"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"order": updated})
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileOrder 对一张 pending 微信单主动查单并落态:已付且金额相符→入账(幂等闸),
|
||||||
|
// 渠道关单/超 TTL→过期。返回最新订单 + 是否金额不符(不符则不入账、留人工对账)。
|
||||||
|
// 前端轮询与掉单补偿定时器共用这一份,避免两处「查单→落态」逻辑漂移。
|
||||||
|
func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) {
|
||||||
|
wc := h.pay.Current()
|
||||||
|
if o.Status != store.OrderPending || wc == nil {
|
||||||
|
return o, false
|
||||||
|
}
|
||||||
if r, err := wc.QueryOrder(ctx, o.ID); err == nil {
|
if r, err := wc.QueryOrder(ctx, o.ID); err == nil {
|
||||||
switch {
|
switch {
|
||||||
case r.Paid && r.AmountFen == o.AmountFen:
|
case r.Paid && r.AmountFen == o.AmountFen:
|
||||||
@@ -101,8 +117,7 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) {
|
|||||||
o, _ = h.db.GetOrder(ctx, o.ID)
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
}
|
}
|
||||||
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
|
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
|
||||||
c.JSON(http.StatusOK, gin.H{"order": o, "warn": "支付金额与订单不符,已挂起待人工核对"})
|
return o, true
|
||||||
return
|
|
||||||
case r.Closed:
|
case r.Closed:
|
||||||
_ = h.db.ExpireOrder(ctx, o.ID)
|
_ = h.db.ExpireOrder(ctx, o.ID)
|
||||||
o, _ = h.db.GetOrder(ctx, o.ID)
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
@@ -112,8 +127,7 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) {
|
|||||||
_ = h.db.ExpireOrder(ctx, o.ID)
|
_ = h.db.ExpireOrder(ctx, o.ID)
|
||||||
o, _ = h.db.GetOrder(ctx, o.ID)
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
}
|
}
|
||||||
}
|
return o, false
|
||||||
c.JSON(http.StatusOK, gin.H{"order": o})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WechatCallback: POST /api/v1/billing/callback/wechat —— 微信支付回调(公开路由,验签是唯一的门)。
|
// WechatCallback: POST /api/v1/billing/callback/wechat —— 微信支付回调(公开路由,验签是唯一的门)。
|
||||||
@@ -262,3 +276,25 @@ func (h *Handler) AdminPacks(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"packs": rows})
|
c.JSON(http.StatusOK, gin.H{"packs": rows})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminOrders: GET /api/v1/admin/orders?status= —— 全平台充值订单流 + 状态计数(P5.3 观测)。
|
||||||
|
func (h *Handler) AdminOrders(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rows, err := h.db.AllOrders(ctx, c.Query("status"), 50)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"orders": rows, "stats": h.db.OrderStats(ctx)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReconcile: GET /api/v1/admin/orders/reconcile —— 日终对账(P5.3)。
|
||||||
|
// paid 订单 ↔ 账本 grant 分录逐单比对,列出对不上的(正常应为空)。
|
||||||
|
func (h *Handler) AdminReconcile(c *gin.Context) {
|
||||||
|
rows, err := h.db.ReconcileOrders(c.Request.Context(), 200)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 掉单补偿(P5.3,设计见 PAYMENT_DESIGN.md §5):
|
||||||
|
// 前端轮询只在「用户开着账单页」时才查单确认——用户扫完码就关页面的话,钱付了、
|
||||||
|
// 订单却永远挂 pending、积分永远不到账。这个后台定时器把「用户在不在场」从入账链路
|
||||||
|
// 里摘掉:周期扫 pending 微信单,逐单 reconcileOrder(与前端轮询同一份幂等落态逻辑)。
|
||||||
|
|
||||||
|
const reconcileInterval = 1 * time.Minute
|
||||||
|
|
||||||
|
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
|
||||||
|
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
|
||||||
|
func (h *Handler) StartReconcile(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(reconcileInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
h.reconcilePending(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("[payment] 掉单补偿定时器已启动(每 %s 扫一次 pending 微信单)", reconcileInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcilePending 扫一轮待补偿的 pending 微信单。渠道未配置时直接返回(不打扰)。
|
||||||
|
func (h *Handler) reconcilePending(ctx context.Context) {
|
||||||
|
if h.pay.Current() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := h.db.PendingWechatOrders(ctx, 200)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[payment] 补偿扫描取 pending 单失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var paid, expired, mismatch int
|
||||||
|
for i := range orders {
|
||||||
|
o := &orders[i]
|
||||||
|
updated, mm := h.reconcileOrder(ctx, o)
|
||||||
|
switch {
|
||||||
|
case mm:
|
||||||
|
mismatch++
|
||||||
|
log.Printf("[payment] ⚠️ 订单 %s 支付金额与订单不符,已挂起待人工对账", o.ID)
|
||||||
|
case updated.Status == "paid":
|
||||||
|
paid++
|
||||||
|
case updated.Status == "expired":
|
||||||
|
expired++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只在有变化时记一行,避免空转刷屏。
|
||||||
|
if paid+expired+mismatch > 0 {
|
||||||
|
log.Printf("[payment] 补偿扫描:入账 %d、过期 %d、金额不符 %d(本轮 %d 单)", paid, expired, mismatch, len(orders))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
h := handler.New(db, cache, bus, blobStore)
|
h := handler.New(db, cache, bus, blobStore)
|
||||||
// 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。
|
// 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。
|
||||||
h.InitWechat(context.Background())
|
h.InitWechat(context.Background())
|
||||||
|
// 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。
|
||||||
|
h.StartReconcile(context.Background())
|
||||||
|
|
||||||
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
|
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
|
||||||
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||||
@@ -140,6 +142,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.PUT("/packs", h.AdminSavePack)
|
admin.PUT("/packs", h.AdminSavePack)
|
||||||
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
admin.GET("/payment/wechat", h.AdminGetWechatPay)
|
||||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||||
|
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
|
||||||
|
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
|
||||||
// 多租户成员管理(平台运维口径)
|
// 多租户成员管理(平台运维口径)
|
||||||
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
||||||
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
||||||
|
|||||||
@@ -249,6 +249,22 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
|||||||
return changed, err
|
return changed, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PendingWechatOrders 捞出所有 pending 的微信订单(掉单补偿定时器扫描用)。
|
||||||
|
// 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。
|
||||||
|
func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
var out []PaymentOrder
|
||||||
|
err := p.db.WithContext(WithoutTenant(ctx)).
|
||||||
|
Where("status = ? AND channel = ?", OrderPending, ChannelWechat).
|
||||||
|
Order("created_at asc").Limit(limit).Find(&out).Error
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// ExpireOrder 把超时未付的 pending 单置为 expired(CAS,已 paid 的不动)。
|
// ExpireOrder 把超时未付的 pending 单置为 expired(CAS,已 paid 的不动)。
|
||||||
func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) error {
|
func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) error {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
@@ -259,6 +275,102 @@ func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) error {
|
|||||||
Update("status", OrderExpired).Error
|
Update("status", OrderExpired).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrderSummary 是 admin 订单流一行(带租户名,免前端二次查)。
|
||||||
|
type OrderSummary struct {
|
||||||
|
PaymentOrder
|
||||||
|
TenantName string `json:"tenant_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllOrders 全平台充值订单流(admin 观测;可按状态过滤)。倒序,翻页。
|
||||||
|
func (p *Postgres) AllOrders(ctx context.Context, status string, limit int) ([]OrderSummary, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
q := p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_payment_order o").
|
||||||
|
Select("o.*, t.name as tenant_name").
|
||||||
|
Joins("LEFT JOIN sundynix_tenant t ON t.id = o.tenant_id").
|
||||||
|
Where("o.deleted_at IS NULL")
|
||||||
|
if status != "" {
|
||||||
|
q = q.Where("o.status = ?", status)
|
||||||
|
}
|
||||||
|
var out []OrderSummary
|
||||||
|
err := q.Order("o.created_at desc").Limit(limit).Scan(&out).Error
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderStats 全平台订单状态计数(观测卡片:pending/paid/expired 分布 + 累计到账额)。
|
||||||
|
type OrderStats struct {
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
Paid int64 `json:"paid"`
|
||||||
|
Expired int64 `json:"expired"`
|
||||||
|
PaidFenTotal int64 `json:"paid_fen_total"` // 累计到账金额(分),只算真渠道 amount_fen>0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) OrderStats(ctx context.Context) OrderStats {
|
||||||
|
var s OrderStats
|
||||||
|
if p.db == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
ctx = WithoutTenant(ctx)
|
||||||
|
// 每次都起新 query builder:复用同一个会累加 WHERE(status=A AND status=B → 恒 0)。
|
||||||
|
countBy := func(status string) int64 {
|
||||||
|
var n int64
|
||||||
|
p.db.WithContext(ctx).Model(&PaymentOrder{}).Where("status = ?", status).Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
s.Pending = countBy(OrderPending)
|
||||||
|
s.Paid = countBy(OrderPaid)
|
||||||
|
s.Expired = countBy(OrderExpired)
|
||||||
|
p.db.WithContext(ctx).Model(&PaymentOrder{}).Where("status = ?", OrderPaid).
|
||||||
|
Select("coalesce(sum(amount_fen),0)").Scan(&s.PaidFenTotal)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileRow 对账差异一行:paid 订单在账本里找不到对应 grant 分录(或反之)。
|
||||||
|
type ReconcileRow struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
CreditsMicro int64 `json:"credits_micro"`
|
||||||
|
Issue string `json:"issue"` // order_without_ledger / ledger_without_order
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileOrders 日终对账:paid 订单 ↔ ledger(kind=grant, ref=订单号) 逐单比对,列出对不上的。
|
||||||
|
// 正常应返回空列表(双闸保证 paid 单必有且仅有一条 grant 分录)。有差异即数据出了问题,需人工查。
|
||||||
|
func (p *Postgres) ReconcileOrders(ctx context.Context, limit int) ([]ReconcileRow, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
ctx = WithoutTenant(ctx)
|
||||||
|
var out []ReconcileRow
|
||||||
|
// paid 订单但账本无对应 grant 分录(钱记了、积分没到——最严重)
|
||||||
|
if err := p.db.WithContext(ctx).
|
||||||
|
Raw(`SELECT o.id AS order_id, o.tenant_id, o.credits_micro, 'order_without_ledger' AS issue
|
||||||
|
FROM sundynix_payment_order o
|
||||||
|
WHERE o.status='paid' AND o.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sundynix_credit_ledger l
|
||||||
|
WHERE l.kind='grant' AND l.ref=o.id AND l.deleted_at IS NULL)
|
||||||
|
ORDER BY o.created_at DESC LIMIT ?`, limit).Scan(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// grant 分录指向的订单不是 paid(积分到了、订单态不对——重复入账/状态错乱)
|
||||||
|
var out2 []ReconcileRow
|
||||||
|
if err := p.db.WithContext(ctx).
|
||||||
|
Raw(`SELECT l.ref AS order_id, l.tenant_id, l.credits_micro, 'ledger_without_paid_order' AS issue
|
||||||
|
FROM sundynix_credit_ledger l
|
||||||
|
JOIN sundynix_payment_order o ON o.id = l.ref
|
||||||
|
WHERE l.kind='grant' AND l.deleted_at IS NULL AND o.status <> 'paid'
|
||||||
|
ORDER BY l.created_at DESC LIMIT ?`, limit).Scan(&out2).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(out, out2...), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPack 按 id 取在售积分包(下单锁价用;下架的包不可下单)。
|
// GetPack 按 id 取在售积分包(下单锁价用;下架的包不可下单)。
|
||||||
func (p *Postgres) GetPack(ctx context.Context, id string) (*CreditPack, error) {
|
func (p *Postgres) GetPack(ctx context.Context, id string) (*CreditPack, error) {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user