Files
2026-09-17 17:38:45 +08:00

609 lines
18 KiB
Go
Raw Permalink 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 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).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 &quotaUsageState{
Id: lastId,
PeriodStart: now,
PeriodEnd: now.AddDate(0, 0, periodDays),
PeriodDaysSnapshot: periodDays,
}, nil
}
}
state := &quotaUsageState{
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 := &quotaUsageState{}
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()] = &quotaUsageState{
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 = &quotaUsageState{}
}
// 只读路径不写库:过期即按归零展示
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)
}