b5ab92a57e
来自 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>
254 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|