From 7e5a697f2881df5ffcb09b8a79c47b2aa7ad9ce4 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 22:40:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E8=B4=AD=E4=B9=B0=E5=8D=95?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E9=80=8F=E4=BC=A0=20pay=20v2=20cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/internal/handler/pay.go | 21 +++++++ backend/internal/router/router.go | 1 + backend/internal/service/pay.go | 55 +++++++++++++++++ backend/internal/service/pay_test.go | 91 ++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) diff --git a/backend/internal/handler/pay.go b/backend/internal/handler/pay.go index 5579461..3521c45 100644 --- a/backend/internal/handler/pay.go +++ b/backend/internal/handler/pay.go @@ -91,6 +91,27 @@ func (h *PayHandler) PurchaseStatus(c *gin.Context) { util.RespondSuccess(c, st) } +// Cancel POST /api/v1/license/purchase/:out_trade_no/cancel — 取消本店一笔购买单 +// (防 pending 单堆积挤占 reconcile 每轮 50 条限额)。仅管理员可用(handler 内判权,同 Purchase)。 +func (h *PayHandler) Cancel(c *gin.Context) { + role := middleware.GetRole(c) + if role != "admin" && role != "superadmin" { + c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可取消购买单"}) + return + } + canceled, err := h.svc.CancelPurchase(middleware.GetShopID(c), c.Param("out_trade_no")) + if err != nil { + if errors.Is(err, service.ErrPurchaseNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + log.Printf("[pay] cancel failed shop=%d out_trade_no=%s: %v", middleware.GetShopID(c), c.Param("out_trade_no"), err) + c.JSON(http.StatusBadGateway, gin.H{"error": "取消失败,请稍后重试"}) + return + } + util.RespondSuccess(c, gin.H{"canceled": canceled}) +} + // PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。 func (h *PayHandler) PromoStatus(c *gin.Context) { used, err := h.svc.PromoUsed(middleware.GetShopID(c)) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 29ba84d..e2f7e9c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -122,6 +122,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权) license.POST("/purchase", payH.Purchase) license.GET("/purchase/:out_trade_no", payH.PurchaseStatus) + license.POST("/purchase/:out_trade_no/cancel", payH.Cancel) license.GET("/promo-status", payH.PromoStatus) } diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 8e65c7f..a9ecf15 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -602,3 +602,58 @@ func (s *PayService) queryOrder(orderNo string) (*payOrderStatus, error) { } return &r.Data, nil } + +// ---------- ⑤ 取消透传(防 pending 单堆积挤占 reconcile 每轮 50 条限额)---------- + +// CancelPurchase 取消本店一笔购买单:仅对 pending 单外呼 pay 取消;已终态/不存在均幂等无害。 +// pay 侧语义是 `UPDATE ... WHERE out_trade_no=? AND status='pending'`,canceled=RowsAffected>0: +// - canceled=true:pay 确认取消(钱未扣/未入账),本地条件更新标 failed。 +// - canceled=false:已支付竞态(取消请求到达 pay 时单已被支付),本地必须保持 pending, +// 等 webhook/reconcile 正常入账,不得误标 failed(否则钱已收但门店权益丢失)。 +func (s *PayService) CancelPurchase(shopID uint64, outTradeNo string) (bool, error) { + var p model.LicensePurchase + if err := s.db.Where("shop_id = ? AND out_trade_no = ?", shopID, outTradeNo).First(&p).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, ErrPurchaseNotFound + } + return false, err + } + if p.Status != "pending" { // 非 pending:幂等无害,不外呼 pay + return false, nil + } + + canceled, err := s.cancelOrder(outTradeNo) + if err != nil { + return false, fmt.Errorf("pay 取消失败: %w", err) + } + if !canceled { + return false, nil + } + if err := s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID). + Update("status", "failed").Error; err != nil { + return false, err + } + return true, nil +} + +// cancelOrder 调 pay 取消单(无签名无请求体)。 +func (s *PayService) cancelOrder(orderNo string) (bool, error) { + resp, err := s.client.Post(s.baseURL+"/api/v2/orders/"+orderNo+"/cancel", "application/json", nil) + if err != nil { + return false, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("pay HTTP %d", resp.StatusCode) + } + var r struct { + Data struct { + Canceled bool `json:"canceled"` + } `json:"data"` + } + if err := json.Unmarshal(body, &r); err != nil { + return false, err + } + return r.Data.Canceled, nil +} diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 1573035..e13c8ce 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -646,3 +646,94 @@ func TestReconcileOnce_WithinWindowSkipped(t *testing.T) { require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-fresh").First(&p).Error) assert.Equal(t, "pending", p.Status, "未超窗口的单本轮不应被对账") } + +// ---------- 取消透传(Task 5:防 pending 堆积挤占 reconcile 每轮 50 条限额)---------- + +// cancelMux 建一个只服务 POST /api/v2/orders/:no/cancel 的假 pay,固定回给定 canceled 值, +// 并统计外呼次数(供"非 pending 单不外呼"断言)。 +func cancelMux(t *testing.T, otn string, canceled bool, callCount *int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v2/orders/"+otn+"/cancel", func(w http.ResponseWriter, r *http.Request) { + if callCount != nil { + *callCount++ + } + fmt.Fprintf(w, `{"data":{"canceled":%v}}`, canceled) + }) + return httptest.NewServer(mux) +} + +func TestCancelPurchase_PayCanceledMarksFailed(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PAY022") + createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-1") + + var calls int + payServer := cancelMux(t, "yanmei-otn-cancel-1", true, &calls) + defer payServer.Close() + + svc := newTestPaySvc(db, payServer.URL) + canceled, err := svc.CancelPurchase(shop.ID, "yanmei-otn-cancel-1") + require.NoError(t, err) + assert.True(t, canceled) + assert.Equal(t, 1, calls, "应外呼 pay 一次") + + var p model.LicensePurchase + require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-cancel-1").First(&p).Error) + assert.Equal(t, "failed", p.Status, "pay 回 canceled=true 应本地标 failed") +} + +// TestCancelPurchase_PayAlreadyPaidKeepsPending:pay 回 canceled=false(已支付竞态: +// 取消请求到达 pay 时单已被支付)→ 本地必须保持 pending,等 webhook/reconcile 正常入账, +// 不得被误标 failed(否则钱已收但门店权益丢失)。 +func TestCancelPurchase_PayAlreadyPaidKeepsPending(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PAY023") + createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-2") + + payServer := cancelMux(t, "yanmei-otn-cancel-2", false, nil) + defer payServer.Close() + + svc := newTestPaySvc(db, payServer.URL) + canceled, err := svc.CancelPurchase(shop.ID, "yanmei-otn-cancel-2") + require.NoError(t, err) + assert.False(t, canceled) + + var p model.LicensePurchase + require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-cancel-2").First(&p).Error) + assert.Equal(t, "pending", p.Status, "已支付竞态本地应保持 pending,等 webhook/reconcile 入账") +} + +func TestCancelPurchase_OtherShopNotFound(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PAY024") + other := testutil.CreateTestShop(db, "PAY025") + createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-3") + + svc := newTestPaySvc(db, "http://pay.invalid") + _, err := svc.CancelPurchase(other.ID, "yanmei-otn-cancel-3") + assert.ErrorIs(t, err, ErrPurchaseNotFound, "跨店不可见") +} + +// TestCancelPurchase_NonPendingNoOpNoExternalCall:非 pending 单(已 paid)直接返回 false, +// 且不外呼 pay(避免对已终态单做无意义/有风险的取消请求)。 +func TestCancelPurchase_NonPendingNoOpNoExternalCall(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PAY026") + p := createPendingPurchase(t, db, shop.ID, "monthly_standard", 29900, "CNY", "yanmei-otn-cancel-4") + require.NoError(t, db.Model(p).Update("status", "paid").Error) + + var calls int + payServer := cancelMux(t, "yanmei-otn-cancel-4", true, &calls) + defer payServer.Close() + + svc := newTestPaySvc(db, payServer.URL) + canceled, err := svc.CancelPurchase(shop.ID, "yanmei-otn-cancel-4") + require.NoError(t, err) + assert.False(t, canceled) + assert.Equal(t, 0, calls, "非 pending 单不应外呼 pay") + + var got model.LicensePurchase + require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-cancel-4").First(&got).Error) + assert.Equal(t, "paid", got.Status, "非 pending 单状态不变") +}