diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 0b5bdc7..e985966 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -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 { try { diff --git a/sundynix-admin/src/components/OrderStream.tsx b/sundynix-admin/src/components/OrderStream.tsx new file mode 100644 index 0000000..3834511 --- /dev/null +++ b/sundynix-admin/src/components/OrderStream.tsx @@ -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 = { + 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 = { paid: "已入账", pending: "待支付", expired: "已过期", failed: "失败", refunded: "已退款" }; +const CHANNEL_LABEL: Record = { redeem: "兑换码", wechat: "微信支付" }; + +export function OrderStream() { + const [orders, setOrders] = useState([]); + const [stats, setStats] = useState({ pending: 0, paid: 0, expired: 0, paid_fen_total: 0 }); + const [filter, setFilter] = useState(""); + const [err, setErr] = useState(""); + // 对账结果:null=未跑;[]=零差异;有元素=有差异 + const [diffs, setDiffs] = useState(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 ( +
+
+

充值订单与对账

+ 全平台充值单落地视角 + 账本一致性核对 +
+ + {/* 状态计数 */} +
+ + + + +
+ +
+
+
+ {[ + ["", "全部"], + ["paid", "已入账"], + ["pending", "待支付"], + ["expired", "已过期"], + ].map(([v, label]) => ( + + ))} +
+ +
+ {err &&

{err}

} + + {/* 对账结果条 */} + {diffs !== null && ( + diffs.length === 0 ? ( +
+ ✅ 对账通过:所有已入账订单都能在账本找到对应积分分录,零差异。 +
+ ) : ( +
+
⚠️ 发现 {diffs.length} 处对账差异(需人工核查)
+
+ {diffs.map((d) => ( +
+ {d.order_id} · {credits(d.credits_micro)} 积分 · {d.issue === "order_without_ledger" ? "订单已付但账本无入账(钱到了积分没给)" : "账本有入账但订单非已付(状态错乱)"} +
+ ))} +
+
+ ) + )} + +
+ + + + + + + + + + + + + {orders.map((o) => ( + + + + + + + + + ))} + {orders.length === 0 && ( + + + + )} + +
时间租户渠道积分金额状态
{new Date(o.created_at).toLocaleString("zh-CN")}{o.tenant_name || o.tenant_id}{CHANNEL_LABEL[o.channel] ?? o.channel}{credits(o.credits_micro)}{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"} + + {STATUS_LABEL[o.status] ?? o.status} + +
暂无充值订单
+
+
+
+ ); +} + +const TONE: Record = { 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 ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/pages/UsagePage.tsx index 6b3767e..63e1c77 100644 --- a/sundynix-admin/src/pages/UsagePage.tsx +++ b/sundynix-admin/src/pages/UsagePage.tsx @@ -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() { {/* 配置端:计费规则(改规则即对后续任务生效) */} void load()} /> - {/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价,P5.1) */} + {/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价 + 微信配置,P5.1/P5.2) */} + {/* 观测端:充值订单流 + 对账(P5.3) */} + + {/* 观测端:用量结果 */}

用量观测

diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 113a55b..2fb6549 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -1,6 +1,7 @@ package handler import ( + "context" "net/http" "strings" "time" @@ -93,27 +94,40 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"}) return } - if wc := h.pay.Current(); o.Status == store.OrderPending && wc != nil { - if r, err := wc.QueryOrder(ctx, o.ID); err == nil { - switch { - case r.Paid && r.AmountFen == o.AmountFen: - if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil { - o, _ = h.db.GetOrder(ctx, o.ID) - } - case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜) - c.JSON(http.StatusOK, gin.H{"order": o, "warn": "支付金额与订单不符,已挂起待人工核对"}) - return - case r.Closed: - _ = h.db.ExpireOrder(ctx, o.ID) + 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 { + switch { + case r.Paid && r.AmountFen == o.AmountFen: + if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil { o, _ = h.db.GetOrder(ctx, o.ID) } - } - if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL { + case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜) + return o, true + case r.Closed: _ = h.db.ExpireOrder(ctx, o.ID) o, _ = h.db.GetOrder(ctx, o.ID) } } - c.JSON(http.StatusOK, gin.H{"order": o}) + if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL { + _ = h.db.ExpireOrder(ctx, o.ID) + o, _ = h.db.GetOrder(ctx, o.ID) + } + return o, false } // 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}) } + +// 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}) +} diff --git a/sundynix-gateway/internal/handler/payment_reconcile.go b/sundynix-gateway/internal/handler/payment_reconcile.go new file mode 100644 index 0000000..79e854c --- /dev/null +++ b/sundynix-gateway/internal/handler/payment_reconcile.go @@ -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)) + } +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index cfccd1d..37316bd 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -35,6 +35,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. h := handler.New(db, cache, bus, blobStore) // 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。 h.InitWechat(context.Background()) + // 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。 + h.StartReconcile(context.Background()) // 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。 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.GET("/payment/wechat", h.AdminGetWechatPay) 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.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner) diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go index 8701102..589001c 100644 --- a/sundynix-gateway/internal/store/payment.go +++ b/sundynix-gateway/internal/store/payment.go @@ -249,6 +249,22 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string 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 的不动)。 func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) error { if p.db == nil { @@ -259,6 +275,102 @@ func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) 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 取在售积分包(下单锁价用;下架的包不可下单)。 func (p *Postgres) GetPack(ctx context.Context, id string) (*CreditPack, error) { if p.db == nil {