refactor(backend): 清理 settle 残单预查死代码块——入参金额直接回填,fail-closed 语义不变
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -284,25 +284,15 @@ func (s *PayService) replayed(nonce string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对(残单先兜底回填)+ 同事务续期。
|
||||
// webhook 与查单兜底共用此入口。
|
||||
// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对(残单用入参权威金额回填)+ 同事务续期。
|
||||
// webhook 与查单兜底(reconcileOnce)共用此入口,两个调用方都自带权威金额(webhook 签名负载 /
|
||||
// reconcile 自身 queryOrder 结果),恒非零。
|
||||
//
|
||||
// 残单兜底(D1)的查单外呼是只读操作,不需要持行锁:调用方(webhook 签名负载 /
|
||||
// reconcile 自身查单结果)若已经带来非零金额,直接复用,不重复外呼 pay;只有调用方
|
||||
// 也不知道金额(amountMinor==0)时,才在事务外先轻量读一次购买单(无锁 First),
|
||||
// 若其 amount_minor 也是 0 才现查一次权威价。事务内仍以 FOR UPDATE 读到的行值为准做
|
||||
// 短路与核对,若行内金额仍为 0 才回填;补查失败/仍未知 → 核对不过 → ErrPayAmount(fail-closed)。
|
||||
// 事务内以 FOR UPDATE 读到的行值为准:若行内 amount_minor 仍为 0(残单,建单时回填曾失败),
|
||||
// 直接用入参 amountMinor 回填落库;若行内已有金额,则入参必须与之一致(防篡改)。核对逻辑
|
||||
// (p.AmountMinor==0 || amountMinor != p.AmountMinor || currency 不一致 → ErrPayAmount)对两种
|
||||
// 情形都成立:入参为 0(不应出现于真实流)→ 回填后行内仍为 0 → 核对不过,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
|
||||
@@ -316,8 +306,8 @@ func (s *PayService) settle(outTradeNo, bizCode string, amountMinor int64, curre
|
||||
if p.Status == "paid" { // 幂等:pay 会重发
|
||||
return nil
|
||||
}
|
||||
if p.AmountMinor == 0 && fillMinor > 0 { // 行内金额仍为 0 才回填,事务外查到的权威价直接落库
|
||||
p.AmountMinor, p.Currency = fillMinor, fillCurrency
|
||||
if p.AmountMinor == 0 && amountMinor > 0 { // 行内金额仍为 0 才回填,入参权威金额直接落库
|
||||
p.AmountMinor, p.Currency = amountMinor, 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
|
||||
|
||||
@@ -167,20 +167,15 @@ func TestHandleCallback_UnknownEventAcked(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestHandleCallback_ResidualAmountBackfill:残单兜底——建单时金额回填失败(amount_minor=0),
|
||||
// 入账前补查权威价成功 → 核对通过并回填。
|
||||
// webhook 自带权威金额(真实流恒非零)→ settle 事务内直接用入参回填并核对通过、入账续期。
|
||||
// settle 不再外呼 queryOrder,此处特意不配置 pay mock server(传 http://pay.invalid),
|
||||
// 证明回填全程无需外部查单。
|
||||
func TestHandleCallback_ResidualAmountBackfill(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PAY015")
|
||||
createPendingPurchase(t, db, shop.ID, "annual_standard", 0, "", "yanmei-otn-residual")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v2/orders/yanmei-otn-residual", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"data":{"order_no":"yanmei-otn-residual","status":"paid","amount_minor":299900,"currency":"CNY"}}`)
|
||||
})
|
||||
payServer := httptest.NewServer(mux)
|
||||
defer payServer.Close()
|
||||
|
||||
svc := newTestPaySvc(db, payServer.URL)
|
||||
svc := newTestPaySvc(db, "http://pay.invalid")
|
||||
body := callbackBody("yanmei-otn-residual", "annual_standard", 299900)
|
||||
ts, nonce, sign := signedCallbackArgs(body)
|
||||
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
|
||||
@@ -188,21 +183,24 @@ func TestHandleCallback_ResidualAmountBackfill(t *testing.T) {
|
||||
var p model.LicensePurchase
|
||||
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-residual").First(&p).Error)
|
||||
assert.Equal(t, "paid", p.Status)
|
||||
assert.Equal(t, int64(299900), p.AmountMinor, "残单兜底应回填金额")
|
||||
assert.Equal(t, int64(299900), p.AmountMinor, "残单兜底应用 webhook 权威金额回填")
|
||||
assert.Equal(t, "CNY", p.Currency)
|
||||
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) {
|
||||
// TestSettle_ZeroAmountInputFailsClosed:settle 入参 amountMinor==0(真实流不应出现,
|
||||
// 防御性场景)时必须 fail-closed:guard 的 p.AmountMinor==0 分支兜底命中 ErrPayAmount,
|
||||
// 不入账、不续期、购买单维持 pending。settle 不再外呼 queryOrder 兜底补查,
|
||||
// 用调用计数断言零调用,防止该死代码路径回归。
|
||||
func TestSettle_ZeroAmountInputFailsClosed(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PAY016")
|
||||
createPendingPurchase(t, db, shop.ID, "annual_standard", 0, "", "yanmei-otn-residual-fail")
|
||||
|
||||
var queryCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v2/orders/yanmei-otn-residual-fail", func(w http.ResponseWriter, r *http.Request) {
|
||||
queryCalls++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
payServer := httptest.NewServer(mux)
|
||||
@@ -210,16 +208,17 @@ func TestSettle_ResidualQueryFailFailsClosed(t *testing.T) {
|
||||
|
||||
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")
|
||||
assert.ErrorIs(t, err, ErrPayAmount, "入参金额为 0 应 fail-closed")
|
||||
assert.Equal(t, 0, queryCalls, "settle 不应再外呼查单")
|
||||
|
||||
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, "补查失败不得续期")
|
||||
assert.Equal(t, "pending", p.Status, "入参金额为 0 不得入账")
|
||||
assert.Nil(t, p.RenewedTo, "入参金额为 0 不得续期")
|
||||
|
||||
var licCount int64
|
||||
require.NoError(t, db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&licCount).Error)
|
||||
assert.Equal(t, int64(0), licCount, "补查失败不得续期")
|
||||
assert.Equal(t, int64(0), licCount, "入参金额为 0 不得续期")
|
||||
}
|
||||
|
||||
// ---------- 回调:入账 ----------
|
||||
|
||||
Reference in New Issue
Block a user