feat(backend): 购买单取消透传 pay v2 cancel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,27 @@ func (h *PayHandler) PurchaseStatus(c *gin.Context) {
|
|||||||
util.RespondSuccess(c, st)
|
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 — 本店首月特惠是否已享用(前端据此置灰特惠档)。
|
// PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。
|
||||||
func (h *PayHandler) PromoStatus(c *gin.Context) {
|
func (h *PayHandler) PromoStatus(c *gin.Context) {
|
||||||
used, err := h.svc.PromoUsed(middleware.GetShopID(c))
|
used, err := h.svc.PromoUsed(middleware.GetShopID(c))
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
|
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
|
||||||
license.POST("/purchase", payH.Purchase)
|
license.POST("/purchase", payH.Purchase)
|
||||||
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
|
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
|
||||||
|
license.POST("/purchase/:out_trade_no/cancel", payH.Cancel)
|
||||||
license.GET("/promo-status", payH.PromoStatus)
|
license.GET("/promo-status", payH.PromoStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,3 +602,58 @@ func (s *PayService) queryOrder(orderNo string) (*payOrderStatus, error) {
|
|||||||
}
|
}
|
||||||
return &r.Data, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-reconcile-fresh").First(&p).Error)
|
||||||
assert.Equal(t, "pending", p.Status, "未超窗口的单本轮不应被对账")
|
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 单状态不变")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user