Files
sundynix-agentix/sundynix-gateway/internal/handler/billing_pay.go
T
Blizzard 70b0419387 fix(payment): 支付轮询链路收口——查单节流 + 前端轮询重写
后端:
- 主动查单加最小间隔(5s)。此前 BillingOrderStatus 见 pending 就直连微信查单,
  而前端 2.5s 轮一次 —— 单笔订单在 30min TTL 内可打出约 720 次渠道调用,
  微信侧有频控,并发用户一多先被限流的是我们自己。回调才是入账主路径,
  查单只是兜底,节流最坏只让在场用户多等 5s。
- 标记随订单落终态清除,并在补偿定时器每轮 prune 掉超 TTL 的残留
  (用户扫码前就关弹窗的订单不会再被轮询,其标记无人回收)。
- 下单响应补 expires_at:二维码有效期由服务端 orderTTL 说了算,
  前端硬编码一份迟早漂移。

前端(sundynix-web):
- orderStatus 不再丢掉 warn。服务端在「已付但金额与订单不符」时不入账、
  挂起人工核对,订单一直停在 pending —— 丢掉 warn 用户就会一直等一个
  永远不会来的结果。现在弹窗显式告警并给出订单号。
- setInterval → 递归 setTimeout:请求慢于间隔时 setInterval 会把请求摞起来,
  对「每次可能触发渠道查单」的端点尤其糟。
- 标签页切走时暂停轮询(用户扫完码要切去微信 App),切回前台立刻补查一次。
- 连续失败指数退避至 15s 封顶,断网时不再定频猛打。
- 二维码倒计时 + 失效遮罩,过期不再让用户扫一个必然失败的码。
- 明说「关掉也不会丢钱,到账会自动补上」——掉单补偿定时器本就兜底,
  但此前 UI 没讲,用户只能守着弹窗。
- 关闭弹窗一律刷新余额:用户可能在关闭前一刻付款、状态刚落地。

测试:新增 handler 包首个测试,钉住节流与标记回收行为(-race 通过)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:46:40 +08:00

368 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// 充值(P5.1:兑换码渠道;设计见 PAYMENT_DESIGN.md)。
// 入账目标一律是「计费租户」(ResolveBillingTenantID)——和消耗记账同一本账,
// 谁的池子扣钱就往谁的池子充,别让用户充进一个花不到的池。
// BillingPacks: GET /api/v1/billing/packs —— 在售积分包 + 可用渠道(wechat 配了 env 才亮)。
func (h *Handler) BillingPacks(c *gin.Context) {
packs, err := h.db.ActivePacks(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 兑换码渠道始终可用(无需配置);真渠道由 Manager 报告哪些已启用。
channels := append([]string{store.ChannelRedeem}, h.pay.Available()...)
c.JSON(http.StatusOK, gin.H{"packs": packs, "channels": channels})
}
// orderTTL 待支付订单的有效期:过期后前端轮询会把它置 expired,不再确认到账。
// 微信 Native 的 code_url 本身约 2 小时有效,这里收紧到 30 分钟——挂太久的单
// 价格可能已经改过,不让旧价格的单无限期可付。
const orderTTL = 30 * time.Minute
// BillingCreateOrder: POST /api/v1/billing/orders {pack_id, channel?} —— 真渠道下单,返回支付凭据。
// channel 缺省 wechat(当前唯一真渠道);金额/积分由服务端按在售包锁定进订单行,前端只传包 id,
// 不信任任何客户端金额。订单记下所用渠道,后续查单/回调/补偿据此路由。
func (h *Handler) BillingCreateOrder(c *gin.Context) {
var b struct {
PackID string `json:"pack_id"`
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"})
return
}
channel := strings.TrimSpace(b.Channel)
if channel == "" {
channel = store.ChannelWechat
}
ch := h.pay.Get(channel)
if ch == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "渠道 " + channel + " 未启用,请换渠道或用兑换码充值"})
return
}
ctx := c.Request.Context()
uid := userID(c)
billing := h.db.ResolveBillingTenantID(ctx, uid, tenantID(c))
if billing == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
return
}
pk, err := h.db.GetPack(ctx, b.PackID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
return
}
o := &store.PaymentOrder{
TenantID: billing, UserID: uid, PackID: pk.ID,
AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
Channel: channel, Status: store.OrderPending,
}
if err := h.db.CreateOrder(ctx, o); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
intent, err := ch.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen)
if err != nil {
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
_ = h.db.ExpireOrder(ctx, o.ID)
c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()})
return
}
// expires_at 由后端下发:二维码有效期是服务端的 orderTTL 说了算,前端硬编码一份迟早漂移。
c.JSON(http.StatusOK, gin.H{
"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen,
"expires_at": o.CreatedAt.Add(orderTTL),
})
}
// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。
// pending 时顺路主动查单确认(本地/内网收不到公网回调也能到账——回调只是生产更快的通道,
// 两条路汇入同一个 MarkOrderPaid 幂等闸);超过 TTL 置 expired。
func (h *Handler) BillingOrderStatus(c *gin.Context) {
ctx := c.Request.Context()
o, err := h.db.GetOrder(ctx, c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
return
}
// 只允许看自己计费租户的单(订单表未挂租户插件,这里显式校验)。
if o.TenantID != h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c)) {
c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"})
return
}
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) {
ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信
if o.Status != store.OrderPending || ch == nil {
forgetQuery(o.ID)
return o, false
}
// 节流:间隔内跳过真实查单,只走下面本地可判定的 TTL 过期。回调是主路径,
// 少查几次不影响到账,只影响「用户在场时的确认延迟」,最坏多等 5s。
if allowQuery(o.ID) {
if r, err := ch.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: // 金额对不上:不入账,人工对账(比错账便宜)
return o, true
case r.Closed:
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
}
}
if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL {
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
if o.Status != store.OrderPending {
forgetQuery(o.ID) // 已落终态,标记没用了
}
return o, false
}
// PaymentCallback: POST /api/v1/billing/callback/:channel —— 渠道支付回调(公开路由,验签是唯一的门)。
// 按 :channel 路由到对应适配器验签。应答契约当前为微信形态({code:SUCCESS}/{code:FAIL}):
// 入账成功/重复推送都回 200 SUCCESS;验签失败 4xx;处理失败 5xx 让渠道重试。
// 注:其它渠道的 ack 格式不同(如支付宝要求纯文本 "success"),接入时按渠道分应答即可。
func (h *Handler) PaymentCallback(c *gin.Context) {
ch := h.pay.Get(c.Param("channel"))
if ch == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": "FAIL", "message": "渠道未配置"})
return
}
r, err := ch.VerifyCallback(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "验签失败"})
return
}
if !r.Paid {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) // 非成功态通知:确认收到即可
return
}
ctx := c.Request.Context()
o, err := h.db.GetOrder(ctx, r.OrderID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) // 不认识的单:可能是别的环境,别让微信无限重试
return
}
if r.AmountFen != o.AmountFen {
// 金额不符:不入账、不让重试(重试也不会变对),落审计人工处理。
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
return
}
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "入账失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
}
// BillingRedeem: POST /api/v1/billing/redeem {code} —— 核销兑换码,积分入计费租户。
func (h *Handler) BillingRedeem(c *gin.Context) {
var b struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Code) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code 必填"})
return
}
ctx := c.Request.Context()
uid := userID(c)
billing := h.db.ResolveBillingTenantID(ctx, uid, tenantID(c))
if billing == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
return
}
order, err := h.db.Redeem(ctx, b.Code, billing, uid)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"order": order,
"balance_micro": h.db.TenantBalance(ctx, billing),
})
}
// BillingOrders: GET /api/v1/billing/orders —— 计费租户最近充值记录(账单页展示)。
func (h *Handler) BillingOrders(c *gin.Context) {
ctx := c.Request.Context()
billing := h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c))
rows, err := h.db.TenantOrders(ctx, billing, 20)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"orders": rows})
}
// AdminGenRedeemCodes: POST /api/v1/admin/redeem-codes {credits, count, memo} —— 批量生成兑换码。
// credits 单位:积分(面向人,非 micro)。
func (h *Handler) AdminGenRedeemCodes(c *gin.Context) {
var b struct {
Credits float64 `json:"credits"`
Count int `json:"count"`
Memo string `json:"memo"`
}
if err := c.ShouldBindJSON(&b); err != nil || b.Credits <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "credits 必填且为正"})
return
}
if b.Count <= 0 {
b.Count = 1
}
codes, err := h.db.GenerateRedeemCodes(c.Request.Context(), b.Count, int64(b.Credits*1e6), b.Memo)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"codes": codes})
}
// AdminRedeemCodes: GET /api/v1/admin/redeem-codes —— 兑换码台账(含核销状态)。
// 码在台账里脱敏只露首尾:完整明文只在生成响应里给一次。兑换码等同现金,
// 常驻可查的列表接口不该是第二个明文出口(丢了码就重新生成一张,不提供找回)。
func (h *Handler) AdminRedeemCodes(c *gin.Context) {
rows, err := h.db.ListRedeemCodes(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
for i := range rows {
if n := len(rows[i].Code); n > 12 {
rows[i].Code = rows[i].Code[:8] + "…" + rows[i].Code[n-4:]
}
}
c.JSON(http.StatusOK, gin.H{"codes": rows})
}
// AdminSavePack: PUT /api/v1/admin/packs {id?, name, credits, price_fen, active, sort} —— 配积分包。
func (h *Handler) AdminSavePack(c *gin.Context) {
var b struct {
ID string `json:"id"`
Name string `json:"name"`
Credits float64 `json:"credits"` // 积分(面向人)
PriceFen int64 `json:"price_fen"`
Active bool `json:"active"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
pk := &store.CreditPack{Name: strings.TrimSpace(b.Name), CreditsMicro: int64(b.Credits * 1e6), PriceFen: b.PriceFen, Active: b.Active, Sort: b.Sort}
pk.ID = b.ID
if err := h.db.SavePack(c.Request.Context(), pk); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"pack": pk})
}
// AdminPacks: GET /api/v1/admin/packs —— 全部积分包(含下架)。
func (h *Handler) AdminPacks(c *gin.Context) {
rows, err := h.db.ListPacks(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
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})
}
// AdminTasks: GET /api/v1/admin/tasks?status=&tenant=&limit= —— 全平台任务/运行观测。
// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。
func (h *Handler) AdminTasks(c *gin.Context) {
ctx := c.Request.Context()
limit := 50
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit)
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
}
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
func (h *Handler) AdminSpaces(c *gin.Context) {
limit := 200
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)})
}
// AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。
// 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。
// 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作
// —— 本期不接自动退款 APIPAYMENT_DESIGN 明确不做),故 memo 里留操作痕迹。
func (h *Handler) AdminRefundOrder(c *gin.Context) {
id := c.Param("id")
var b struct {
Memo string `json:"memo"`
}
_ = c.ShouldBindJSON(&b) // memo 可选
changed, err := h.db.RefundOrder(c.Request.Context(), id, userID(c), b.Memo)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !changed {
// 幂等:本就无需退(已退 / 未支付 / 不存在)。据现状返回可读提示,不当错误。
c.JSON(http.StatusOK, gin.H{"status": "noop", "detail": "订单非已支付状态或已退款,未做冲销"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "refunded", "order_id": id})
}