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