feat(billing): 订阅制后端 —— 手动购买 + 周期发放积分 + 到期失效
规格(按需求):用户扫码买一个订阅周期,有效期内每 N 天发一次积分,到期即失效, 不自动续费。N 与每次发放额度都在套餐里配,后台可改。 为什么不做自动续费:微信 Native 扫码支付没有代扣能力,真自动续费要走「委托代扣」 ——另一套产品与资质。与其假装有,不如把"到期即失效"这个语义做扎实。 两个决定,都写进了代码注释: - **发放语义是累加而非重置**。每次刷新写一条 grant 分录、余额累加。重置型 (月度配额清零)会让「余额 = SUM(ledger)」这条对账不变量变复杂,且有误清 用户自费积分的风险。 - **订阅开通放在 store.MarkOrderPaid 内**,而不是各调用方。回调与掉单补偿两条 路都经过它,放这一处才没人能漏掉;按 orderID 幂等,重复调用无害。 复用而非另造:订阅单与积分包单走同一条支付链路(下单/回调/查单/掉单补偿), 只是 kind=sub 且 credits_micro=0——积分不在付款时给,由订阅按周期发。 定时器每 10 分钟扫一轮,语义与幂等都在 store.TickSubscription 里,与手动触发 共用,不会两处漂移。停机期间欠下的发放会一次性补齐。 8 组测试。其中一条当场抓到真 bug:开通时原本无条件发一笔,同一订单重复开通 (回调重推/查单赛跑)会因序号自增绕过幂等索引,白送积分。改为开通也走与定时器 同一套排期判断——排期天然幂等。教训:幂等键要锚在业务时间轴上,不能靠自增序号。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,12 @@ import (
|
||||
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
|
||||
|
||||
// 订单状态机:pending → paid | failed | expired;paid →(人工)refunded。
|
||||
const (
|
||||
// 订单类型:一次性积分包 vs 订阅周期
|
||||
OrderKindPack = "pack"
|
||||
OrderKindSub = "sub"
|
||||
)
|
||||
|
||||
const (
|
||||
OrderPending = "pending"
|
||||
OrderPaid = "paid"
|
||||
@@ -49,9 +56,11 @@ func (CreditPack) TableName() string { return "sundynix_credit_pack" }
|
||||
// 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。
|
||||
type PaymentOrder struct {
|
||||
BaseModel
|
||||
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
|
||||
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
|
||||
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
|
||||
TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
|
||||
UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
|
||||
PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
|
||||
Kind string `gorm:"size:16;default:pack" json:"kind"` // pack=积分包(一次性) / sub=订阅
|
||||
PlanID string `gorm:"size:24" json:"plan_id"` // kind=sub 时的订阅套餐
|
||||
AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"`
|
||||
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
|
||||
Channel string `gorm:"size:16;index" json:"channel"`
|
||||
@@ -219,6 +228,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
||||
return false, errStoreDisabled
|
||||
}
|
||||
changed := false
|
||||
var paid *PaymentOrder
|
||||
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
res := tx.Model(&PaymentOrder{}).
|
||||
@@ -234,19 +244,35 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
||||
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&CreditLedger{
|
||||
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
|
||||
return err
|
||||
paid = &o
|
||||
// 订阅单自身不带积分(积分由订阅按周期发放),跳过零额分录避免账本噪声。
|
||||
if o.CreditsMicro != 0 {
|
||||
if err := tx.Create(&CreditLedger{
|
||||
TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
return nil
|
||||
})
|
||||
return changed, err
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
// 订阅开通放在这里、而不是各调用方:回调与掉单补偿两条路都经过 MarkOrderPaid,
|
||||
// 放在这一处才没人能漏掉。ActivateSubscription 按 orderID 幂等,重复调用无害。
|
||||
if paid != nil && paid.Kind == OrderKindSub && paid.PlanID != "" {
|
||||
if _, aerr := p.ActivateSubscription(ctx, paid.TenantID, paid.PlanID, paid.ID); aerr != nil {
|
||||
// 钱已收、订单已 paid:这里失败**不能**回滚订单(否则用户付了钱订单还回到 pending,
|
||||
// 补偿定时器会再入账一次)。出声即可,可人工或下次回调补开通。
|
||||
log.Printf("[payment] ⚠️ 订单 %s 已入账但订阅开通失败: %v(需人工补开通)", paid.ID, aerr)
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// RefundOrder 人工退款(PAYMENT_DESIGN §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
|
||||
|
||||
Reference in New Issue
Block a user