Files
pangolin/server/internal/pay/webhook.go
T
wangjia bc9aa38392 fix(server): pay webhook 兜底补行不再漏停用用户(修静默丢钱)+ 未知事件日志 + PAY_BIZ_SECRET 启动守卫
- webhook 兜底路径(台账缺行按 biz_ref 定位用户)去掉 status='active' 过滤,
  与正常路径(row.UserID 直接开通不看状态)对齐:钱已实收,不因用户被停用
  (banned)而拒绝补行开通,避免 500→pay 12 次重投后死信→静默丢钱。
- 未知 event_type 分支加 slog.Warn,便于将来 pay 侧误注册无 handler 的事件
  类型时能被观测到。
- PAY_BASE_URL 已设但 PAY_BIZ_SECRET 为空时 log.Fatal 拒绝启动,避免出站
  签名失败+入站验签全 401 的静默瘫痪。
- InsertFromWebhookTx 不再把 channel 冒充 method 写入台账(payload 无 method
  字段可复原,留空并加注释,消除台账观感误导)。

新增 TestWebhook_MissingLedgerFallsBackEvenWhenUserSuspended 覆盖 #1:
台账缺行 + 目标用户 banned 时,兜底补行仍成功开通并回 SUCCESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 00:48:57 +08:00

177 lines
5.9 KiB
Go

package pay
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/codes"
)
// Granter 抽象 codes.Service 的支付授予入口(测试可替身;生产传 *codes.Service)。
type Granter interface {
GrantPaidSubscriptionTx(ctx context.Context, tx *sql.Tx, userID int64, plan codes.PlanCode, days int, ref string) (int64, time.Time, error)
}
// WebhookHandler 接收 pay 的 payment.succeeded 出站 webhook。
// 验签与 pay verifyBizSign 对称:同 secret,parts=[system, ts, nonce, rawBody],
// ±tolerance 时间窗。幂等三层:nonce SETNX(传输重放)→ out_trade_no 锁内
// CAS(业务幂等,重投唯一可靠键)→ biz_ref 兜底(台账缺行自修复)。
type WebhookHandler struct {
store *Store
granter Granter
db *sql.DB
rdb *redis.Client // 可为 nil:跳过 nonce 层,业务幂等仍成立
system string
secret string
tolerance time.Duration
nonceTTL time.Duration
now func() time.Time // 测试注入
}
func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client,
system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler {
return &WebhookHandler{store: store, granter: granter, db: db, rdb: rdb,
system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now}
}
// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload
// (注意:payment.succeeded 无 refund_id 字段)。
type webhookEvent struct {
EventType string `json:"event_type"`
OutTradeNo string `json:"out_trade_no"`
BizSystem string `json:"biz_system"`
BizRef string `json:"biz_ref"`
ProductBizCode string `json:"product_biz_code"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Channel string `json:"channel"`
PaidAt string `json:"paid_at"` // RFC3339
}
func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
if !h.verify(r, body) {
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}
// nonce 防重放(仅传输层;pay 每次重投换新 nonce,业务幂等靠 out_trade_no)。
if h.rdb != nil {
if nonce := r.Header.Get("X-Pay-Nonce"); nonce != "" {
ok, err := h.rdb.SetNX(r.Context(), "pay:webhook:nonce:"+nonce, 1, h.nonceTTL).Result()
if err == nil && !ok {
writeSuccess(w) // 同 nonce 重放:已处理过,直接确认
return
}
}
}
var ev webhookEvent
if err := json.Unmarshal(body, &ev); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if ev.EventType != "payment.succeeded" {
// 事件白名单外(pay 侧只应配 payment.succeeded):确认不处理,免重投。
slog.Warn("pay webhook: 未知事件已 ack 未处理", "event_type", ev.EventType, "out_trade_no", ev.OutTradeNo)
writeSuccess(w)
return
}
if err := h.settle(r.Context(), &ev); err != nil {
slog.Error("pay webhook 开通失败(pay 将退避重投)", "order_no", ev.OutTradeNo, "err", err)
http.Error(w, "settle failed", http.StatusInternalServerError)
return
}
writeSuccess(w)
}
// writeSuccess:pay 的 ACK 判据是 HTTP 200 且 body 含 "SUCCESS"(大写包含)。
func writeSuccess(w http.ResponseWriter) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("SUCCESS"))
}
func (h *WebhookHandler) verify(r *http.Request, body []byte) bool {
if r.Header.Get("X-Pay-System") != h.system {
return false
}
ts := r.Header.Get("X-Pay-Timestamp")
nonce := r.Header.Get("X-Pay-Nonce")
sign := r.Header.Get("X-Pay-Sign")
if ts == "" || nonce == "" || sign == "" {
return false
}
tsi, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
tol := int64(h.tolerance.Seconds())
if d := h.now().Unix() - tsi; d > tol || d < -tol {
return false
}
return hmacVerify(h.secret, sign, h.system, ts, nonce, string(body))
}
// settle 幂等开通:锁台账行 → created→paid 翻转 + 同事务 grant(叠加语义
// 复用 codes.applySubscription)。已 paid 直接返回 nil(重投/并发输家)。
// canceled 行也照常开通——钱已实收,本地 cancel 只是未支付单的整理。
func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error {
item, ok := CatalogBySKU(ev.ProductBizCode)
if !ok {
return fmt.Errorf("未知 product_biz_code %q(与 pay 种子漂移?)", ev.ProductBizCode)
}
tx, err := h.store.BeginTx(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var purchaseID, userID int64
row, err := h.store.LockByOutTradeNoTx(ctx, tx, ev.OutTradeNo)
switch {
case err == sql.ErrNoRows:
// 台账缺行(下单后本地写失败)→ 按 biz_ref=用户 uuid 兜底定位补建。
// 与正常路径(row.UserID 直接开通,不看 user 状态)对齐:钱已实收,
// 兜底定位不应因用户被停用(suspended)而拒绝开通——否则静默丢钱。
if err := h.db.QueryRowContext(ctx,
`SELECT id FROM users WHERE uuid = ?`, ev.BizRef).Scan(&userID); err != nil {
return fmt.Errorf("biz_ref %q 定位用户失败: %w", ev.BizRef, err)
}
purchaseID, err = h.store.InsertFromWebhookTx(ctx, tx, userID, ev.BizRef, ev.ProductBizCode, ev.OutTradeNo, ev.Channel)
if err != nil {
return err
}
case err != nil:
return err
case row.Status == "paid":
return nil // 幂等:已消费,直接 SUCCESS
default:
purchaseID, userID = row.ID, row.UserID
}
subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID,
codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo)
if err != nil {
return err
}
paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt)
if perr != nil {
paidAt = h.now().UTC()
}
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
return err
}
return tx.Commit()
}