80d4aaf4ba
规格(按需求):用户扫码买一个订阅周期,有效期内每 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>
300 lines
11 KiB
Go
300 lines
11 KiB
Go
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
|
||
}
|