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

116 lines
3.3 KiB
Go

package task
import (
"context"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/logic/tenant"
)
// LifecycleCron 生命周期定时扫描总入口
func LifecycleCron(ctx context.Context) {
g.Log().Info(ctx, "[Lifecycle] starting cron scan...")
CheckTrialExpiry(ctx)
CheckGracePeriodExpiry(ctx)
CheckFrozenExpiry(ctx)
CheckClosingCooldown(ctx)
g.Log().Info(ctx, "[Lifecycle] cron scan completed")
}
// CheckTrialExpiry 扫描试用到期
func CheckTrialExpiry(ctx context.Context) {
now := time.Now()
result, err := dao.TntTenants.Ctx(ctx).
Where("status", "trial").
Where("trial_ends_at IS NOT NULL").
Where("trial_ends_at < ?", now).
Data(do.TntTenants{
Status: "frozen",
FrozenAt: gtime.NewFromTime(now),
DataRemovalAt: gtime.NewFromTime(now.Add(30 * 24 * time.Hour)),
}).Update()
if err != nil {
g.Log().Errorf(ctx, "[Lifecycle] CheckTrialExpiry: %v", err)
return
}
if rows, _ := result.RowsAffected(); rows > 0 {
g.Log().Infof(ctx, "[Lifecycle] CheckTrialExpiry: processed %d trial expiries", rows)
}
}
// CheckGracePeriodExpiry 扫描宽限期到期
func CheckGracePeriodExpiry(ctx context.Context) {
now := time.Now()
_, err := dao.TntTenants.Ctx(ctx).
Where("status", "past_due").
Where("grace_period_ends_at IS NOT NULL").
Where("grace_period_ends_at < ?", now).
Data(do.TntTenants{
Status: "frozen",
FrozenAt: gtime.NewFromTime(now),
DataRemovalAt: gtime.NewFromTime(now.Add(30 * 24 * time.Hour)),
}).Update()
if err != nil {
g.Log().Errorf(ctx, "[Lifecycle] CheckGracePeriodExpiry: %v", err)
return
}
g.Log().Info(ctx, "[Lifecycle] CheckGracePeriodExpiry: processed")
}
// CheckFrozenExpiry 扫描冻结到期(30 天后终止)
func CheckFrozenExpiry(ctx context.Context) {
now := time.Now()
_, err := dao.TntTenants.Ctx(ctx).
Where("status", "frozen").
Where("data_removal_at IS NOT NULL").
Where("data_removal_at < ?", now).
Data(do.TntTenants{
Status: "terminated",
}).Update()
if err != nil {
g.Log().Errorf(ctx, "[Lifecycle] CheckFrozenExpiry: %v", err)
return
}
g.Log().Info(ctx, "[Lifecycle] CheckFrozenExpiry: processed")
}
// CheckClosingCooldown 扫描冷静期到期(7 天后关闭)
func CheckClosingCooldown(ctx context.Context) {
now := time.Now()
_, err := dao.TntTenants.Ctx(ctx).
Where("status", "closing").
Where("closing_requested_at IS NOT NULL").
Where("closing_requested_at < ?", now.Add(-7*24*time.Hour)).
Data(do.TntTenants{
Status: "closed",
}).Update()
if err != nil {
g.Log().Errorf(ctx, "[Lifecycle] CheckClosingCooldown: %v", err)
return
}
g.Log().Info(ctx, "[Lifecycle] CheckClosingCooldown: processed")
}
// EnsureTenantHasWallet 确保租户有钱包(用于解冻时检查)
func EnsureTenantActive(ctx context.Context, tenantID int64) {
// Check if tenant has an active subscription
var activePlan *struct {
ID int64 `json:"id"`
}
err := dao.PlnTenantPlans.Ctx(ctx).
Where("tenant_id", tenantID).
Where("status", "active").
Where("end_at > ?", time.Now()).
Limit(1).
Scan(&activePlan)
if err == nil && activePlan != nil {
// Has active plan, can unfreeze
tenant.TransitionTenantStatus(ctx, tenantID, "active")
}
}