diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 9d4effd..8e65c7f 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -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:继续等 } } diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 992211e..1573035 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -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, "未超窗口的单本轮不应被对账") +}