b8bdb2c193
HandleCallback 原门 serr==nil && SubscriptionRef!="" 挡不住 Settle() 对非 PaidSucceeded 事件返回 (SettleIgnored, nil) 的情况(如 Stripe checkout.session.completed 异步支付 payment_status=unpaid),导致 onSubscriptionActivated 无确认付款先激活订阅。改为按结算结果开门 (SettleProcessed/SettleDuplicate),并在 onSubscriptionActivated 内加 o.Status.Settled() 纵深防御。
136 lines
5.2 KiB
Go
136 lines
5.2 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/wangjia/pay/internal/model"
|
|
"github.com/wangjia/pay/internal/provider"
|
|
"github.com/wangjia/pay/internal/util"
|
|
|
|
"github.com/wangjia/pay/internal/accounts"
|
|
)
|
|
|
|
// webhook event 常量集中在 internal/gateway(Task 7 收敛业务方声明)。
|
|
const (
|
|
EvtPaymentSucceeded = "payment.succeeded"
|
|
EvtSubscriptionCreated = "subscription.created"
|
|
EvtSubscriptionRenewed = "subscription.renewed"
|
|
EvtSubscriptionPastDue = "subscription.past_due"
|
|
EvtSubscriptionCanceled = "subscription.canceled"
|
|
EvtChargebackReceived = "chargeback.received"
|
|
)
|
|
|
|
type CreateSubscriptionInput struct {
|
|
SKU string
|
|
Method string
|
|
BizSystem string
|
|
BizRef string
|
|
ReturnURL string
|
|
}
|
|
|
|
type SubscriptionResult struct {
|
|
SubID string `json:"sub_id"`
|
|
OrderNo string `json:"order_no"`
|
|
Session SessionView `json:"session"`
|
|
}
|
|
|
|
// CreateSubscription 解析套餐权威金额 → 选 stripe 账户(须实现 SubscriptionProvider 且
|
|
// SupportsRecurring)→ 落 pending 首购 OrderV2 + Attempt → 返回订阅 Checkout redirect。
|
|
// 订阅在首期支付回调时诞生(见 onSubscriptionActivated)。
|
|
func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error) {
|
|
prov, err := g.providers.Get(in.Method)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
caps := prov.Capabilities()
|
|
subProv, ok := prov.(provider.SubscriptionProvider)
|
|
if !ok || !caps.SupportsRecurring {
|
|
return nil, provider.ErrNotSupported
|
|
}
|
|
if len(caps.SettleCurrencies) == 0 {
|
|
return nil, ErrNoSettleCurrency
|
|
}
|
|
currency := caps.SettleCurrencies[0]
|
|
amount, subject, bizCode, err := g.products.Resolve(in.SKU, currency)
|
|
if err != nil {
|
|
return nil, err // ErrProductNotFound(含"该币种无价")
|
|
}
|
|
outTradeNo := util.NewOutTradeNo("pay")
|
|
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{OutTradeNo: outTradeNo, AmountMinor: amount})
|
|
if err != nil {
|
|
if errors.Is(err, accounts.ErrNoAccount) {
|
|
return nil, ErrNoAccount
|
|
}
|
|
return nil, err
|
|
}
|
|
// SubID 确定性派生自 out_trade_no(与 onSubscriptionActivated 一致,消除双号)。
|
|
subID := "SUB-" + outTradeNo
|
|
sess, err := subProv.CreateSubscriptionCheckout(ctx, provider.CreateRequest{
|
|
OutTradeNo: outTradeNo, Subject: subject, AmountMinor: amount, Currency: currency,
|
|
Account: acct, ReturnURL: in.ReturnURL, Metadata: map[string]string{"pay_sub_id": subID},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gateway.CreateSubscription: %w", err)
|
|
}
|
|
order := &model.OrderV2{
|
|
OutTradeNo: outTradeNo, BizSystem: in.BizSystem, BizRef: in.BizRef, BizCode: bizCode,
|
|
Subject: subject, AmountMinor: amount, Currency: currency, Status: model.OrderPendingV2,
|
|
}
|
|
if err := g.orders.CreateOrder(order); err != nil {
|
|
return nil, err
|
|
}
|
|
att := &model.Attempt{
|
|
OutTradeNo: outTradeNo, Channel: in.Method, AccountID: acct.AccountID, Provider: in.Method,
|
|
ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType),
|
|
AmountMinor: amount, Currency: currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt,
|
|
}
|
|
if err := g.orders.CreateAttempt(att); err != nil {
|
|
return nil, err
|
|
}
|
|
return &SubscriptionResult{
|
|
SubID: subID, OrderNo: outTradeNo,
|
|
Session: SessionView{RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt},
|
|
}, nil
|
|
}
|
|
|
|
// onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。
|
|
// created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。
|
|
func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error {
|
|
att, err := g.orders.AttemptByProviderRef(ev.ProviderRef)
|
|
if err != nil {
|
|
return nil // 首期会话未落库(不该发生);交由 Settle 侧日志,订阅侧静默
|
|
}
|
|
o, err := g.orders.GetOrder(att.OutTradeNo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !o.Status.Settled() {
|
|
// 纵深防御:HandleCallback 的门(res==Processed/Duplicate)已挡住未结算订单,
|
|
// 这里再核一遍订单状态,防止未来调用点绕过门直接调用本函数。
|
|
log.Printf("[subscription] onSubscriptionActivated 订单未结算 out_trade_no=%s status=%s,拒绝激活", o.OutTradeNo, o.Status)
|
|
return nil
|
|
}
|
|
subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等
|
|
created, err := g.subs.Create(&model.Subscription{
|
|
SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode,
|
|
Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled,
|
|
AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !created || o.BizSystem == "" {
|
|
return nil // 已诞生过(重投)/ 独立收款无业务方回调
|
|
}
|
|
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, "", map[string]any{
|
|
"event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID,
|
|
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
|
|
"amount_minor": o.AmountMinor, "currency": o.Currency, "channel": att.Channel,
|
|
"created_at": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|