From 18b97d5d3518605e8851b39fc39d3b9a4fce59d8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 01:31:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(backend):=20=E6=B8=85=E7=90=86=20settl?= =?UTF-8?q?e=20=E6=AE=8B=E5=8D=95=E9=A2=84=E6=9F=A5=E6=AD=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=9D=97=E2=80=94=E2=80=94=E5=85=A5=E5=8F=82=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E7=9B=B4=E6=8E=A5=E5=9B=9E=E5=A1=AB=EF=BC=8Cfail-clos?= =?UTF-8?q?ed=20=E8=AF=AD=E4=B9=89=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/internal/service/pay.go | 28 +++++++--------------- backend/internal/service/pay_test.go | 35 ++++++++++++++-------------- 2 files changed, 26 insertions(+), 37 deletions(-) diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index afdeb77..156f25b 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -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 diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 1451160..54197d9 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -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 不得续期") } // ---------- 回调:入账 ----------