依据《代码分析结果.md》P3 章节,逐条修复 20 项细节问题:
正确性修复:
- P3-1 锁定剩余分钟改用 math.Ceil,避免「0.9 分钟」显示为「0 分钟」
- P3-12 移除 CheckAndUpgradeLevel 中恒真的 hasUpdate 标志
- P3-14 移除 CheckTrialExpiry 中恒真表达式(count > 0 || true)调试残留,改用 RowsAffected
- P3-2 成员不存在统一返回 NewNotFoundError(404),与其它方法语义一致
安全加固:
- P3-6 X-Request-Id 加格式校验 ^[A-Za-z0-9-]{1,64}$,防日志注入
- P3-7 新增公共 EscapeLikePattern,转义 admin LIKE 搜索的 %/_ 通配符
- P3-8 Encrypt 输出加 v1: 版本前缀,Decrypt 兼容无前缀历史密文
- P3-9 turnstile 响应体读取加 64KB 上限,防内存耗尽
- P3-11 backup codes 熵由 48-bit 提升到 64-bit
- P3-15 recordAudit 对 multipart/二进制体跳过 Unmarshal 与原文落库
- P3-17 错误日志按 Content-Type 跳过 multipart/二进制体读取
- P3-16 HandleRealtime 加 panic recover 与连接生命周期日志
可维护性/一致性:
- P3-3 oauth.go 时间格式统一为 time.RFC3339
- P3-5 MsgSuccess 统一为 ok,response.go 改用常量引用
- P3-4 错误码分段策略加注释,说明空洞与跳跃的成因
- P3-13 ModelMultiplier 占位语义加注释(计费公式设计位 + 快照列)
- P3-19 抽取 registerCronJobs,主启动流程清爽
重构:
- P3-18 删除死代码 BuildBillingSummary/SummaryParams(仅测试引用),保留共用 helper
- P3-20 抽取 relay.InvalidateMemberModelScopeCache 单一失效入口,
消除三处 member_model 前缀字面量重复(原为有意跨包失效,原建议改名会破坏)
经核实保留 + 文档化:
- P3-10 TOTP 暂保留 SHA-1:totp.Validate 硬编码 SHA-1 且存储不保存算法元信息,
直接切 SHA-256 会导致校验恒失败,需配合 schema 改动,超出 P3 范围
235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
package billing
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/shopspring/decimal"
|
||
|
||
"github.com/qianfree/team-api/internal/dao"
|
||
do "github.com/qianfree/team-api/internal/model/do"
|
||
"github.com/qianfree/team-api/internal/model/entity"
|
||
)
|
||
|
||
// GetTenantEffectiveLimits 获取租户实际生效的成员数上限和并发上限。
|
||
// max_members 为 0/NULL 时从等级配置取值;等级配置也为 0 表示无限制。
|
||
// 返回值 0 表示无限制。调用方需用 memberCount < maxMembers || maxMembers == 0 判断。
|
||
func GetTenantEffectiveLimits(ctx context.Context, tenantID int64) (maxMembers int, maxConcurrency int, err error) {
|
||
var tenant *entity.TntTenants
|
||
err = dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
if tenant == nil {
|
||
return 0, 0, nil
|
||
}
|
||
|
||
// 租户自定义值优先(> 0 表示已自定义,0/NULL 表示跟随等级配置)
|
||
if tenant.MaxMembers > 0 {
|
||
maxMembers = tenant.MaxMembers
|
||
}
|
||
if tenant.MaxConcurrency > 0 {
|
||
maxConcurrency = tenant.MaxConcurrency
|
||
}
|
||
|
||
// 未自定义的字段从等级配置取
|
||
if tenant.MaxMembers <= 0 || tenant.MaxConcurrency <= 0 {
|
||
var config *entity.TntTenantLevelConfigs
|
||
_ = dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", tenant.Level).Scan(&config)
|
||
if tenant.MaxMembers <= 0 {
|
||
if config != nil {
|
||
maxMembers = config.MaxMembers // 0 表示无限制
|
||
} else {
|
||
maxMembers = 10
|
||
}
|
||
}
|
||
if tenant.MaxConcurrency <= 0 {
|
||
if config != nil {
|
||
maxConcurrency = config.MaxConcurrency // 0 表示无限制
|
||
} else {
|
||
maxConcurrency = 0
|
||
}
|
||
}
|
||
}
|
||
|
||
return maxMembers, maxConcurrency, nil
|
||
}
|
||
|
||
// GetTenantEffectiveLimitsByEntity 通过已查询的租户实体获取实际生效限制(避免重复查询租户)
|
||
// 返回值 0 表示无限制。
|
||
func GetTenantEffectiveLimitsByEntity(ctx context.Context, tenant *entity.TntTenants) (maxMembers int, maxConcurrency int) {
|
||
if tenant == nil {
|
||
return 0, 0
|
||
}
|
||
|
||
// 租户自定义值优先(> 0 表示已自定义,0/NULL 表示跟随等级配置)
|
||
if tenant.MaxMembers > 0 {
|
||
maxMembers = tenant.MaxMembers
|
||
}
|
||
if tenant.MaxConcurrency > 0 {
|
||
maxConcurrency = tenant.MaxConcurrency
|
||
}
|
||
|
||
// 未自定义的字段从等级配置取
|
||
if tenant.MaxMembers <= 0 || tenant.MaxConcurrency <= 0 {
|
||
var config *entity.TntTenantLevelConfigs
|
||
_ = dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", tenant.Level).Scan(&config)
|
||
if tenant.MaxMembers <= 0 {
|
||
if config != nil {
|
||
maxMembers = config.MaxMembers // 0 表示无限制
|
||
} else {
|
||
maxMembers = 10
|
||
}
|
||
}
|
||
if tenant.MaxConcurrency <= 0 {
|
||
if config != nil {
|
||
maxConcurrency = config.MaxConcurrency // 0 表示无限制
|
||
} else {
|
||
maxConcurrency = 0
|
||
}
|
||
}
|
||
}
|
||
|
||
return maxMembers, maxConcurrency
|
||
}
|
||
|
||
// GetTenantLevelConfig 获取指定等级的配置
|
||
func GetTenantLevelConfig(ctx context.Context, level int) (*entity.TntTenantLevelConfigs, error) {
|
||
var config *entity.TntTenantLevelConfigs
|
||
err := dao.TntTenantLevelConfigs.Ctx(ctx).
|
||
Where("level", level).
|
||
Scan(&config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if config == nil {
|
||
return nil, fmt.Errorf("level config not found for level %d", level)
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
// GetLevelPriceMultiplier 获取租户级别的价格乘数(decimal 原生版)
|
||
// 查询失败时静默返回 1.0,不阻断计费
|
||
func GetLevelPriceMultiplier(ctx context.Context, tenantID int64) decimal.Decimal {
|
||
var tenant *entity.TntTenants
|
||
if err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant); err != nil || tenant == nil {
|
||
g.Log().Warningf(ctx, "[Billing] failed to read tenant level for multiplier: tenantID=%d, err=%v", tenantID, err)
|
||
return One
|
||
}
|
||
|
||
if tenant.Level <= 1 {
|
||
return One
|
||
}
|
||
|
||
var config *entity.TntTenantLevelConfigs
|
||
if err := dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", tenant.Level).Scan(&config); err != nil || config == nil {
|
||
g.Log().Warningf(ctx, "[Billing] failed to read level config for multiplier: level=%d, err=%v", tenant.Level, err)
|
||
return One
|
||
}
|
||
|
||
if config.PriceMultiplier.GreaterThan(Zero) && !config.PriceMultiplier.Equal(One) {
|
||
return config.PriceMultiplier
|
||
}
|
||
return One
|
||
}
|
||
|
||
// CheckAndUpgradeLevel 检查并升级租户等级(充值后调用)
|
||
// 升级策略:仅升不降 — max_members/max_concurrency 仅在新值 > 当前值时才更新
|
||
func CheckAndUpgradeLevel(ctx context.Context, tenantID int64) error {
|
||
// 1. 读取租户当前等级
|
||
var tenant *entity.TntTenants
|
||
err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tenant == nil {
|
||
return nil
|
||
}
|
||
|
||
// 2. 读取累计充值
|
||
var wallet *entity.BilWallets
|
||
err = dao.BilWallets.Ctx(ctx).Where("tenant_id", tenantID).Scan(&wallet)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if wallet == nil {
|
||
return nil
|
||
}
|
||
|
||
// 3. 根据累计充值计算应得等级
|
||
newLevel, err := RecalculateLevel(ctx, wallet.CumulativeRecharge)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 4. 仅当等级提升时才更新
|
||
if newLevel <= tenant.Level {
|
||
return nil
|
||
}
|
||
|
||
// 5. 获取新等级配置
|
||
config, err := GetTenantLevelConfig(ctx, newLevel)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 6. 构建更新数据:等级一定更新(newLevel > tenant.Level 已由第 4 步保证),
|
||
// max_members/max_concurrency 仅在新值更优时附加更新(仅升不降策略)。
|
||
zero := 0
|
||
updateData := do.TntTenants{Level: newLevel}
|
||
|
||
// max_members:0 时跟随等级配置(不更新),> 0 时仅升不降
|
||
if tenant.MaxMembers > 0 {
|
||
if config.MaxMembers == 0 {
|
||
// 新等级允许无限成员,直接更新
|
||
updateData.MaxMembers = &zero
|
||
} else if config.MaxMembers > tenant.MaxMembers {
|
||
updateData.MaxMembers = &config.MaxMembers
|
||
}
|
||
}
|
||
|
||
// max_concurrency:0 时跟随等级配置(不更新),> 0 时仅升不降
|
||
if tenant.MaxConcurrency > 0 {
|
||
if config.MaxConcurrency == 0 {
|
||
updateData.MaxConcurrency = &zero
|
||
} else if config.MaxConcurrency > tenant.MaxConcurrency {
|
||
updateData.MaxConcurrency = &config.MaxConcurrency
|
||
}
|
||
}
|
||
|
||
// 等级本身已变更,必定需要写库(无需 hasUpdate 标志,原标志恒真属冗余判断)
|
||
_, err = dao.TntTenants.Ctx(ctx).
|
||
Where("id", tenantID).
|
||
Data(updateData).
|
||
Update()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 7. 清除并发限制缓存
|
||
_, _ = g.Redis().Do(ctx, "DEL", fmt.Sprintf("tenant:conc_limit:%d", tenantID))
|
||
|
||
// 8. 清除该租户的价格缓存(级别变化可能影响折扣)
|
||
modelPriceCache.DeleteByPattern(ctx, fmt.Sprintf("%d:*", tenantID))
|
||
|
||
return nil
|
||
}
|
||
|
||
// RecalculateLevel 根据累计充值金额重新计算等级(decimal 原生版)
|
||
func RecalculateLevel(ctx context.Context, cumulativeRecharge decimal.Decimal) (int, error) {
|
||
var config *entity.TntTenantLevelConfigs
|
||
err := dao.TntTenantLevelConfigs.Ctx(ctx).
|
||
Where("cumulative_recharge_threshold <= ?", cumulativeRecharge).
|
||
OrderDesc("level").
|
||
Limit(1).
|
||
Scan(&config)
|
||
if err != nil {
|
||
return 1, err
|
||
}
|
||
if config == nil {
|
||
return 1, nil
|
||
}
|
||
return config.Level, nil
|
||
}
|