feat(backend): pay webhook 升 v2 事件模型——event_type 分发、amount_minor 核对、nonce 防重放
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user