feat(backend): pay 查单兜底适配 v2 订单八态 + 残单补查移出行锁事务
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 才回填;补查失败/仍未知 → 核对不过 → ErrPayAmount(fail-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:继续等
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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_WithinWindowSkipped:created_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, "未超窗口的单本轮不应被对账")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user