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:
wangjia
2026-07-10 19:43:32 +08:00
32 changed files with 2826 additions and 52 deletions
+60
View File
@@ -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+"失败,请稍后重试")