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:继续等
}
}
+137
View File
@@ -62,6 +62,16 @@ func createPendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode str
return p
}
// createStalePendingPurchase 建一条 created_at 在 5 分钟对账窗口之外的 pending 购买单,
// 供 reconcileOnce 测试(该函数只捞 created_at < now-5min 的 pending 单)。
func createStalePendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode string, amountMinor int64, currency, otn string) *model.LicensePurchase {
t.Helper()
p := createPendingPurchase(t, db, shopID, bizCode, amountMinor, currency, otn)
stale := time.Now().Add(-10 * time.Minute)
require.NoError(t, db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Update("created_at", stale).Error)
return p
}
// ---------- 签名 ----------
func TestPaySign_Vector(t *testing.T) {
@@ -164,6 +174,35 @@ func TestHandleCallback_ResidualAmountBackfill(t *testing.T) {
assert.NotNil(t, p.RenewedTo, "入账应落库续期后到期日")
}
// TestSettle_ResidualQueryFailFailsClosed:残单(amount_minor=0)且调用方自己也不知道金额
// amountMinor==0,如 reconcile 遇到 pay 侧字段异常)时,settle 内补查权威价若也失败
// pay 查单 500),必须 fail-closed:不入账、不续期、购买单维持 pending。
func TestSettle_ResidualQueryFailFailsClosed(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY016")
createPendingPurchase(t, db, shop.ID, "annual_standard", 0, "", "yanmei-otn-residual-fail")
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v2/orders/yanmei-otn-residual-fail", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
payServer := httptest.NewServer(mux)
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
err := svc.settle("yanmei-otn-residual-fail", "annual_standard", 0, "", "alipay", time.Now())
assert.ErrorIs(t, err, ErrPayAmount, "补查也失败应 fail-closed")
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-residual-fail").First(&p).Error)
assert.Equal(t, "pending", p.Status, "补查失败不得入账")
assert.Nil(t, p.RenewedTo, "补查失败不得续期")
var licCount int64
require.NoError(t, db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&licCount).Error)
assert.Equal(t, int64(0), licCount, "补查失败不得续期")
}
// ---------- 回调:入账 ----------
func TestHandleCallback_SettleAndRenew(t *testing.T) {
@@ -509,3 +548,101 @@ func TestBackfillPurchaseAmountMinor(t *testing.T) {
assert.Equal(t, int64(123), got3.AmountMinor, "已有 amount_minor>0 的行不被覆盖")
assert.Equal(t, "USD", got3.Currency, "已有 amount_minor>0 的行不被覆盖")
}
// ---------- 查单兜底:reconcileOnce v2 八态映射 ----------
// reconcileMux 建一个只服务 GET /api/v2/orders/:no 的假 pay,固定返回给定状态/金额。
func reconcileMux(t *testing.T, otn, status string, amountMinor int64, currency string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v2/orders/"+otn, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"data":{"order_no":%q,"status":%q,"amount_minor":%d,"currency":%q}}`, otn, status, amountMinor, currency)
})
return httptest.NewServer(mux)
}
func TestReconcileOnce_PaidSettlesAndRenews(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY017")
createStalePendingPurchase(t, db, shop.ID, "annual_standard", 299900, "CNY", "yanmei-otn-reconcile-paid")
payServer := reconcileMux(t, "yanmei-otn-reconcile-paid", "paid", 299900, "CNY")
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
svc.reconcileOnce()
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-paid").First(&p).Error)
assert.Equal(t, "paid", p.Status, "查单发现已支付应入账")
assert.NotNil(t, p.RenewedTo, "入账应同事务续期")
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
assert.InDelta(t, 365, daysFromNow(lic.ExpiresAt), 1, "年付套餐应续期 365 天")
}
func TestReconcileOnce_CanceledMarksFailed(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY018")
createStalePendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-canceled")
payServer := reconcileMux(t, "yanmei-otn-reconcile-canceled", "canceled", 29900, "CNY")
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
svc.reconcileOnce()
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-canceled").First(&p).Error)
assert.Equal(t, "failed", p.Status, "canceled 应标 failed")
}
func TestReconcileOnce_ExpiredMarksFailed(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY019")
createStalePendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-expired")
payServer := reconcileMux(t, "yanmei-otn-reconcile-expired", "expired", 29900, "CNY")
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
svc.reconcileOnce()
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-expired").First(&p).Error)
assert.Equal(t, "failed", p.Status, "expired 应标 failed")
}
func TestReconcileOnce_RefundedNoOp(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY020")
createStalePendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-refunded")
payServer := reconcileMux(t, "yanmei-otn-reconcile-refunded", "refunded", 29900, "CNY")
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
svc.reconcileOnce()
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-refunded").First(&p).Error)
assert.Equal(t, "pending", p.Status, "refunded 本轮 no-op,仅记录,状态不变")
}
// TestReconcileOnce_WithinWindowSkippedcreated_at 未超 5 分钟对账窗口的 pending 单不应被捞到。
func TestReconcileOnce_WithinWindowSkipped(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PAY021")
createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-reconcile-fresh")
payServer := reconcileMux(t, "yanmei-otn-reconcile-fresh", "paid", 29900, "CNY")
defer payServer.Close()
svc := newTestPaySvc(db, payServer.URL)
svc.reconcileOnce()
var p model.LicensePurchase
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-fresh").First(&p).Error)
assert.Equal(t, "pending", p.Status, "未超窗口的单本轮不应被对账")
}