Files
dudu/server/internal/billing/billing.go
T
wangjia b5ab92a57e
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s
fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端:
- server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、
  billing 行锁、redis Lua 过期与设备槽刷新、config 解析
- desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询
- android:握手时序、请求头封装、账户状态派生、按需重组
- ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:50:08 +08:00

254 lines
8.5 KiB
Go

// 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)
}
}
}
}
}()
}