usage_log_repo_trend.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  9. )
  10. // TrendDataPoint represents a single point in trend data
  11. type TrendDataPoint = usagestats.TrendDataPoint
  12. // ModelStat represents usage statistics for a single model
  13. type ModelStat = usagestats.ModelStat
  14. // UserUsageTrendPoint represents user usage trend data point
  15. type UserUsageTrendPoint = usagestats.UserUsageTrendPoint
  16. // UserSpendingRankingItem represents a user spending ranking row.
  17. type UserSpendingRankingItem = usagestats.UserSpendingRankingItem
  18. type UserSpendingRankingResponse = usagestats.UserSpendingRankingResponse
  19. // APIKeyUsageTrendPoint represents API key usage trend data point
  20. type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint
  21. // GetAPIKeyUsageTrend returns usage trend data grouped by API key and date
  22. func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []APIKeyUsageTrendPoint, err error) {
  23. dateFormat := safeDateFormat(granularity)
  24. query := fmt.Sprintf(`
  25. WITH top_keys AS (
  26. SELECT api_key_id
  27. FROM usage_logs
  28. WHERE created_at >= $1 AND created_at < $2
  29. GROUP BY api_key_id
  30. ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC
  31. LIMIT $3
  32. )
  33. SELECT
  34. TO_CHAR(u.created_at, '%s') as date,
  35. u.api_key_id,
  36. COALESCE(k.name, '') as key_name,
  37. COUNT(*) as requests,
  38. COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens
  39. FROM usage_logs u
  40. LEFT JOIN api_keys k ON u.api_key_id = k.id
  41. WHERE u.api_key_id IN (SELECT api_key_id FROM top_keys)
  42. AND u.created_at >= $4 AND u.created_at < $5
  43. GROUP BY date, u.api_key_id, k.name
  44. ORDER BY date ASC, tokens DESC
  45. `, dateFormat)
  46. rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime)
  47. if err != nil {
  48. return nil, err
  49. }
  50. defer func() {
  51. // 保持主错误优先;仅在无错误时回传 Close 失败。
  52. // 同时清空返回值,避免误用不完整结果。
  53. if closeErr := rows.Close(); closeErr != nil && err == nil {
  54. err = closeErr
  55. results = nil
  56. }
  57. }()
  58. results = make([]APIKeyUsageTrendPoint, 0)
  59. for rows.Next() {
  60. var row APIKeyUsageTrendPoint
  61. if err = rows.Scan(&row.Date, &row.APIKeyID, &row.KeyName, &row.Requests, &row.Tokens); err != nil {
  62. return nil, err
  63. }
  64. results = append(results, row)
  65. }
  66. if err = rows.Err(); err != nil {
  67. return nil, err
  68. }
  69. return results, nil
  70. }
  71. // GetUserUsageTrend returns usage trend data grouped by user and date
  72. func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) {
  73. dateFormat := safeDateFormat(granularity)
  74. query := fmt.Sprintf(`
  75. WITH top_users AS (
  76. SELECT user_id
  77. FROM usage_logs
  78. WHERE created_at >= $1 AND created_at < $2
  79. GROUP BY user_id
  80. ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC
  81. LIMIT $3
  82. )
  83. SELECT
  84. TO_CHAR(u.created_at, '%s') as date,
  85. u.user_id,
  86. COALESCE(us.email, '') as email,
  87. COALESCE(us.username, '') as username,
  88. COUNT(*) as requests,
  89. COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens,
  90. COALESCE(SUM(u.total_cost), 0) as cost,
  91. COALESCE(SUM(u.actual_cost), 0) as actual_cost
  92. FROM usage_logs u
  93. LEFT JOIN users us ON u.user_id = us.id
  94. WHERE u.user_id IN (SELECT user_id FROM top_users)
  95. AND u.created_at >= $4 AND u.created_at < $5
  96. GROUP BY date, u.user_id, us.email, us.username
  97. ORDER BY date ASC, tokens DESC
  98. `, dateFormat)
  99. rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime)
  100. if err != nil {
  101. return nil, err
  102. }
  103. defer func() {
  104. // 保持主错误优先;仅在无错误时回传 Close 失败。
  105. // 同时清空返回值,避免误用不完整结果。
  106. if closeErr := rows.Close(); closeErr != nil && err == nil {
  107. err = closeErr
  108. results = nil
  109. }
  110. }()
  111. results = make([]UserUsageTrendPoint, 0)
  112. for rows.Next() {
  113. var row UserUsageTrendPoint
  114. if err = rows.Scan(&row.Date, &row.UserID, &row.Email, &row.Username, &row.Requests, &row.Tokens, &row.Cost, &row.ActualCost); err != nil {
  115. return nil, err
  116. }
  117. results = append(results, row)
  118. }
  119. if err = rows.Err(); err != nil {
  120. return nil, err
  121. }
  122. return results, nil
  123. }
  124. // GetUserSpendingRanking returns user spending ranking aggregated within the time range.
  125. func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (result *UserSpendingRankingResponse, err error) {
  126. if limit <= 0 {
  127. limit = 12
  128. }
  129. query := `
  130. WITH user_spend AS (
  131. SELECT
  132. u.user_id,
  133. COALESCE(us.email, '') as email,
  134. COALESCE(us.username, '') as username,
  135. COALESCE(SUM(u.actual_cost), 0) as actual_cost,
  136. COUNT(*) as requests,
  137. COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens
  138. FROM usage_logs u
  139. LEFT JOIN users us ON u.user_id = us.id
  140. WHERE u.created_at >= $1 AND u.created_at < $2
  141. GROUP BY u.user_id, us.email, us.username
  142. ),
  143. ranked AS (
  144. SELECT
  145. user_id,
  146. email,
  147. username,
  148. actual_cost,
  149. requests,
  150. tokens,
  151. COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost,
  152. COALESCE(SUM(requests) OVER (), 0) as total_requests,
  153. COALESCE(SUM(tokens) OVER (), 0) as total_tokens
  154. FROM user_spend
  155. ORDER BY actual_cost DESC, tokens DESC, user_id ASC
  156. LIMIT $3
  157. )
  158. SELECT
  159. user_id,
  160. email,
  161. username,
  162. actual_cost,
  163. requests,
  164. tokens,
  165. total_actual_cost,
  166. total_requests,
  167. total_tokens
  168. FROM ranked
  169. ORDER BY actual_cost DESC, tokens DESC, user_id ASC
  170. `
  171. rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit)
  172. if err != nil {
  173. return nil, err
  174. }
  175. defer func() {
  176. if closeErr := rows.Close(); closeErr != nil && err == nil {
  177. err = closeErr
  178. result = nil
  179. }
  180. }()
  181. ranking := make([]UserSpendingRankingItem, 0)
  182. totalActualCost := 0.0
  183. totalRequests := int64(0)
  184. totalTokens := int64(0)
  185. for rows.Next() {
  186. var row UserSpendingRankingItem
  187. if err = rows.Scan(&row.UserID, &row.Email, &row.Username, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
  188. return nil, err
  189. }
  190. ranking = append(ranking, row)
  191. }
  192. if err = rows.Err(); err != nil {
  193. return nil, err
  194. }
  195. return &UserSpendingRankingResponse{
  196. Ranking: ranking,
  197. TotalActualCost: totalActualCost,
  198. TotalRequests: totalRequests,
  199. TotalTokens: totalTokens,
  200. }, nil
  201. }
  202. // GetUserUsageTrendByUserID 获取指定用户的使用趋势
  203. func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
  204. dateFormat := safeDateFormat(granularity)
  205. query := fmt.Sprintf(`
  206. SELECT
  207. TO_CHAR(created_at, '%s') as date,
  208. COUNT(*) as requests,
  209. COALESCE(SUM(input_tokens), 0) as input_tokens,
  210. COALESCE(SUM(output_tokens), 0) as output_tokens,
  211. COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
  212. COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
  213. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
  214. COALESCE(SUM(total_cost), 0) as cost,
  215. COALESCE(SUM(actual_cost), 0) as actual_cost
  216. FROM usage_logs
  217. WHERE user_id = $1 AND created_at >= $2 AND created_at < $3
  218. GROUP BY date
  219. ORDER BY date ASC
  220. `, dateFormat)
  221. rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime)
  222. if err != nil {
  223. return nil, err
  224. }
  225. defer func() {
  226. // 保持主错误优先;仅在无错误时回传 Close 失败。
  227. // 同时清空返回值,避免误用不完整结果。
  228. if closeErr := rows.Close(); closeErr != nil && err == nil {
  229. err = closeErr
  230. results = nil
  231. }
  232. }()
  233. results, err = scanTrendRows(rows)
  234. if err != nil {
  235. return nil, err
  236. }
  237. return results, nil
  238. }
  239. // GetUserModelStats 获取指定用户的模型统计
  240. func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) {
  241. return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil, nil)
  242. }
  243. // GetUsageTrendWithFilters returns usage trend data with optional filters
  244. func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) {
  245. return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil, nil)
  246. }
  247. func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) {
  248. return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch, filters.NativeCompactionV2)
  249. }
  250. func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) (results []TrendDataPoint, err error) {
  251. if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch, nativeCompactionV2) {
  252. aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity)
  253. if aggregatedErr == nil && len(aggregated) > 0 {
  254. return aggregated, nil
  255. }
  256. }
  257. dateFormat := safeDateFormat(granularity)
  258. query := fmt.Sprintf(`
  259. SELECT
  260. TO_CHAR(created_at, '%s') as date,
  261. COUNT(*) as requests,
  262. COALESCE(SUM(input_tokens), 0) as input_tokens,
  263. COALESCE(SUM(output_tokens), 0) as output_tokens,
  264. COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
  265. COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
  266. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
  267. COALESCE(SUM(total_cost), 0) as cost,
  268. COALESCE(SUM(actual_cost), 0) as actual_cost
  269. FROM usage_logs
  270. WHERE created_at >= $1 AND created_at < $2
  271. `, dateFormat)
  272. args := []any{startTime, endTime}
  273. if userID > 0 {
  274. query += fmt.Sprintf(" AND user_id = $%d", len(args)+1)
  275. args = append(args, userID)
  276. }
  277. if apiKeyID > 0 {
  278. query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1)
  279. args = append(args, apiKeyID)
  280. }
  281. if accountID > 0 {
  282. query += fmt.Sprintf(" AND account_id = $%d", len(args)+1)
  283. args = append(args, accountID)
  284. }
  285. if groupID > 0 {
  286. query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
  287. args = append(args, groupID)
  288. }
  289. query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource)
  290. query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
  291. query, args = appendNativeCompactionV2QueryFilter(query, args, nativeCompactionV2, "")
  292. if billingType != nil {
  293. query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
  294. args = append(args, int16(*billingType))
  295. }
  296. query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
  297. if upstreamModelMismatch != nil {
  298. query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
  299. }
  300. query += " GROUP BY date ORDER BY date ASC"
  301. rows, err := r.sql.QueryContext(ctx, query, args...)
  302. if err != nil {
  303. return nil, err
  304. }
  305. defer func() {
  306. // 保持主错误优先;仅在无错误时回传 Close 失败。
  307. // 同时清空返回值,避免误用不完整结果。
  308. if closeErr := rows.Close(); closeErr != nil && err == nil {
  309. err = closeErr
  310. results = nil
  311. }
  312. }()
  313. results, err = scanTrendRows(rows)
  314. if err != nil {
  315. return nil, err
  316. }
  317. return results, nil
  318. }
  319. func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) bool {
  320. if granularity != "day" && granularity != "hour" {
  321. return false
  322. }
  323. return userID == 0 &&
  324. apiKeyID == 0 &&
  325. accountID == 0 &&
  326. groupID == 0 &&
  327. model == "" &&
  328. requestType == nil &&
  329. stream == nil &&
  330. billingType == nil &&
  331. billingMode == "" &&
  332. upstreamModelMismatch == nil &&
  333. nativeCompactionV2 == nil
  334. }
  335. func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
  336. dateFormat := safeDateFormat(granularity)
  337. query := ""
  338. args := []any{startTime, endTime}
  339. switch granularity {
  340. case "hour":
  341. query = fmt.Sprintf(`
  342. SELECT
  343. TO_CHAR(bucket_start, '%s') as date,
  344. total_requests as requests,
  345. input_tokens,
  346. output_tokens,
  347. cache_creation_tokens,
  348. cache_read_tokens,
  349. (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens,
  350. total_cost as cost,
  351. actual_cost
  352. FROM usage_dashboard_hourly
  353. WHERE bucket_start >= $1 AND bucket_start < $2
  354. ORDER BY bucket_start ASC
  355. `, dateFormat)
  356. case "day":
  357. query = fmt.Sprintf(`
  358. SELECT
  359. TO_CHAR(bucket_date::timestamp, '%s') as date,
  360. total_requests as requests,
  361. input_tokens,
  362. output_tokens,
  363. cache_creation_tokens,
  364. cache_read_tokens,
  365. (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens,
  366. total_cost as cost,
  367. actual_cost
  368. FROM usage_dashboard_daily
  369. WHERE bucket_date >= $1::date AND bucket_date < $2::date
  370. ORDER BY bucket_date ASC
  371. `, dateFormat)
  372. default:
  373. return nil, nil
  374. }
  375. rows, err := r.sql.QueryContext(ctx, query, args...)
  376. if err != nil {
  377. return nil, err
  378. }
  379. defer func() {
  380. if closeErr := rows.Close(); closeErr != nil && err == nil {
  381. err = closeErr
  382. results = nil
  383. }
  384. }()
  385. results, err = scanTrendRows(rows)
  386. if err != nil {
  387. return nil, err
  388. }
  389. return results, nil
  390. }
  391. // GetModelStatsWithFilters returns model statistics with optional filters
  392. func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
  393. return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil, nil)
  394. }
  395. // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
  396. // source: requested | upstream | mapping.
  397. func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
  398. return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil, nil)
  399. }
  400. func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) {
  401. return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch, filters.NativeCompactionV2)
  402. }
  403. func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) (results []ModelStat, err error) {
  404. actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
  405. // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
  406. if accountID > 0 && userID == 0 && apiKeyID == 0 {
  407. actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost"
  408. }
  409. accountCostExpr := "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as account_cost"
  410. modelExpr := resolveModelDimensionExpression(source)
  411. query := fmt.Sprintf(`
  412. SELECT
  413. %s as model,
  414. COUNT(*) as requests,
  415. COALESCE(SUM(input_tokens), 0) as input_tokens,
  416. COALESCE(SUM(output_tokens), 0) as output_tokens,
  417. COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
  418. COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
  419. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
  420. COALESCE(SUM(total_cost), 0) as cost,
  421. %s,
  422. %s
  423. FROM usage_logs
  424. WHERE created_at >= $1 AND created_at < $2
  425. `, modelExpr, actualCostExpr, accountCostExpr)
  426. args := []any{startTime, endTime}
  427. if userID > 0 {
  428. query += fmt.Sprintf(" AND user_id = $%d", len(args)+1)
  429. args = append(args, userID)
  430. }
  431. if apiKeyID > 0 {
  432. query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1)
  433. args = append(args, apiKeyID)
  434. }
  435. if accountID > 0 {
  436. query += fmt.Sprintf(" AND account_id = $%d", len(args)+1)
  437. args = append(args, accountID)
  438. }
  439. if groupID > 0 {
  440. query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
  441. args = append(args, groupID)
  442. }
  443. if strings.TrimSpace(model) != "" {
  444. query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1)
  445. args = append(args, model)
  446. }
  447. query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
  448. query, args = appendNativeCompactionV2QueryFilter(query, args, nativeCompactionV2, "")
  449. if billingType != nil {
  450. query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
  451. args = append(args, int16(*billingType))
  452. }
  453. query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
  454. if upstreamModelMismatch != nil {
  455. query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
  456. }
  457. query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr)
  458. rows, err := r.sql.QueryContext(ctx, query, args...)
  459. if err != nil {
  460. return nil, err
  461. }
  462. defer func() {
  463. // 保持主错误优先;仅在无错误时回传 Close 失败。
  464. // 同时清空返回值,避免误用不完整结果。
  465. if closeErr := rows.Close(); closeErr != nil && err == nil {
  466. err = closeErr
  467. results = nil
  468. }
  469. }()
  470. results, err = scanModelStatsRows(rows)
  471. if err != nil {
  472. return nil, err
  473. }
  474. return results, nil
  475. }
  476. // GetGroupStatsWithFilters returns group usage statistics with optional filters
  477. func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
  478. return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil, nil)
  479. }
  480. func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) {
  481. return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch, filters.NativeCompactionV2)
  482. }
  483. func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) (results []usagestats.GroupStat, err error) {
  484. query := `
  485. SELECT
  486. COALESCE(ul.group_id, 0) as group_id,
  487. COALESCE(g.name, '') as group_name,
  488. COUNT(*) as requests,
  489. COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
  490. COALESCE(SUM(ul.total_cost), 0) as cost,
  491. COALESCE(SUM(ul.actual_cost), 0) as actual_cost,
  492. COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost
  493. FROM usage_logs ul
  494. LEFT JOIN groups g ON g.id = ul.group_id
  495. WHERE ul.created_at >= $1 AND ul.created_at < $2
  496. `
  497. args := []any{startTime, endTime}
  498. if userID > 0 {
  499. query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1)
  500. args = append(args, userID)
  501. }
  502. if apiKeyID > 0 {
  503. query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1)
  504. args = append(args, apiKeyID)
  505. }
  506. if accountID > 0 {
  507. query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1)
  508. args = append(args, accountID)
  509. }
  510. if groupID > 0 {
  511. query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
  512. args = append(args, groupID)
  513. }
  514. if strings.TrimSpace(model) != "" {
  515. modelExpr := resolveModelDimensionExpressionWithAlias(usagestats.ModelSourceRequested, "ul")
  516. query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1)
  517. args = append(args, model)
  518. }
  519. query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
  520. query, args = appendNativeCompactionV2QueryFilter(query, args, nativeCompactionV2, "ul")
  521. if billingType != nil {
  522. query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1)
  523. args = append(args, int16(*billingType))
  524. }
  525. query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul")
  526. if upstreamModelMismatch != nil {
  527. query += " AND " + upstreamModelMismatchCondition("ul.upstream_model_mismatch", *upstreamModelMismatch)
  528. }
  529. query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
  530. rows, err := r.sql.QueryContext(ctx, query, args...)
  531. if err != nil {
  532. return nil, err
  533. }
  534. defer func() {
  535. if closeErr := rows.Close(); closeErr != nil && err == nil {
  536. err = closeErr
  537. results = nil
  538. }
  539. }()
  540. results = make([]usagestats.GroupStat, 0)
  541. for rows.Next() {
  542. var row usagestats.GroupStat
  543. if err := rows.Scan(
  544. &row.GroupID,
  545. &row.GroupName,
  546. &row.Requests,
  547. &row.TotalTokens,
  548. &row.Cost,
  549. &row.ActualCost,
  550. &row.AccountCost,
  551. ); err != nil {
  552. return nil, err
  553. }
  554. results = append(results, row)
  555. }
  556. if err := rows.Err(); err != nil {
  557. return nil, err
  558. }
  559. return results, nil
  560. }
  561. // GetUserBreakdownStats returns per-user usage breakdown within a specific dimension.
  562. func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) {
  563. query := `
  564. SELECT
  565. COALESCE(ul.user_id, 0) as user_id,
  566. COALESCE(u.email, '') as email,
  567. COUNT(*) as requests,
  568. COALESCE(SUM(ul.input_tokens), 0) as input_tokens,
  569. COALESCE(SUM(ul.output_tokens), 0) as output_tokens,
  570. COALESCE(SUM(ul.cache_creation_tokens + ul.cache_read_tokens), 0) as cache_tokens,
  571. COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
  572. COALESCE(SUM(ul.total_cost), 0) as cost,
  573. COALESCE(SUM(ul.actual_cost), 0) as actual_cost,
  574. COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost
  575. FROM usage_logs ul
  576. LEFT JOIN users u ON u.id = ul.user_id
  577. WHERE ul.created_at >= $1 AND ul.created_at < $2
  578. `
  579. args := []any{startTime, endTime}
  580. if dim.GroupID > 0 {
  581. query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
  582. args = append(args, dim.GroupID)
  583. }
  584. if dim.Model != "" {
  585. query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(dim.ModelType), len(args)+1)
  586. args = append(args, dim.Model)
  587. }
  588. if dim.Endpoint != "" {
  589. col := resolveEndpointColumn(dim.EndpointType)
  590. query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1)
  591. args = append(args, dim.Endpoint)
  592. }
  593. if dim.UserID > 0 {
  594. query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1)
  595. args = append(args, dim.UserID)
  596. }
  597. if dim.APIKeyID > 0 {
  598. query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1)
  599. args = append(args, dim.APIKeyID)
  600. }
  601. if dim.AccountID > 0 {
  602. query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1)
  603. args = append(args, dim.AccountID)
  604. }
  605. if dim.RequestType != nil {
  606. condition, conditionArgs := buildRequestTypeFilterConditionWithAlias(len(args)+1, *dim.RequestType, "ul")
  607. query += " AND " + condition
  608. args = append(args, conditionArgs...)
  609. }
  610. if dim.Stream != nil {
  611. query += fmt.Sprintf(" AND ul.stream = $%d", len(args)+1)
  612. args = append(args, *dim.Stream)
  613. }
  614. query, args = appendNativeCompactionV2QueryFilter(query, args, dim.NativeCompactionV2, "ul")
  615. if dim.BillingType != nil {
  616. query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1)
  617. args = append(args, *dim.BillingType)
  618. }
  619. // ORDER BY 列来自固定 allowlist(非用户原样字符串),避免 SQL 注入。
  620. orderBy := "actual_cost"
  621. switch dim.SortBy {
  622. case "total_tokens", "input_tokens", "output_tokens", "cache_tokens", "requests", "cost", "actual_cost":
  623. orderBy = dim.SortBy
  624. }
  625. query += " GROUP BY ul.user_id, u.email ORDER BY " + orderBy + " DESC"
  626. if limit > 0 {
  627. query += fmt.Sprintf(" LIMIT %d", limit)
  628. }
  629. rows, err := r.sql.QueryContext(ctx, query, args...)
  630. if err != nil {
  631. return nil, err
  632. }
  633. defer func() {
  634. if closeErr := rows.Close(); closeErr != nil && err == nil {
  635. err = closeErr
  636. results = nil
  637. }
  638. }()
  639. results = make([]usagestats.UserBreakdownItem, 0)
  640. for rows.Next() {
  641. var row usagestats.UserBreakdownItem
  642. if err := rows.Scan(
  643. &row.UserID,
  644. &row.Email,
  645. &row.Requests,
  646. &row.InputTokens,
  647. &row.OutputTokens,
  648. &row.CacheTokens,
  649. &row.TotalTokens,
  650. &row.Cost,
  651. &row.ActualCost,
  652. &row.AccountCost,
  653. ); err != nil {
  654. return nil, err
  655. }
  656. results = append(results, row)
  657. }
  658. if err := rows.Err(); err != nil {
  659. return nil, err
  660. }
  661. return results, nil
  662. }
  663. // GetAllGroupUsageSummary 返回所有分组在服务端配置时区内的今日、昨日与当前保留记录累计金额。
  664. func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
  665. return r.getAllGroupUsageSummaryFromRollups(ctx, todayStart)
  666. }
  667. // resolveModelDimensionExpression maps model source type to a safe SQL expression.
  668. func resolveModelDimensionExpression(modelType string) string {
  669. return resolveModelDimensionExpressionWithAlias(modelType, "")
  670. }
  671. func resolveModelDimensionExpressionWithAlias(modelType, alias string) string {
  672. column := func(name string) string {
  673. if alias == "" {
  674. return name
  675. }
  676. return alias + "." + name
  677. }
  678. requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model"))
  679. upstreamExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), column("model"))
  680. switch usagestats.NormalizeModelSource(modelType) {
  681. case usagestats.ModelSourceUpstream:
  682. return upstreamExpr
  683. case usagestats.ModelSourceMapping:
  684. return fmt.Sprintf("(%s || ' -> ' || %s)", requestedExpr, upstreamExpr)
  685. default:
  686. return requestedExpr
  687. }
  688. }
  689. func scanTrendRows(rows *sql.Rows) ([]TrendDataPoint, error) {
  690. results := make([]TrendDataPoint, 0)
  691. for rows.Next() {
  692. var row TrendDataPoint
  693. if err := rows.Scan(
  694. &row.Date,
  695. &row.Requests,
  696. &row.InputTokens,
  697. &row.OutputTokens,
  698. &row.CacheCreationTokens,
  699. &row.CacheReadTokens,
  700. &row.TotalTokens,
  701. &row.Cost,
  702. &row.ActualCost,
  703. ); err != nil {
  704. return nil, err
  705. }
  706. results = append(results, row)
  707. }
  708. if err := rows.Err(); err != nil {
  709. return nil, err
  710. }
  711. return results, nil
  712. }
  713. func scanModelStatsRows(rows *sql.Rows) ([]ModelStat, error) {
  714. results := make([]ModelStat, 0)
  715. for rows.Next() {
  716. var row ModelStat
  717. if err := rows.Scan(
  718. &row.Model,
  719. &row.Requests,
  720. &row.InputTokens,
  721. &row.OutputTokens,
  722. &row.CacheCreationTokens,
  723. &row.CacheReadTokens,
  724. &row.TotalTokens,
  725. &row.Cost,
  726. &row.ActualCost,
  727. &row.AccountCost,
  728. ); err != nil {
  729. return nil, err
  730. }
  731. results = append(results, row)
  732. }
  733. if err := rows.Err(); err != nil {
  734. return nil, err
  735. }
  736. return results, nil
  737. }