Files
sundynix-agentix/sundynix-gateway/internal/store/subscription.go
T
Blizzard 80d4aaf4ba 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>
2026-07-20 16:11:34 +08:00

300 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// 订阅(手动购买制)。刻意**不做自动续费**:微信 Native 扫码支付没有代扣能力,
// 真自动续费要走「委托代扣」——另一套产品与资质。这里的语义是:
//
// 用户扫码买一个订阅周期 → 有效期内每 N 天发一次积分 → 到期即失效,要续得再买一次。
//
// N(间隔天数)与每次发多少积分都在套餐里配,后台可改。
//
// **发放语义是「累加」而非「重置」**:每次刷新写一条 grant 分录、余额累加,
// 用不完的会留着,也绝不会清掉用户自己充值的积分。重置型(月度配额清零)会让
// 「余额 = SUM(ledger)」这条对账不变量变复杂,且有误删用户已付费积分的风险,
// 故不采用。
// SubscriptionPlan 订阅套餐(价格 / 时长 / 发放节奏,全部后台可配)。
type SubscriptionPlan struct {
BaseModel
Name string `gorm:"size:64" json:"name"`
PriceFen int64 `gorm:"column:price_fen" json:"price_fen"` // 售价(分)
DurationDays int `gorm:"column:duration_days" json:"duration_days"` // 一个订阅周期多少天
RefillCreditsMicro int64 `gorm:"column:refill_credits_micro" json:"refill_credits_micro"` // 每次发放的积分 ×10⁻⁶
RefillIntervalDays int `gorm:"column:refill_interval_days" json:"refill_interval_days"` // 每几天发一次
Active bool `json:"active"`
Sort int `json:"sort"`
}
func (SubscriptionPlan) TableName() string { return "sundynix_sub_plan" }
// Subscription 一次已购订阅。到期即 expired,不自动续。
type Subscription struct {
BaseModel
TenantID string `gorm:"size:64;index" json:"tenant_id"`
PlanID string `gorm:"size:24;index" json:"plan_id"`
OrderID string `gorm:"size:24" json:"order_id"` // 来源支付订单(人工发放为空)
Status string `gorm:"size:16;index" json:"status"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
RefillSeq int `gorm:"column:refill_seq" json:"refill_seq"` // 已发放次数;兼作幂等序号
LastRefillAt *time.Time `json:"last_refill_at"`
}
func (Subscription) TableName() string { return "sundynix_subscription" }
func (Subscription) isTenantScoped() {} // 用户面只看得到自己租户的订阅;系统级扫描须 WithoutTenant
const (
SubActive = "active"
SubExpired = "expired"
)
// refillRef 是一次发放的幂等键,落到 credit_ledger.ref。
// credit_ledger 上 (kind='grant', ref) 的唯一索引是最终闸门:定时器重跑、多实例并发、
// 手动补发,撞到同一序号都只会成功一次。
func refillRef(subID string, seq int) string { return fmt.Sprintf("sub:%s:%d", subID, seq) }
// isDupKey 判断是否唯一索引冲突(= 这一笔已经发过了,幂等成功而非失败)。
func isDupKey(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "duplicate key") || strings.Contains(s, "unique constraint") ||
strings.Contains(s, "unique violation") || strings.Contains(s, "constraint failed")
}
// ---- 套餐配置 ----
func (p *Postgres) ListSubPlans(ctx context.Context, onlyActive bool) []SubscriptionPlan {
if p.db == nil {
return nil
}
q := p.db.WithContext(WithoutTenant(ctx)).Order("sort asc, price_fen asc")
if onlyActive {
q = q.Where("active = ?", true)
}
var out []SubscriptionPlan
q.Find(&out)
return out
}
func (p *Postgres) GetSubPlan(ctx context.Context, id string) *SubscriptionPlan {
if p.db == nil || id == "" {
return nil
}
var pl SubscriptionPlan
if err := p.db.WithContext(WithoutTenant(ctx)).First(&pl, "id = ?", id).Error; err != nil {
return nil
}
return &pl
}
// SaveSubPlan 新增或更新套餐(id 空=新增)。
func (p *Postgres) SaveSubPlan(ctx context.Context, pl *SubscriptionPlan) error {
if p.db == nil {
return errStoreDisabled
}
if pl.DurationDays <= 0 {
return errors.New("订阅时长必须大于 0 天")
}
if pl.RefillIntervalDays <= 0 {
return errors.New("发放间隔必须大于 0 天")
}
// 间隔比时长还长 = 一个周期内一次都发不到第二回,多半是配错了,直接拦下。
if pl.RefillIntervalDays > pl.DurationDays {
return errors.New("发放间隔不能大于订阅时长")
}
return p.db.WithContext(WithoutTenant(ctx)).Save(pl).Error
}
// ---- 订阅生命周期 ----
// ActivateSubscription 支付成功后开通/续期,并立即发放第一笔积分。
// 已有生效中的订阅则**顺延**到期时间(而不是新建一条),避免同租户多条 active 互相打架。
// 幂等:同一 orderID 只会开通一次。
func (p *Postgres) ActivateSubscription(ctx context.Context, tenantID, planID, orderID string) (*Subscription, error) {
if p.db == nil {
return nil, errStoreDisabled
}
pl := p.GetSubPlan(ctx, planID)
if pl == nil {
return nil, errors.New("订阅套餐不存在")
}
ctx = WithoutTenant(ctx) // 系统级:为目标租户开通,调用方可能是 admin
now := time.Now()
var sub *Subscription
err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 幂等闸:同一订单已开通过就直接返回,不重复延期
if orderID != "" {
var exist Subscription
if err := tx.First(&exist, "order_id = ?", orderID).Error; err == nil {
sub = &exist
return nil
}
}
var cur Subscription
err := tx.Where("tenant_id = ? AND status = ?", tenantID, SubActive).
Order("expires_at desc").First(&cur).Error
switch {
case err == nil: // 续期:在原到期时间上顺延
cur.ExpiresAt = cur.ExpiresAt.AddDate(0, 0, pl.DurationDays)
cur.PlanID, cur.OrderID = pl.ID, orderID
if err := tx.Save(&cur).Error; err != nil {
return err
}
sub = &cur
return nil
case errors.Is(err, gorm.ErrRecordNotFound):
s := &Subscription{
TenantID: tenantID, PlanID: pl.ID, OrderID: orderID, Status: SubActive,
StartedAt: now, ExpiresAt: now.AddDate(0, 0, pl.DurationDays),
}
if err := tx.Create(s).Error; err != nil {
return err
}
sub = s
return nil
default:
return err
}
})
if err != nil {
return nil, err
}
// 首笔发放走与定时器**同一套排期判断**(而不是无条件发一笔):否则同一订单重复
// 开通时(回调重推、查单与回调赛跑)会各发一笔,序号递增绕过幂等索引 —— 白送钱。
// 排期判断天然幂等:seq 已发过则下一笔的到期时间在未来,不会发。
// 放在事务外:发放失败不该导致"已付款却没开通",定时器下一轮会补上。
if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
return sub, nil // 开通已成功,发放失败交给定时器补
}
return sub, nil
}
// refillOnce 发放一次积分并推进序号。返回 granted=false 表示这一笔已发过(幂等)。
func (p *Postgres) refillOnce(ctx context.Context, sub *Subscription, pl *SubscriptionPlan, now time.Time) (bool, error) {
if pl.RefillCreditsMicro <= 0 {
return false, nil
}
seq := sub.RefillSeq + 1
err := p.GrantCredits(ctx, sub.TenantID, LedgerGrant, pl.RefillCreditsMicro,
refillRef(sub.ID, seq), "订阅发放 "+pl.Name)
if err != nil {
if isDupKey(err) {
return false, nil // 已发过:幂等成功
}
return false, err
}
sub.RefillSeq = seq
sub.LastRefillAt = &now
return true, p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
Where("id = ?", sub.ID).
Updates(map[string]any{"refill_seq": seq, "last_refill_at": now}).Error
}
// DueSubscriptions 取到期需处理的订阅(系统级,跨租户)。
func (p *Postgres) DueSubscriptions(ctx context.Context, limit int) []Subscription {
if p.db == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
var out []Subscription
p.db.WithContext(WithoutTenant(ctx)).
Where("status = ?", SubActive).Order("expires_at asc").Limit(limit).Find(&out)
return out
}
// TickSubscription 推进一条订阅:先看是否到期,再看是否该发放。
// 返回 (发放笔数, 是否刚过期)。定时器与手动触发共用这一份逻辑,避免两处行为漂移。
func (p *Postgres) TickSubscription(ctx context.Context, sub *Subscription, now time.Time) (int, bool, error) {
pl := p.GetSubPlan(ctx, sub.PlanID)
if pl == nil {
return 0, false, errors.New("订阅套餐已不存在: " + sub.PlanID)
}
granted := 0
// 补发:进程停机/定时器漏跑期间欠下的次数要一次性补齐,而不是只发最近一次。
// 上限用到期时间卡住——过期之后的周期一律不发。
for {
due := sub.StartedAt.AddDate(0, 0, pl.RefillIntervalDays*(sub.RefillSeq))
if due.After(now) || !due.Before(sub.ExpiresAt) {
break
}
ok, err := p.refillOnce(ctx, sub, pl, now)
if err != nil {
return granted, false, err
}
if ok {
granted++
}
if sub.RefillSeq > 1000 { // 防呆:配置异常(间隔 0)时不至于死循环
break
}
}
if now.After(sub.ExpiresAt) {
if err := p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
Where("id = ? AND status = ?", sub.ID, SubActive).
Update("status", SubExpired).Error; err != nil {
return granted, false, err
}
return granted, true, nil
}
return granted, false, nil
}
// ActiveSubscription 取某租户当前生效的订阅(用户面:账单页展示到期时间)。
func (p *Postgres) ActiveSubscription(ctx context.Context, tenantID string) *Subscription {
if p.db == nil || tenantID == "" {
return nil
}
var s Subscription
if err := p.db.WithContext(WithoutTenant(ctx)).
Where("tenant_id = ? AND status = ?", tenantID, SubActive).
Order("expires_at desc").First(&s).Error; err != nil {
return nil
}
return &s
}
// AdminSubRow 是管理端订阅观测一行:订阅 + 租户名 + 套餐名。
type AdminSubRow struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `json:"expires_at"`
RefillSeq int `json:"refill_seq"`
}
// AllSubscriptions 全平台订阅(管理端观测,跨租户)。
func (p *Postgres) AllSubscriptions(ctx context.Context, limit int) []AdminSubRow {
if p.db == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 200
}
var out []AdminSubRow
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_subscription as s").
Select("s.id, s.tenant_id, s.status, s.started_at, s.expires_at, s.refill_seq, " +
"coalesce(t.name,'') as tenant_name, coalesce(pl.name,'') as plan_name").
Joins("left join sundynix_tenant t on t.id = s.tenant_id").
Joins("left join sundynix_sub_plan pl on pl.id = s.plan_id").
Where("s.deleted_at is null").
Order("s.expires_at desc").Limit(limit).Scan(&out)
return out
}