feat(backend): pay 查单兜底适配 v2 订单八态 + 残单补查移出行锁事务

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 22:34:34 +08:00
parent 7f713c4c96
commit f00f453d6f
2 changed files with 168 additions and 15 deletions
+31 -15
View File
@@ -285,7 +285,23 @@ func (s *PayService) replayed(nonce string) bool {
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对(残单先兜底回填)+ 同事务续期。
// webhook 与查单兜底共用此入口。
//
// 残单兜底(D1)的查单外呼是只读操作,不需要持行锁:调用方(webhook 签名负载 /
// reconcile 自身查单结果)若已经带来非零金额,直接复用,不重复外呼 pay;只有调用方
// 也不知道金额(amountMinor==0)时,才在事务外先轻量读一次购买单(无锁 First),
// 若其 amount_minor 也是 0 才现查一次权威价。事务内仍以 FOR UPDATE 读到的行值为准做
// 短路与核对,若行内金额仍为 0 才回填;补查失败/仍未知 → 核对不过 → ErrPayAmountfail-closed)。
func (s *PayService) settle(outTradeNo, bizCode string, amountMinor int64, currency, channel string, paidAt time.Time) error {
fillMinor, fillCurrency := amountMinor, currency
if fillMinor == 0 {
var pre model.LicensePurchase
if err := s.db.Where("out_trade_no = ?", outTradeNo).First(&pre).Error; err == nil && pre.AmountMinor == 0 {
if st, qerr := s.queryOrder(outTradeNo); qerr == nil && st.AmountMinor > 0 {
fillMinor, fillCurrency = st.AmountMinor, st.Currency
}
}
}
var shopID uint64
err := s.db.Transaction(func(tx *gorm.DB) error {
var p model.LicensePurchase
@@ -299,16 +315,14 @@ func (s *PayService) settle(outTradeNo, bizCode string, amountMinor int64, curre
if p.Status == "paid" { // 幂等:pay 会重发
return nil
}
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 p.AmountMinor == 0 && fillMinor > 0 { // 行内金额仍为 0 才回填,事务外查到的权威价直接落库
p.AmountMinor, p.Currency = fillMinor, fillCurrency
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) {
if p.AmountMinor == 0 || 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
}
@@ -532,26 +546,28 @@ func (s *PayService) reconcileOnce() {
if err != nil {
continue
}
// 状态语义暂沿用旧的三态判断(v2 八态 created|pending|paid|canceled|expired|
// refunding|partially_refunded|refunded 的完整适配留 Task 4);这里仅做字段对齐保编译。
// v2 八态映射:created|pending 继续等;paid 入账续期;canceled|expired 标 failed
// WHERE status='pending' 防竞态,webhook 可能已抢先入账);退款三态本轮不冲权益,
// 仅记录(退款接入另起任务)。
switch st.Status {
case "paid":
paidAt := time.Now()
if st.PaidAt != nil {
paidAt = *st.PaidAt
}
// st.AmountMinor/st.Currency 已是本次查单拿到的权威价,settle 收到非 0 金额
// 不会再触发内部补查(见 settle 注释),此处查单只外呼一次。
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)
}
case "closed":
case "canceled", "expired":
s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID).
Update("status", "failed")
case "refunded":
// 本轮不冲权益,仅记录(退款处理后续设计)
log.Printf("[pay] order refunded out_trade_no=%s (no-op)", p.OutTradeNo)
}
case "refunding", "partially_refunded", "refunded":
log.Printf("[pay] order %s status=%s (no-op,退款接入另起任务)", p.OutTradeNo, st.Status)
} // created/pending:继续等
}
}