package logic import ( "context" "strconv" "sync" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/user/v1" "tool-api/internal/consts" "tool-api/internal/model/entity" ) // ============================================================================ // 额度引擎 // // 三种额度,按「免费 → 会员 → 付费」的顺序扣减,先扣对用户最有利的池: // 1. 免费额度 free_quota —— 每个周期(默认 7 天)重置,所有用户都有 // 2. 会员额度 member_quota —— 每周期重置,仅会员可用(在免费额度用尽后启用) // 3. 付费额度 次数包 —— 买断制、按工具绑定、在有效期内累计使用,不随周期重置 // // 周期是「滚动窗口」而非自然周:首次使用时把周期起点定为当时时间, // 起点 + 周期天数 = 终点;到点归零。这样做的好处是后台改周期天数后 // 立刻按新长度重算,不用等下一个自然周。 // // 周期的取值优先级:用户单独设置 > 会员套餐(发货时写入用户)> 全局设置 > 默认 7 天。 // ============================================================================ const ( quotaSettingCacheTTL = 30 * time.Second quotaMaxPeriodDays = 3650 ) // 扣减时返回的额度判定结果,附带「还能不能用」与失败原因。 type quotaVerdict struct { Source string // 命中/将命中的池:free / member / paid / none Message string // source == none 时的可读原因 } // ===== 全局设置读取(带短缓存,避免每次扣减都查库)===== type settingCacheItem struct { value string at time.Time } var ( settingCacheMu sync.RWMutex settingCache = map[string]settingCacheItem{} ) // SettingValue 读取 settings 表中的配置值,带 30 秒进程内缓存 func SettingValue(ctx context.Context, key string) string { settingCacheMu.RLock() item, ok := settingCache[key] settingCacheMu.RUnlock() if ok && time.Since(item.at) < quotaSettingCacheTTL { return item.value } value, err := g.Model(consts.TableSettings).Where("k", key).Value("v") if err != nil || value == nil { return "" } settingCacheMu.Lock() settingCache[key] = settingCacheItem{value: value.String(), at: time.Now()} settingCacheMu.Unlock() return value.String() } // settingSave 写入配置并立即失效缓存(管理端保存后马上生效) func settingSave(ctx context.Context, key, value, remark string) error { if _, err := g.Model(consts.TableSettings).Data(g.Map{ "k": key, "v": value, "remark": remark, "updated_at": gtime.Now(), }).Save(); err != nil { return err } settingCacheMu.Lock() delete(settingCache, key) settingCacheMu.Unlock() return nil } // GlobalQuotaPeriodDays 全局默认额度周期(天) func GlobalQuotaPeriodDays(ctx context.Context) int { raw := SettingValue(ctx, consts.SettingQuotaPeriodDays) days, err := strconv.Atoi(raw) if err != nil || days <= 0 || days > quotaMaxPeriodDays { return consts.DefaultQuotaPeriodDays } return days } // EffectiveQuotaPeriodDays 用户实际生效的额度周期: // 用户单独设置(后台可改)优先,其次全局设置。 func EffectiveQuotaPeriodDays(ctx context.Context, user *entity.Users) int { if user != nil && user.QuotaPeriodDays > 0 && user.QuotaPeriodDays <= quotaMaxPeriodDays { return user.QuotaPeriodDays } return GlobalQuotaPeriodDays(ctx) } // ===== 工具读取 ===== func toolRow(ctx context.Context, toolKey string) (*entity.Tools, error) { record, err := g.Model(consts.TableTools).Where("tool_key", toolKey).WhereNull("deleted_at").One() if err != nil { return nil, err } if record.IsEmpty() { return nil, gerror.New("工具不存在") } tool := &entity.Tools{} if err = record.Struct(tool); err != nil { return nil, err } return tool, nil } // ===== 周期用量 ===== type quotaUsageState struct { Id int64 FreeUsed int MemberUsed int PeriodStart *gtime.Time PeriodEnd *gtime.Time Version int // 供构建输出用(本周期是否被重置过) ResetOccurred bool PeriodDaysSnapshot int } // lockOrInitUsage 行锁读取(不存在则创建)某用户某工具的周期用量; // 周期到期或后台改短周期导致已过期时,就地归零并开启新周期。 func lockOrInitUsage(ctx context.Context, tx gdb.TX, userId int64, toolKey string, periodDays int) (*quotaUsageState, error) { now := gtime.Now() record, err := tx.Model(consts.TableQuotaUsage). Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One() if err != nil { return nil, err } if record.IsEmpty() { data := g.Map{ "user_id": userId, "tool_key": toolKey, "period_start": now, "period_end": now.AddDate(0, 0, periodDays), "free_used": 0, "member_used": 0, "updated_at": now, } lastId, insertErr := tx.Model(consts.TableQuotaUsage).Data(data).InsertAndGetId() if insertErr != nil { // 并发下可能已被其它请求插入:重新加锁读取 record, err = tx.Model(consts.TableQuotaUsage). Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One() if err != nil { return nil, err } if record.IsEmpty() { return nil, insertErr } } else { return "aUsageState{ Id: lastId, PeriodStart: now, PeriodEnd: now.AddDate(0, 0, periodDays), PeriodDaysSnapshot: periodDays, }, nil } } state := "aUsageState{ Id: record["id"].Int64(), FreeUsed: record["free_used"].Int(), MemberUsed: record["member_used"].Int(), PeriodStart: record["period_start"].GTime(), PeriodEnd: record["period_end"].GTime(), } if state.PeriodStart == nil || state.PeriodStart.IsZero() { return state.reset(ctx, tx, now, periodDays) } // 按「当前配置的周期天数」重算终点:后台改长/改短都能立刻对齐 expectedEnd := state.PeriodStart.AddDate(0, 0, periodDays) if !now.Before(expectedEnd) { // 周期已结束 → 归零开新周期 return state.reset(ctx, tx, now, periodDays) } if state.PeriodEnd == nil || !state.PeriodEnd.Equal(expectedEnd) { if _, err = tx.Model(consts.TableQuotaUsage).Where("id", state.Id).Data(g.Map{ "period_end": expectedEnd, "updated_at": now, }).Update(); err != nil { return nil, err } state.PeriodEnd = expectedEnd } return state, nil } func (s *quotaUsageState) reset(ctx context.Context, tx gdb.TX, now *gtime.Time, periodDays int) (*quotaUsageState, error) { end := now.AddDate(0, 0, periodDays) if _, err := tx.Model(consts.TableQuotaUsage).Where("id", s.Id).Data(g.Map{ "period_start": now, "period_end": end, "free_used": 0, "member_used": 0, "updated_at": now, }).Update(); err != nil { return nil, err } s.FreeUsed = 0 s.MemberUsed = 0 s.PeriodStart = now s.PeriodEnd = end s.ResetOccurred = true s.PeriodDaysSnapshot = periodDays return s, nil } // ===== 付费额度余额 ===== type paidBalance struct { Id int64 TimesLeft int ExpireAt *gtime.Time Exists bool } func paidExpired(b *paidBalance, now *gtime.Time) bool { if b == nil || !b.Exists { return true } if b.ExpireAt == nil || b.ExpireAt.IsZero() { return true } return !now.Before(b.ExpireAt) } // lockPaidBalance 行锁读取用户在某工具上的付费额度余额;为空时返回不存在(不创建) func lockPaidBalance(ctx context.Context, tx gdb.TX, userId int64, toolKey string) (*paidBalance, error) { record, err := tx.Model(consts.TableUserToolQuota). Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One() if err != nil { return nil, err } if record.IsEmpty() { return &paidBalance{Exists: false}, nil } return &paidBalance{ Id: record["id"].Int64(), TimesLeft: record["times_left"].Int(), ExpireAt: record["expire_at"].GTime(), Exists: true, }, nil } // grantToolQuota 发货:给用户增加某工具的付费额度。 // 同工具重复购买 → 次数累加、有效期顺延(base = max(now, 当前到期时间))。 func grantToolQuota( ctx context.Context, tx gdb.TX, userId int64, toolKey string, times, validDays int, ) (*gtime.Time, error) { if times <= 0 { return nil, gerror.New("次数包次数异常") } if validDays <= 0 { validDays = 365 } now := gtime.Now() record, err := tx.Model(consts.TableUserToolQuota). Where("user_id", userId).Where("tool_key", toolKey).LockUpdate().One() if err != nil { return nil, err } base := now curLeft := 0 if !record.IsEmpty() { curExpire := record["expire_at"].GTime() curLeft = record["times_left"].Int() if curExpire != nil && curExpire.After(now) { base = curExpire } else { curLeft = 0 // 已过期:旧余额作废,重新计 } } expireAt := base.AddDate(0, 0, validDays) if record.IsEmpty() { if _, err = tx.Model(consts.TableUserToolQuota).Data(g.Map{ "user_id": userId, "tool_key": toolKey, "times_left": times, "total_bought": times, "expire_at": expireAt, "created_at": now, "updated_at": now, }).Insert(); err != nil { return nil, err } return expireAt, nil } if _, err = tx.Model(consts.TableUserToolQuota).Where("id", record["id"].Int64()).Data(g.Map{ "times_left": curLeft + times, "total_bought": record["total_bought"].Int() + times, "expire_at": expireAt, "updated_at": now, }).Update(); err != nil { return nil, err } return expireAt, nil } // ===== 额度判定 ===== // judgeQuota 计算当前会命中哪个池,以及还有没有额度 func judgeQuota(tool *entity.Tools, usage *quotaUsageState, paid *paidBalance, isMember bool, now *gtime.Time) quotaVerdict { payCost := tool.PayCost if payCost <= 0 { payCost = 1 } if usage == nil || usage.FreeUsed < tool.FreeQuota { return quotaVerdict{Source: consts.QuotaSourceFree} } if isMember && usage.MemberUsed < tool.MemberQuota { return quotaVerdict{Source: consts.QuotaSourceMember} } if paid != nil && paid.Exists && !paidExpired(paid, now) && paid.TimesLeft >= payCost { return quotaVerdict{Source: consts.QuotaSourcePaid} } return quotaVerdict{Source: consts.QuotaSourceNone, Message: quotaExhaustedMessage(tool)} } func quotaExhaustedMessage(tool *entity.Tools) string { return "「" + tool.Name + "」本周期额度已用完,开通会员可获得更多额度,或购买次数包继续使用" } // ConsumeToolQuota 扣减一次额度(产出结果时调用)。 // // 事务内按 out_trade_no 之外的另一把锁保证并发安全:行锁落在 user_quota_usage / // user_tool_quota 上,两个请求同时扣同一个人同一工具时串行执行,不会超发。 func ConsumeToolQuota(ctx context.Context, userId int64, toolKey string) (*v1.QuotaToolOut, error) { user, err := getUserById(ctx, userId) if err != nil { return nil, err } tool, err := toolRow(ctx, toolKey) if err != nil { return nil, err } if tool.IsEnabled != 1 { return nil, gerror.New("工具不存在或已下架") } isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey periodDays := EffectiveQuotaPeriodDays(ctx, user) now := gtime.Now() err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { usage, err := lockOrInitUsage(ctx, tx, userId, toolKey, periodDays) if err != nil { return err } paid, err := lockPaidBalance(ctx, tx, userId, toolKey) if err != nil { return err } verdict := judgeQuota(tool, usage, paid, isMember, now) switch verdict.Source { case consts.QuotaSourceFree: if _, err = tx.Model(consts.TableQuotaUsage).Where("id", usage.Id).Data(g.Map{ "free_used": usage.FreeUsed + 1, "updated_at": now, }).Update(); err != nil { return err } case consts.QuotaSourceMember: if _, err = tx.Model(consts.TableQuotaUsage).Where("id", usage.Id).Data(g.Map{ "member_used": usage.MemberUsed + 1, "updated_at": now, }).Update(); err != nil { return err } case consts.QuotaSourcePaid: payCost := tool.PayCost if payCost <= 0 { payCost = 1 } if _, err = tx.Model(consts.TableUserToolQuota).Where("id", paid.Id).Data(g.Map{ "times_left": paid.TimesLeft - payCost, "updated_at": now, }).Update(); err != nil { return err } default: return gerror.NewCode(gcodeQuotaExhausted(), verdict.Message) } return nil }) if err != nil { return nil, err } // 扣减后回读一次真实状态(含周期已滚动的情形) status, err := ToolQuotaStatus(ctx, user, tool) if err != nil { return nil, err } return status, nil } // ===== 状态查询 ===== // ToolQuotaStatus 单个工具的额度状态 func ToolQuotaStatus(ctx context.Context, user *entity.Users, tool *entity.Tools) (*v1.QuotaToolOut, error) { isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey periodDays := EffectiveQuotaPeriodDays(ctx, user) now := gtime.Now() usage := "aUsageState{} record, err := g.Model(consts.TableQuotaUsage). Where("user_id", user.Id).Where("tool_key", tool.ToolKey).One() if err != nil { return nil, err } if !record.IsEmpty() { usage.Id = record["id"].Int64() usage.FreeUsed = record["free_used"].Int() usage.MemberUsed = record["member_used"].Int() usage.PeriodStart = record["period_start"].GTime() usage.PeriodEnd = record["period_end"].GTime() // 只读场景不写库:过期即视为用量归零 if usage.PeriodEnd == nil || !now.Before(usage.PeriodEnd) { usage.FreeUsed = 0 usage.MemberUsed = 0 usage.PeriodStart = now usage.PeriodEnd = now.AddDate(0, 0, periodDays) } else if usage.PeriodStart != nil { expectedEnd := usage.PeriodStart.AddDate(0, 0, periodDays) if expectedEnd.After(now) { usage.PeriodEnd = expectedEnd } } } else { usage.PeriodStart = now usage.PeriodEnd = now.AddDate(0, 0, periodDays) } paid := &paidBalance{} paidRecord, err := g.Model(consts.TableUserToolQuota). Where("user_id", user.Id).Where("tool_key", tool.ToolKey).One() if err != nil { return nil, err } if !paidRecord.IsEmpty() { paid.Exists = true paid.Id = paidRecord["id"].Int64() paid.TimesLeft = paidRecord["times_left"].Int() paid.ExpireAt = paidRecord["expire_at"].GTime() } return buildQuotaOut(tool, usage, paid, isMember, periodDays, now), nil } // QuotaSnapshotForTools 批量计算多个工具的额度状态(2 次查询搞定,避免 11 个工具 11 次查库) func QuotaSnapshotForTools( ctx context.Context, user *entity.Users, tools []*entity.Tools, ) (map[string]*v1.QuotaToolOut, error) { out := make(map[string]*v1.QuotaToolOut, len(tools)) if len(tools) == 0 { return out, nil } isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey periodDays := EffectiveQuotaPeriodDays(ctx, user) now := gtime.Now() usageRecords, err := g.Model(consts.TableQuotaUsage).Where("user_id", user.Id).All() if err != nil { return nil, err } usageMap := map[string]*quotaUsageState{} for _, r := range usageRecords { usageMap[r["tool_key"].String()] = "aUsageState{ Id: r["id"].Int64(), FreeUsed: r["free_used"].Int(), MemberUsed: r["member_used"].Int(), PeriodStart: r["period_start"].GTime(), PeriodEnd: r["period_end"].GTime(), } } paidRecords, err := g.Model(consts.TableUserToolQuota).Where("user_id", user.Id).All() if err != nil { return nil, err } paidMap := map[string]*paidBalance{} for _, r := range paidRecords { paidMap[r["tool_key"].String()] = &paidBalance{ Id: r["id"].Int64(), TimesLeft: r["times_left"].Int(), ExpireAt: r["expire_at"].GTime(), Exists: true, } } for _, tool := range tools { usage := usageMap[tool.ToolKey] if usage == nil { usage = "aUsageState{} } // 只读路径不写库:过期即按归零展示 if usage.PeriodEnd == nil || !now.Before(usage.PeriodEnd) { usage.FreeUsed = 0 usage.MemberUsed = 0 usage.PeriodStart = now usage.PeriodEnd = now.AddDate(0, 0, periodDays) } else if usage.PeriodStart != nil { if expectedEnd := usage.PeriodStart.AddDate(0, 0, periodDays); expectedEnd.After(now) { usage.PeriodEnd = expectedEnd } } paid := paidMap[tool.ToolKey] if paid == nil { paid = &paidBalance{} } out[tool.ToolKey] = buildQuotaOut(tool, usage, paid, isMember, periodDays, now) } return out, nil } func buildQuotaOut( tool *entity.Tools, usage *quotaUsageState, paid *paidBalance, isMember bool, periodDays int, now *gtime.Time, ) *v1.QuotaToolOut { payCost := tool.PayCost if payCost <= 0 { payCost = 1 } freeLeft := clampMin(tool.FreeQuota-usage.FreeUsed, 0) memberLeft := 0 if isMember { memberLeft = clampMin(tool.MemberQuota-usage.MemberUsed, 0) } paidLeft := 0 paidExpireAt := "" if paid != nil && paid.Exists && !paidExpired(paid, now) { paidLeft = clampMax(paid.TimesLeft, 0) if paid.ExpireAt != nil && !paid.ExpireAt.IsZero() { paidExpireAt = paid.ExpireAt.Format("Y-m-d") } } verdict := judgeQuota(tool, usage, paid, isMember, now) totalLeft := freeLeft + memberLeft + paidLeft/payCost out := &v1.QuotaToolOut{ ToolKey: tool.ToolKey, Name: tool.Name, Icon: tool.Icon, FreeTotal: tool.FreeQuota, FreeUsed: usage.FreeUsed, FreeLeft: freeLeft, MemberTotal: tool.MemberQuota, MemberUsed: usage.MemberUsed, MemberLeft: memberLeft, PaidLeft: paidLeft, PaidExpireAt: paidExpireAt, PayCost: payCost, IsMember: isMember, PeriodDays: periodDays, Source: verdict.Source, CanUse: verdict.Source != consts.QuotaSourceNone, TotalLeft: totalLeft, } if usage.PeriodEnd != nil && !usage.PeriodEnd.IsZero() { out.PeriodEnd = usage.PeriodEnd.Format("Y-m-d H:i:s") } if verdict.Source == consts.QuotaSourceNone { out.Message = verdict.Message } return out } func clampMin(v, min int) int { if v < min { return min } return v } func clampMax(v, max int) int { if v > max { return max } return v } func gcodeQuotaExhausted() gcode.Code { return gcode.New(consts.CodeQuotaExhausted, "", nil) }