feat(backend): pay webhook 升 v2 事件模型——event_type 分发、amount_minor 核对、nonce 防重放

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 20:47:55 +08:00
parent f5a70d08cc
commit 7f713c4c96
2 changed files with 192 additions and 74 deletions
+78 -31
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -19,8 +20,7 @@ import (
"github.com/wangjia/jiu/backend/internal/util"
)
// PayService jiu ↔ pay 收款中枢对接下单/查单已切 pay v2 契约 /api/v2/orders
// webhook 回调仍为 v1 契约,见 HandleCallback)。
// PayService jiu ↔ pay 收款中枢对接下单/查单/webhook 均为 pay v2 契约。
// 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。
type PayService struct {
db *gorm.DB
@@ -28,6 +28,9 @@ type PayService struct {
secret string
retURL string
client *http.Client
seenMu sync.Mutex // 保护 seen(nonce 防重放,单实例内存实现)
seen map[string]time.Time
}
var (
@@ -208,18 +211,21 @@ func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) {
// ---------- ② webhook 入账 ----------
// payNotification pay v2 webhook payload(无 trade_no 字段,事件以 event_type 为准,
// X-Pay-Event 头不参与签名不依赖)。
type payNotification 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"`
Amount string `json:"amount"`
TradeNo string `json:"trade_no"`
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
Channel string `json:"channel"`
PaidAt string `json:"paid_at"`
}
// HandleCallback 验签 + 幂等 + 金额核对 + 续期。错误分两类:
// HandleCallback 验签 + 时间窗 + nonce 防重放 + 按 event_type 分发。错误分两类:
// ErrPaySignature(回 401,不重试也无效);其余(回非 SUCCESS,pay 会重试)。
func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) error {
if !s.Configured() {
@@ -235,6 +241,9 @@ func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) erro
if d := time.Since(time.Unix(tsInt, 0)); d > 5*time.Minute || d < -5*time.Minute {
return ErrPaySignature
}
if s.replayed(nonce) {
return ErrPaySignature
}
var n payNotification
if err := json.Unmarshal(rawBody, &n); err != nil || n.OutTradeNo == "" {
@@ -244,12 +253,39 @@ func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) erro
if t, err := time.Parse(time.RFC3339, n.PaidAt); err == nil {
paidAt = t
}
return s.settle(n.OutTradeNo, n.ProductBizCode, n.Amount, n.TradeNo, n.Channel, paidAt)
switch n.EventType {
case "payment.succeeded":
return s.settle(n.OutTradeNo, n.ProductBizCode, n.AmountMinor, n.Currency, n.Channel, paidAt)
default: // refund.*/未来事件:本期不接(另起任务);ack 防 60s 永久重投,ALERT 留痕
log.Printf("[pay] ALERT unhandled webhook event=%s out_trade_no=%s (acked)", n.EventType, n.OutTradeNo)
return nil
}
}
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对 + 同事务续期。
// replayed nonce 防重放:10 分钟窗口内重复即拒绝(pay 合法重投每次生成新 nonce 不受影响;
// 单实例内存实现,重启丢失由 settle 幂等兜底)。
func (s *PayService) replayed(nonce string) bool {
s.seenMu.Lock()
defer s.seenMu.Unlock()
now := time.Now()
for k, t := range s.seen {
if now.Sub(t) > 10*time.Minute {
delete(s.seen, k)
}
}
if _, ok := s.seen[nonce]; ok {
return true
}
if s.seen == nil {
s.seen = map[string]time.Time{}
}
s.seen[nonce] = now
return false
}
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对(残单先兜底回填)+ 同事务续期。
// webhook 与查单兜底共用此入口。
func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string, paidAt time.Time) error {
func (s *PayService) settle(outTradeNo, bizCode string, amountMinor int64, currency, channel string, paidAt time.Time) error {
var shopID uint64
err := s.db.Transaction(func(tx *gorm.DB) error {
var p model.LicensePurchase
@@ -263,8 +299,17 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
if p.Status == "paid" { // 幂等:pay 会重发
return nil
}
if !amountEqual(p.Amount, amount) {
log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.Amount, amount)
if p.AmountMinor == 0 { // D1 残单兜底:下单后回填失败,入账前补查权威价
if st, qerr := s.queryOrder(outTradeNo); qerr == nil && st.AmountMinor > 0 {
p.AmountMinor, p.Currency = st.AmountMinor, st.Currency
if err := tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).
Updates(map[string]any{"amount_minor": p.AmountMinor, "currency": p.Currency}).Error; err != nil {
return err
}
}
}
if amountMinor != p.AmountMinor || !strings.EqualFold(currency, p.Currency) {
log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%d/%s callback=%d/%s", outTradeNo, p.AmountMinor, p.Currency, amountMinor, currency)
return ErrPayAmount
}
// 权益按建单时的套餐映射;回调 biz_code 仅一致性校验(不一致以本地为准并告警)
@@ -291,18 +336,20 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
log.Printf("[pay] ALERT promo double-claim out_trade_no=%s shop=%d:本店已享受过首月特惠,本单不叠加时长", outTradeNo, p.ShopID)
}
}
updates := map[string]any{
"status": "paid",
"channel": channel,
"paid_at": paidAt,
}
if entitleOK {
if err := entitle(tx, p.ShopID, plan); err != nil {
renewedTo, err := entitle(tx, p.ShopID, plan)
if err != nil {
return err
}
updates["renewed_to"] = renewedTo
}
shopID = p.ShopID
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
"status": "paid",
"trade_no": tradeNo,
"channel": channel,
"paid_at": paidAt,
}).Error
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(updates).Error
})
if err != nil {
return err
@@ -316,8 +363,9 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
}
// entitle 给门店续期:时长直接叠加(未过期从到期日叠,已过期从现在起算),
// 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。
func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
// 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。返回续期后的授权到期日
// settle 落库 renewed_to 展示用)。
func entitle(tx *gorm.DB, shopID uint64, plan payPlan) (time.Time, error) {
now := time.Now()
var lic model.License
err := tx.Set("gorm:query_option", "FOR UPDATE").
@@ -325,7 +373,7 @@ func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
Order("id DESC").First(&lic).Error
creating := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !creating {
return err
return time.Time{}, err
}
base := now
@@ -345,16 +393,22 @@ func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
MaxDevices: plan.MaxDevices,
Features: plan.Features,
}
return tx.Create(&lic).Error
if err := tx.Create(&lic).Error; err != nil {
return time.Time{}, err
}
return expires, nil
}
return tx.Model(&lic).Updates(map[string]any{
if err := tx.Model(&lic).Updates(map[string]any{
"type": plan.Type,
"tier": plan.Tier,
"expires_at": expires,
"is_active": true,
"max_devices": plan.MaxDevices,
"features": plan.Features,
}).Error
}).Error; err != nil {
return time.Time{}, err
}
return expires, nil
}
// BackfillPurchaseAmountMinor 启动回填:v1 存量购买单 amount("2999.00") → amount_minor(299900)+CNY。
@@ -372,13 +426,6 @@ func BackfillPurchaseAmountMinor(db *gorm.DB) {
}
}
// amountEqual 金额按分归一比较("2999.00" == "2999" == "2999.0")。
func amountEqual(a, b string) bool {
ca, ea := toCents(a)
cb, eb := toCents(b)
return ea == nil && eb == nil && ca == cb
}
func toCents(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
@@ -493,7 +540,7 @@ func (s *PayService) reconcileOnce() {
if st.PaidAt != nil {
paidAt = *st.PaidAt
}
if err := s.settle(p.OutTradeNo, p.ProductBizCode, formatMinor(st.AmountMinor), "", "", paidAt); err != nil {
if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.AmountMinor, st.Currency, "", paidAt); err != nil {
log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err)
} else {
log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo)