// Package billing 时长包查询、下单与微信支付回调入账。 // PayClient 抽象真实微信支付(wechatpay-go,2B 商户号就绪后接入),开发期 MockPay。 // 设计见 doc/backend-architecture.html 3.3 与第五章。 package billing import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" "dudu/server/internal/auth" "dudu/server/internal/quota" "dudu/server/internal/store" "dudu/server/pkg/protocol" ) // NotifyResult 支付回调验签后的结果。 type NotifyResult struct { OrderID string TransactionID string AmountCents int } type PayClient interface { CreateNative(ctx context.Context, orderID string, amountCents int, desc string) (codeURL string, err error) CreateApp(ctx context.Context, orderID string, amountCents int, desc string) (params map[string]any, err error) // ParseNotify 验签并解析回调;签名不合法返回 error ParseNotify(r *http.Request) (NotifyResult, error) // Query 主动查单(兜底):已支付返回 transactionID Query(ctx context.Context, orderID string) (paid bool, transactionID string, amountCents int, err error) } // MockPay 开发期实现:回调体为明文 JSON {order_id, transaction_id, amount_cents}。 type MockPay struct{} func (MockPay) CreateNative(_ context.Context, orderID string, _ int, _ string) (string, error) { return "weixin://wxpay/mock/" + orderID, nil } func (MockPay) CreateApp(_ context.Context, orderID string, amountCents int, _ string) (map[string]any, error) { return map[string]any{"mock": true, "order_id": orderID, "amount": amountCents}, nil } func (MockPay) ParseNotify(r *http.Request) (NotifyResult, error) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { return NotifyResult{}, err } var p struct { OrderID string `json:"order_id"` TransactionID string `json:"transaction_id"` AmountCents int `json:"amount_cents"` } if err := json.Unmarshal(body, &p); err != nil || p.OrderID == "" || p.TransactionID == "" { return NotifyResult{}, errors.New("invalid mock notify") } return NotifyResult{OrderID: p.OrderID, TransactionID: p.TransactionID, AmountCents: p.AmountCents}, nil } func (MockPay) Query(context.Context, string) (bool, string, int, error) { return false, "", 0, nil } // ─── Handlers ──────────────────────────────────────────────────────────────── type Handlers struct { DB *gorm.DB Pay PayClient Quota *quota.Manager } // Packs GET /v1/packs 🔓 func (h *Handlers) Packs(c *gin.Context) { var rows []store.DurationPack if err := h.DB.Where("active").Order("sort").Find(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } resp := protocol.PacksResponse{Packs: make([]protocol.Pack, 0, len(rows))} for _, p := range rows { resp.Packs = append(resp.Packs, protocol.Pack{ ID: p.ID, Minutes: p.Minutes, PriceCents: p.PriceCents, UnitDesc: p.UnitDesc, Tag: p.Tag, }) } c.JSON(http.StatusOK, resp) } // CreateOrder POST /v1/orders(需登录) func (h *Handlers) CreateOrder(c *gin.Context) { var req protocol.CreateOrderRequest if err := c.ShouldBindJSON(&req); err != nil || (req.Channel != protocol.PayChannelNative && req.Channel != protocol.PayChannelApp) { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } var pack store.DurationPack if err := h.DB.Where("active").First(&pack, "id = ?", req.PackID).Error; err != nil { c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) return } order := store.Order{ ID: "o" + strings.ReplaceAll(uuid.NewString(), "-", "")[:23], UserID: auth.UserID(c), PackID: pack.ID, PriceCents: pack.PriceCents, Channel: req.Channel, Status: store.OrderPending, } if err := h.DB.Create(&order).Error; err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } desc := "dudu 时长包 " + pack.ID resp := protocol.CreateOrderResponse{OrderID: order.ID} var err error if req.Channel == protocol.PayChannelNative { resp.CodeURL, err = h.Pay.CreateNative(c, order.ID, pack.PriceCents, desc) } else { resp.PayParams, err = h.Pay.CreateApp(c, order.ID, pack.PriceCents, desc) } if err != nil { c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) return } c.JSON(http.StatusOK, resp) } // OrderStatus GET /v1/orders/:id(需登录,桌面轮询) func (h *Handlers) OrderStatus(c *gin.Context) { var order store.Order if err := h.DB.First(&order, "id = ? AND user_id = ?", c.Param("id"), auth.UserID(c)).Error; err != nil { c.JSON(http.StatusNotFound, protocol.NewAPIError(protocol.ErrBadRequest)) return } resp := protocol.OrderStatusResponse{Status: order.Status} if order.Status == store.OrderPaid { if snap, err := h.Quota.Get(c, order.UserID); err == nil { resp.BalanceSeconds = snap.BalanceSeconds } } c.JSON(http.StatusOK, resp) } // Notify POST /v1/pay/notify 🔓 微信支付回调:验签 → 幂等入账。 func (h *Handlers) Notify(c *gin.Context) { res, err := h.Pay.ParseNotify(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "签名验证失败"}) return } if err := h.MarkPaid(c, res); err != nil { slog.Error("mark paid failed", "err", err, "order", res.OrderID) c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "入账失败"}) return } c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) } // ErrAmountMismatch 金额与订单不符(拒绝入账并告警)。 var ErrAmountMismatch = errors.New("amount mismatch") // MarkPaid 幂等入账:pending→paid + 时长 ledger + 余额冗余列,同一事务。 // 重复回调(订单已 paid 或 transaction_id 已存在)直接返回成功。 func (h *Handlers) MarkPaid(ctx context.Context, res NotifyResult) error { var credited bool var userID string var seconds int64 err := h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order store.Order // GORM v2 行锁:v1 的 Set("gorm:query_option","FOR UPDATE") 在 v2 静默失效(16F)。 if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&order, "id = ?", res.OrderID).Error; err != nil { return err } if order.Status == store.OrderPaid { return nil // 幂等 } if order.PriceCents != res.AmountCents { return ErrAmountMismatch } var pack store.DurationPack if err := tx.First(&pack, "id = ?", order.PackID).Error; err != nil { return err } now := time.Now() r := tx.Model(&store.Order{}). Where("id = ? AND status = ?", order.ID, store.OrderPending). Updates(map[string]any{"status": store.OrderPaid, "transaction_id": res.TransactionID, "paid_at": now}) if r.Error != nil { return r.Error } if r.RowsAffected == 0 { return nil // 并发回调已处理 } seconds = int64(pack.Minutes) * 60 userID = order.UserID oid := order.ID if err := tx.Create(&store.BalanceLedger{ UserID: userID, DeltaSeconds: seconds, Reason: store.LedgerPurchase, OrderID: &oid, }).Error; err != nil { return err } if err := tx.Model(&store.User{}).Where("id = ?", userID). UpdateColumn("balance_seconds", gorm.Expr("balance_seconds + ?", seconds)).Error; err != nil { return err } credited = true return nil }) if err != nil { return err } if credited { // 失效 Redis 余额键 → 下次读取从 DB 重载(避免双写竞态) _ = h.Quota.InvalidateBalance(ctx, userID) } return nil } // StartWatchdog 查单兜底:每分钟扫描 pending 超 5 分钟的订单主动查单(防回调丢失)。 func (h *Handlers) StartWatchdog(ctx context.Context) { go func() { t := time.NewTicker(time.Minute) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: var orders []store.Order cutoff := time.Now().Add(-5 * time.Minute) if err := h.DB.Where("status = ? AND created_at < ?", store.OrderPending, cutoff). Limit(100).Find(&orders).Error; err != nil { continue } for _, o := range orders { paid, txn, amount, err := h.Pay.Query(ctx, o.ID) if err != nil || !paid { continue } if err := h.MarkPaid(ctx, NotifyResult{OrderID: o.ID, TransactionID: txn, AmountCents: amount}); err != nil { slog.Error("watchdog mark paid failed", "err", err, "order", o.ID) } } } } }() }