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
+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, "未超窗口的单本轮不应被对账")
}