bc9aa38392
- 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
177 lines
5.9 KiB
Go
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()
|
|
}
|