Files
martin a6ed67d841 fix: 完成 P3 细节优化项
依据《代码分析结果.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 范围
2026-07-26 12:16:33 +08:00

235 lines
6.9 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 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
}