Files

274 lines
9.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 admin
import (
"context"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/logic/common"
)
// 用量日志分页 total 的统计策略:
//
// bil_usage_logs 是全系统写入量最大的分区表,明细精确 COUNT 的代价与筛选范围内
// 行数成正比,时间范围一宽,每翻一页都要重数一遍。这里按天拆段求和:
// - 完整天:bil_usage_daily 日汇总 SUM(request_count),O(天数) 而非 O(行数);
// - 头尾不完整天:明细表精确 COUNT,保持与列表完全一致的时间边界。
//
// 口径说明(与列表对齐的两个截断):
// 1. 自动清理(usage_log_cleanup)只删 bil_usage_logs 明细、不删日汇总,
// 超过保留期的完整天在日汇总里仍保留计数而行已删——完整天窗口按保留期
// 截断,被清掉的老区间不再计数(行已删,列表同样查不出)。
// 2. ID/用户/Key/请求类型无法从日粒度汇总还原,出现即整体回退精确 COUNT。
// 保留期内日汇总与明细仍可能因手动按维度清理产生少量偏差,分页 total 允许该近似。
//
// 历史覆盖:日汇总起点(MIN(stat_date))早于查询下界时才可信;未回填的缺口段
// 落入头段走明细 COUNT 补齐(正确但不加速),空表整体回退精确口径。
// usageLogCountPlan 日汇总计数的拆段计划(纯计算产物,便于单测)
type usageLogCountPlan struct {
fallback bool // true = 无法安全使用日汇总,整体回退明细精确 COUNT
dailyFrom time.Time // 完整天窗口下界(含,当天 00:00)
dailyTo time.Time // 完整天窗口上界(含,当天 00:00)
headFrom *time.Time // 头段下界(闭,nil=无下界);与 dailyFrom 之间为开区间
headTo *time.Time // 头段上界(开)
hasHead bool
tailFrom *time.Time // 尾段下界(闭)
tailTo *time.Time // 尾段上界(闭,nil=无上界)
hasTail bool
}
// usageLogCountDailyServable 判断筛选条件能否映射到 bil_usage_daily 的聚合维度
// (租户/渠道/模型/状态)。日汇总以天为粒度,ID/用户/Key/请求类型/上游请求ID无法还原。
func usageLogCountDailyServable(f v1.AdminUsageLogFilter) bool {
return f.ID == 0 && f.UserID == 0 && f.ApiKeyID == 0 && f.RequestType == 0 && f.UpstreamRequestId == ""
}
// parseUsageLogRange 解析筛选时间边界(与 buildUsageLogFilter 同口径:
// 仅日期补齐当天边界,带时间原样使用),任一侧缺省返回 nil。格式异常返回 ok=false。
func parseUsageLogRange(f v1.AdminUsageLogFilter) (startTs, endTs *time.Time, ok bool) {
parse := func(value, expanded string) (*time.Time, bool) {
if value == "" {
return nil, true
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", expanded, time.Local)
if err != nil {
return nil, false
}
return &t, true
}
var valid bool
if startTs, valid = parse(f.StartDate, common.StartOfRange(f.StartDate)); !valid {
return nil, nil, false
}
if endTs, valid = parse(f.EndDate, common.EndOfRange(f.EndDate)); !valid {
return nil, nil, false
}
return startTs, endTs, true
}
// buildUsageLogCountPlan 计算日汇总计数的拆段计划(纯函数,不访问数据库)。
// 完整天 D 的判定:D 00:00 >= start 且 D 次日 00:00 <= end;日汇总只聚合到
// 昨天(cron 每天 01:00 重算最近 3 个完整天,当天进行中的数据不聚合),
// 因此上界无论如何不超过昨天,当天与结束日的不完整部分落在尾段走明细。
func buildUsageLogCountPlan(startTs, endTs *time.Time, now time.Time, retentionDays int, minStat time.Time) usageLogCountPlan {
dayStart := func(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
todayStart := dayStart(now)
// 完整天窗口下界:起点当天 00:00 起算,起点带非零时刻则从次日起
lower := time.Time{}
if startTs != nil {
lower = dayStart(*startTs)
if !startTs.Equal(lower) {
lower = lower.AddDate(0, 0, 1)
}
}
// 完整天窗口上界:min(昨天, 结束时间能完整覆盖的最后一天)
upper := todayStart.AddDate(0, 0, -1)
if endTs != nil {
if e := dayStart(endTs.AddDate(0, 0, -1)); e.Before(upper) {
upper = e
}
}
// 保留期截断:超期明细已被清理而日汇总仍保留计数,截掉避免多算
if retentionDays > 0 {
if retainFrom := todayStart.AddDate(0, 0, -retentionDays); retainFrom.After(lower) {
lower = retainFrom
}
}
// 日汇总覆盖下界:历史未回填到 lower 时缺口段走明细(头段)补齐
if minStat.After(lower) {
lower = minStat
}
if lower.After(upper) {
return usageLogCountPlan{fallback: true}
}
plan := usageLogCountPlan{
dailyFrom: lower,
dailyTo: upper,
}
// 头段 [start, dailyFrom 00:00):开区间上界,避免与日汇总重叠
plan.hasHead = startTs == nil || startTs.Before(lower)
plan.headFrom = startTs
headTo := lower
plan.headTo = &headTo
// 尾段 [dailyTo 次日 00:00, end]:闭区间,覆盖当天/结束日的进行中数据
tailFrom := upper.AddDate(0, 0, 1)
plan.tailFrom = &tailFrom
plan.tailTo = endTs
plan.hasTail = endTs == nil || !endTs.Before(tailFrom)
return plan
}
// usageLogDimConds 构建可映射到日汇总维度的筛选条件(租户/渠道/模型/状态),
// 返回不带 WHERE 前缀的条件串,供明细计数与日汇总查询拼接复用。
func usageLogDimConds(f v1.AdminUsageLogFilter) (string, []any) {
var conds []string
var args []any
if f.TenantID > 0 {
conds = append(conds, "tenant_id = ?")
args = append(args, f.TenantID)
}
if f.ChannelID > 0 {
conds = append(conds, "channel_id = ?")
args = append(args, f.ChannelID)
}
if f.Model != "" {
conds = append(conds, "model_name = ?")
args = append(args, f.Model)
}
if f.Status != "" {
conds = append(conds, "status = ?")
args = append(args, f.Status)
}
return strings.Join(conds, " AND "), args
}
// countUsageLogsExact 对明细表做与列表口径完全一致的精确 COUNT(含全部筛选维度)
func countUsageLogsExact(ctx context.Context, f v1.AdminUsageLogFilter) (int, error) {
where, args := buildUsageLogFilter(f)
val, err := g.DB().GetValue(ctx, "SELECT COUNT(*) FROM bil_usage_logs u"+where, args...)
if err != nil {
return 0, err
}
return int(val.Int64()), nil
}
// usageLogDailyMinStatDate 读取日汇总最早统计日期,用于判断历史覆盖。
// stat_date 上有索引、表又极小,MIN 是首条索引探测;空表返回 ok=false。
func usageLogDailyMinStatDate(ctx context.Context) (time.Time, bool, error) {
val, err := g.DB().GetValue(ctx, "SELECT MIN(stat_date) FROM bil_usage_daily")
if err != nil {
return time.Time{}, false, err
}
if val.IsNil() {
return time.Time{}, false, nil
}
t := val.Time()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()), true, nil
}
// countUsageLogsRange 对明细表按维度条件 + 时间窗 COUNT。
// from 闭(created_at >= from,nil=无下界);toExclusive 为 true 时 to 为开区间
// (created_at < to),否则闭区间(created_at <= to)。
func countUsageLogsRange(ctx context.Context, f v1.AdminUsageLogFilter, from, to *time.Time, toExclusive bool) (int64, error) {
dimConds, args := usageLogDimConds(f)
var conds []string
if dimConds != "" {
conds = append(conds, dimConds)
}
if from != nil {
conds = append(conds, "created_at >= ?")
args = append(args, *from)
}
if to != nil {
if toExclusive {
conds = append(conds, "created_at < ?")
} else {
conds = append(conds, "created_at <= ?")
}
args = append(args, *to)
}
sqlStr := "SELECT COUNT(*) FROM bil_usage_logs"
if len(conds) > 0 {
sqlStr += " WHERE " + strings.Join(conds, " AND ")
}
val, err := g.DB().GetValue(ctx, sqlStr, args...)
if err != nil {
return 0, err
}
return val.Int64(), nil
}
// countUsageLogs 统计用量日志筛选结果总数(管理后台分页 total)。
// 不可映射维度 / 时间参数异常 / 短窗口(≤2 天,拆段无收益)一律走精确 COUNT,
// 与原口径完全一致;大范围才进入日汇总拆段路径。
func countUsageLogs(ctx context.Context, f v1.AdminUsageLogFilter) (int, error) {
if !usageLogCountDailyServable(f) {
return countUsageLogsExact(ctx, f)
}
startTs, endTs, ok := parseUsageLogRange(f)
if !ok {
return countUsageLogsExact(ctx, f)
}
if startTs != nil && endTs != nil && endTs.Sub(*startTs) <= 48*time.Hour {
return countUsageLogsExact(ctx, f)
}
// 日汇总覆盖检查:空表或读取失败均回退精确口径
minStat, hasDaily, err := usageLogDailyMinStatDate(ctx)
if err != nil || !hasDaily {
return countUsageLogsExact(ctx, f)
}
retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days")
if retentionDays == 0 {
retentionDays = 90
}
plan := buildUsageLogCountPlan(startTs, endTs, time.Now(), retentionDays, minStat)
if plan.fallback {
return countUsageLogsExact(ctx, f)
}
var total int64
// 头段:不完整起始天(或无下界时的保留期前零头)
if plan.hasHead {
n, err := countUsageLogsRange(ctx, f, plan.headFrom, plan.headTo, true)
if err != nil {
return 0, err
}
total += n
}
// 完整天:日汇总求和(维度条件与明细列一一对应)
dailySQL := "SELECT COALESCE(SUM(request_count), 0) FROM bil_usage_daily WHERE stat_date >= ? AND stat_date <= ?"
dailyArgs := []any{plan.dailyFrom.Format("2006-01-02"), plan.dailyTo.Format("2006-01-02")}
if dimConds, dimArgs := usageLogDimConds(f); dimConds != "" {
dailySQL += " AND " + dimConds
dailyArgs = append(dailyArgs, dimArgs...)
}
val, err := g.DB().GetValue(ctx, dailySQL, dailyArgs...)
if err != nil {
return 0, err
}
total += val.Int64()
// 尾段:当天及结束日的不完整部分
if plan.hasTail {
n, err := countUsageLogsRange(ctx, f, plan.tailFrom, plan.tailTo, false)
if err != nil {
return 0, err
}
total += n
}
return int(total), nil
}