merge: P8 订阅/recurring + 拒付 chargeback 并入(订阅生命周期/续费/取消/past_due/chargeback/事件集收口)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u # Conflicts: # internal/model/testdb.go # internal/provider/provider.go # internal/router/router.go # internal/store/order_query_test.go # main.go
This commit is contained in:
@@ -92,6 +92,39 @@ func (h *GatewayHandler) CreateOrder(c *gin.Context) {
|
||||
util.RespondSuccess(c, res)
|
||||
}
|
||||
|
||||
// CreateSubscription POST /api/v2/subscriptions —— 建订阅,返回 {sub_id, order_no, session:{render_type, payload}}。
|
||||
// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign,与 CreateOrder 同惯例)。
|
||||
func (h *GatewayHandler) CreateSubscription(c *gin.Context) {
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
|
||||
return
|
||||
}
|
||||
var req createV2Request
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
|
||||
return
|
||||
}
|
||||
if req.SKU == "" || req.Method == "" {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 sku 或 method")
|
||||
return
|
||||
}
|
||||
if req.BizSystem != "" {
|
||||
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
|
||||
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := h.g.CreateSubscription(c.Request.Context(), gateway.CreateSubscriptionInput{
|
||||
SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeCreateErr(c, "建订阅", req.Method, err)
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, res)
|
||||
}
|
||||
|
||||
// GetStatus GET /api/v2/orders/:order_no
|
||||
func (h *GatewayHandler) GetStatus(c *gin.Context) {
|
||||
v, err := h.g.GetOrder(c.Param("order_no"))
|
||||
@@ -102,6 +135,31 @@ func (h *GatewayHandler) GetStatus(c *gin.Context) {
|
||||
util.RespondSuccess(c, v)
|
||||
}
|
||||
|
||||
// GetSubscription GET /api/v2/subscriptions/:sub_id —— 查订阅状态 + 续费锚点。
|
||||
func (h *GatewayHandler) GetSubscription(c *gin.Context) {
|
||||
v, err := h.g.GetSubscription(c.Param("sub_id"))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, v)
|
||||
}
|
||||
|
||||
// CancelSubscription POST /api/v2/subscriptions/:sub_id/cancel —— 主动取消:查订阅 → 渠道
|
||||
// 取消 → 本地翻 canceled + 入队 subscription.canceled(幂等,重复调用 no-op)。
|
||||
func (h *GatewayHandler) CancelSubscription(c *gin.Context) {
|
||||
if err := h.g.CancelSubscription(c.Request.Context(), c.Param("sub_id")); err != nil {
|
||||
if errors.Is(err, store.ErrSubNotFound) {
|
||||
util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
|
||||
return
|
||||
}
|
||||
log.Printf("[v2 subscription] 取消失败 sub_id=%s: %v", c.Param("sub_id"), err)
|
||||
util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"canceled": true})
|
||||
}
|
||||
|
||||
type retryRequest struct {
|
||||
Method string `json:"method"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // 同 createV2Request.Metadata,过同一白名单
|
||||
@@ -189,6 +247,8 @@ func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, e
|
||||
util.RespondError(c, http.StatusConflict, "currency_mismatch", "该支付方式结算币种与订单不符,请换一种支付方式")
|
||||
case errors.Is(err, gateway.ErrNoSettleCurrency):
|
||||
util.RespondError(c, http.StatusServiceUnavailable, "no_settle_currency", "该支付方式配置不完整,暂不可用")
|
||||
case errors.Is(err, provider.ErrNotSupported):
|
||||
util.RespondError(c, http.StatusBadRequest, "method_not_recurring", "该支付方式不支持订阅")
|
||||
default:
|
||||
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
|
||||
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
|
||||
|
||||
@@ -44,13 +44,15 @@ func buildEngineWithStore(t *testing.T) (*gin.Engine, *store.OrderStore) {
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
chargebacks := store.NewChargebackStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
preg.Register(fake.New())
|
||||
areg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r, orders
|
||||
@@ -177,6 +179,8 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
chargebacks := store.NewChargebackStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
preg.Register(fake.New()) // method="fake", settles in "USDT"
|
||||
|
||||
@@ -189,7 +193,7 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
|
||||
{AccountID: "fake-a2", Channel: "fake_eur", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
|
||||
@@ -246,3 +250,147 @@ func (p *eurFakeProvider) VerifyCallback(_ context.Context, in provider.Callback
|
||||
func (p *eurFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
// subFakeProvider 实现 provider.SubscriptionProvider,供 handler 层 CancelSubscription/
|
||||
// GetSubscription 端到端测试用(独立于 internal/gateway 包的 fakeSubProvider,同构但不能跨
|
||||
// 测试包复用未导出类型)。VerifyCallback 原样透传测试构造的 provider.PaidEvent JSON。
|
||||
type subFakeProvider struct {
|
||||
sessionRef string
|
||||
}
|
||||
|
||||
func (p *subFakeProvider) Method() string { return "subfake" }
|
||||
|
||||
func (p *subFakeProvider) Capabilities() provider.Capabilities {
|
||||
return provider.Capabilities{
|
||||
RenderTypes: []provider.RenderType{provider.RenderRedirect},
|
||||
SupportsRecurring: true,
|
||||
RecurringKind: provider.RecurringKindGatewayScheduled,
|
||||
SettleCurrencies: []string{"USD"},
|
||||
Regions: []string{"global"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *subFakeProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) {
|
||||
return nil, errors.New("subFakeProvider: one-time Create not used")
|
||||
}
|
||||
|
||||
func (p *subFakeProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
|
||||
return &provider.Session{
|
||||
ProviderRef: p.sessionRef, RenderType: provider.RenderRedirect,
|
||||
Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *subFakeProvider) CancelSubscription(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (p *subFakeProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||
var ev provider.PaidEvent
|
||||
if err := json.Unmarshal(in.Raw, &ev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
func (p *subFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
|
||||
}
|
||||
|
||||
type subResolver struct{}
|
||||
|
||||
func (subResolver) Resolve(sku, currency string) (int64, string, string, error) {
|
||||
return 2999, "Pro 月付", "pro_monthly", nil
|
||||
}
|
||||
|
||||
// buildSubEngine 装配一套支持订阅的路由(subfake 渠道),供取消/查询端点测试用。
|
||||
func buildSubEngine(t *testing.T) (*gin.Engine, *subFakeProvider) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
chargebacks := store.NewChargebackStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
fp := &subFakeProvider{sessionRef: "cs_sub_1"}
|
||||
preg.Register(fp)
|
||||
areg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "subfake-a1", Channel: "subfake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, subResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r, fp
|
||||
}
|
||||
|
||||
// TestV2SubscriptionNotFound404 —— 未知 sub_id 的取消/查询都应 404,而不是 500。
|
||||
func TestV2SubscriptionNotFound404(t *testing.T) {
|
||||
r, _ := buildSubEngine(t)
|
||||
|
||||
wGet, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/SUB-GHOST", nil)
|
||||
if wGet.Code != http.StatusNotFound {
|
||||
t.Fatalf("get unknown sub code=%d, want 404", wGet.Code)
|
||||
}
|
||||
wCancel, _ := do(t, r, http.MethodPost, "/api/v2/subscriptions/SUB-GHOST/cancel", nil)
|
||||
if wCancel.Code != http.StatusNotFound {
|
||||
t.Fatalf("cancel unknown sub code=%d, want 404", wCancel.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestV2SubscriptionCancelLifecycle 端到端:建订阅 → 首期支付回调激活 → 查询 active →
|
||||
// 取消 → 查询 canceled;取消端点重复调用幂等(仍 200,canceled=true)。
|
||||
func TestV2SubscriptionCancelLifecycle(t *testing.T) {
|
||||
r, fp := buildSubEngine(t)
|
||||
|
||||
w, out := do(t, r, http.MethodPost, "/api/v2/subscriptions", map[string]any{
|
||||
"sku": "pro_monthly", "method": "subfake", "biz_system": "", "biz_ref": "u-1",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create subscription code=%d body=%v", w.Code, out)
|
||||
}
|
||||
data := out["data"].(map[string]any)
|
||||
subID := data["sub_id"].(string)
|
||||
|
||||
// 激活前:GetSubscription 404(Subscription 行在首期支付回调时才诞生)。
|
||||
wPre, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
|
||||
if wPre.Code != http.StatusNotFound {
|
||||
t.Fatalf("get before activation code=%d, want 404", wPre.Code)
|
||||
}
|
||||
|
||||
// 首期支付回调激活。
|
||||
raw, err := json.Marshal(provider.PaidEvent{
|
||||
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
|
||||
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_h1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal event: %v", err)
|
||||
}
|
||||
wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/subfake", json.RawMessage(raw))
|
||||
if wc.Code != http.StatusOK {
|
||||
t.Fatalf("activate callback code=%d", wc.Code)
|
||||
}
|
||||
|
||||
wActive, outActive := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
|
||||
if wActive.Code != http.StatusOK {
|
||||
t.Fatalf("get after activation code=%d body=%v", wActive.Code, outActive)
|
||||
}
|
||||
if outActive["data"].(map[string]any)["status"] != "active" {
|
||||
t.Fatalf("status = %v, want active", outActive["data"])
|
||||
}
|
||||
|
||||
// 取消。
|
||||
wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil)
|
||||
if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true {
|
||||
t.Fatalf("cancel = %d %v", wCancel.Code, outCancel)
|
||||
}
|
||||
wAfter, outAfter := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
|
||||
if wAfter.Code != http.StatusOK || outAfter["data"].(map[string]any)["status"] != "canceled" {
|
||||
t.Fatalf("status after cancel = %d %v", wAfter.Code, outAfter)
|
||||
}
|
||||
|
||||
// 重复取消:幂等 200,不报错。
|
||||
wCancel2, outCancel2 := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil)
|
||||
if wCancel2.Code != http.StatusOK || outCancel2["data"].(map[string]any)["canceled"] != true {
|
||||
t.Fatalf("repeat cancel = %d %v", wCancel2.Code, outCancel2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestV2CreateOrderMetadataWhitelist(t *testing.T) {
|
||||
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestV2CreateOrderRateLimited(t *testing.T) {
|
||||
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestV2RateLimitDisabled(t *testing.T) {
|
||||
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
|
||||
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
|
||||
|
||||
@@ -45,11 +45,13 @@ func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.Orde
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
chargebacks := store.NewChargebackStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
fp := fake.New()
|
||||
preg.Register(fp)
|
||||
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}})
|
||||
g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global")
|
||||
g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r, g, orders, fp
|
||||
|
||||
Reference in New Issue
Block a user