usage_log_repo.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. package repository
  2. import (
  3. "database/sql"
  4. "fmt"
  5. "strings"
  6. "sync"
  7. "time"
  8. dbent "github.com/Wei-Shaw/sub2api/ent"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. gocache "github.com/patrickmn/go-cache"
  11. )
  12. const rawUsageLogModelColumn = "model"
  13. // rawUsageLogModelColumn preserves the exact stored usage_logs.model semantics for direct filters.
  14. // Historical rows may contain upstream/billing model values, while newer rows store requested_model.
  15. // Requested/upstream/mapping analytics must use resolveModelDimensionExpression instead.
  16. // usageLogSuccessFilterUL 用于把"失败请求 usage log"(tokens=0、cost=0、不计费的占位记录)
  17. // 从统计性聚合中排除,避免污染 Dashboard / 用量拆分等指标。
  18. //
  19. // schema 中没有 success bool 列;新增列要做迁移,风险大;这里用 actual_cost > 0 作为代理:
  20. // 任何成功落账的请求都会产生 actual_cost(包括 token 计费、纯图片 token 计费、按次/按图计费),
  21. // 反之 failed-request usage log 的 actual_cost 为 0。
  22. // 早期版本用 4 项 token 和 > 0 判定会把"按次/按图计费"与"image_output_tokens 独立计费"的纯图片
  23. // 请求误判为失败,导致这部分请求从用量统计里消失,故改用 actual_cost。
  24. // 配合 `FROM usage_logs ul` JOIN 查询使用。
  25. const usageLogSuccessFilterUL = "ul.actual_cost > 0"
  26. // usageLogEffectivePlatformExpr 用于按"有效平台"维度聚合 usage_logs:
  27. // 优先取请求实际走的分组 platform,若分组未设置 platform 再 fallback 到 account.platform。
  28. // Composite groups are a routing layer, so platform analytics must use the
  29. // resolved concrete account platform instead of grouping spend under "composite".
  30. // 配套要求查询里 LEFT JOIN groups g ON g.id = ul.group_id 与 LEFT JOIN accounts a ON a.id = ul.account_id。
  31. const usageLogEffectivePlatformExpr = "CASE WHEN g.platform = 'composite' THEN a.platform ELSE COALESCE(NULLIF(g.platform,''), a.platform) END"
  32. // dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL
  33. var dateFormatWhitelist = map[string]string{
  34. "hour": "YYYY-MM-DD HH24:00",
  35. "day": "YYYY-MM-DD",
  36. "week": "IYYY-IW",
  37. "month": "YYYY-MM",
  38. }
  39. // safeDateFormat 根据白名单获取 dateFormat,未匹配时返回默认值
  40. func safeDateFormat(granularity string) string {
  41. if f, ok := dateFormatWhitelist[granularity]; ok {
  42. return f
  43. }
  44. return "YYYY-MM-DD"
  45. }
  46. // appendRawUsageLogModelWhereCondition keeps direct model filters on the raw model column for backward
  47. // compatibility with historical rows. Requested/upstream analytics must use
  48. // resolveModelDimensionExpression instead.
  49. func appendRawUsageLogModelWhereCondition(conditions []string, args []any, model string) ([]string, []any) {
  50. if strings.TrimSpace(model) == "" {
  51. return conditions, args
  52. }
  53. conditions = append(conditions, fmt.Sprintf("%s = $%d", rawUsageLogModelColumn, len(args)+1))
  54. args = append(args, model)
  55. return conditions, args
  56. }
  57. func appendUsageLogBillingModeWhereCondition(conditions []string, args []any, billingMode string) ([]string, []any) {
  58. return appendUsageLogBillingModeWhereConditionWithAlias(conditions, args, billingMode, "")
  59. }
  60. func appendUsageLogBillingModeWhereConditionWithAlias(conditions []string, args []any, billingMode string, alias string) ([]string, []any) {
  61. mode := strings.TrimSpace(billingMode)
  62. if mode == "" {
  63. return conditions, args
  64. }
  65. column := func(name string) string {
  66. if alias == "" {
  67. return name
  68. }
  69. return alias + "." + name
  70. }
  71. placeholder := fmt.Sprintf("$%d", len(args)+1)
  72. switch service.BillingMode(mode) {
  73. case service.BillingModeImage:
  74. conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) > 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count")))
  75. case service.BillingModeVideo:
  76. conditions = append(conditions, fmt.Sprintf("%s = %s", column("billing_mode"), placeholder))
  77. case service.BillingModeToken:
  78. conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) <= 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count")))
  79. default:
  80. conditions = append(conditions, fmt.Sprintf("%s = %s", column("billing_mode"), placeholder))
  81. }
  82. args = append(args, mode)
  83. return conditions, args
  84. }
  85. func appendUsageLogBillingModeQueryFilter(query string, args []any, billingMode string, alias string) (string, []any) {
  86. conditions, args := appendUsageLogBillingModeWhereConditionWithAlias(nil, args, billingMode, alias)
  87. if len(conditions) == 0 {
  88. return query, args
  89. }
  90. return query + " AND " + conditions[0], args
  91. }
  92. func appendUsageLogModelWhereCondition(conditions []string, args []any, model string, source string) ([]string, []any) {
  93. if strings.TrimSpace(source) == "" {
  94. return appendRawUsageLogModelWhereCondition(conditions, args, model)
  95. }
  96. if strings.TrimSpace(model) == "" {
  97. return conditions, args
  98. }
  99. conditions = append(conditions, fmt.Sprintf("%s = $%d", resolveModelDimensionExpression(source), len(args)+1))
  100. args = append(args, model)
  101. return conditions, args
  102. }
  103. // appendRawUsageLogModelQueryFilter keeps direct model filters on the raw model column for backward
  104. // compatibility with historical rows. Requested/upstream analytics must use
  105. // resolveModelDimensionExpression instead.
  106. func appendRawUsageLogModelQueryFilter(query string, args []any, model string) (string, []any) {
  107. if strings.TrimSpace(model) == "" {
  108. return query, args
  109. }
  110. query += fmt.Sprintf(" AND %s = $%d", rawUsageLogModelColumn, len(args)+1)
  111. args = append(args, model)
  112. return query, args
  113. }
  114. func appendUsageLogModelQueryFilter(query string, args []any, model string, source string) (string, []any) {
  115. if strings.TrimSpace(source) == "" {
  116. return appendRawUsageLogModelQueryFilter(query, args, model)
  117. }
  118. if strings.TrimSpace(model) == "" {
  119. return query, args
  120. }
  121. query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(source), len(args)+1)
  122. args = append(args, model)
  123. return query, args
  124. }
  125. type usageLogRepository struct {
  126. client *dbent.Client
  127. sql sqlExecutor
  128. db *sql.DB
  129. createBatchOnce sync.Once
  130. createBatchCh chan usageLogCreateRequest
  131. bestEffortBatchOnce sync.Once
  132. bestEffortBatchCh chan usageLogBestEffortRequest
  133. bestEffortRecent *gocache.Cache
  134. }
  135. func NewUsageLogRepository(client *dbent.Client, sqlDB *sql.DB) service.UsageLogRepository {
  136. return newUsageLogRepositoryWithSQL(client, sqlDB)
  137. }
  138. func newUsageLogRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *usageLogRepository {
  139. // 使用 scanSingleRow 替代 QueryRowContext,保证 ent.Tx 作为 sqlExecutor 可用。
  140. repo := &usageLogRepository{client: client, sql: sqlq}
  141. if db, ok := sqlq.(*sql.DB); ok {
  142. repo.db = db
  143. }
  144. repo.bestEffortRecent = gocache.New(usageLogBestEffortRecentTTL, time.Minute)
  145. return repo
  146. }
  147. func buildWhere(conditions []string) string {
  148. if len(conditions) == 0 {
  149. return ""
  150. }
  151. return "WHERE " + strings.Join(conditions, " AND ")
  152. }
  153. func appendRequestTypeOrStreamWhereCondition(conditions []string, args []any, requestType *int16, stream *bool) ([]string, []any) {
  154. if requestType != nil {
  155. condition, conditionArgs := buildRequestTypeFilterCondition(len(args)+1, *requestType)
  156. conditions = append(conditions, condition)
  157. args = append(args, conditionArgs...)
  158. return conditions, args
  159. }
  160. if stream != nil {
  161. conditions = append(conditions, fmt.Sprintf("stream = $%d", len(args)+1))
  162. args = append(args, *stream)
  163. }
  164. return conditions, args
  165. }
  166. func appendRequestTypeOrStreamQueryFilter(query string, args []any, requestType *int16, stream *bool) (string, []any) {
  167. if requestType != nil {
  168. condition, conditionArgs := buildRequestTypeFilterCondition(len(args)+1, *requestType)
  169. query += " AND " + condition
  170. args = append(args, conditionArgs...)
  171. return query, args
  172. }
  173. if stream != nil {
  174. query += fmt.Sprintf(" AND stream = $%d", len(args)+1)
  175. args = append(args, *stream)
  176. }
  177. return query, args
  178. }
  179. func appendNativeCompactionV2WhereCondition(conditions []string, args []any, nativeCompactionV2 *bool, alias string) ([]string, []any) {
  180. if nativeCompactionV2 == nil {
  181. return conditions, args
  182. }
  183. column := "native_compaction_v2"
  184. if alias != "" {
  185. column = alias + "." + column
  186. }
  187. conditions = append(conditions, fmt.Sprintf("%s = $%d", column, len(args)+1))
  188. args = append(args, *nativeCompactionV2)
  189. return conditions, args
  190. }
  191. func appendNativeCompactionV2QueryFilter(query string, args []any, nativeCompactionV2 *bool, alias string) (string, []any) {
  192. conditions, args := appendNativeCompactionV2WhereCondition(nil, args, nativeCompactionV2, alias)
  193. if len(conditions) == 0 {
  194. return query, args
  195. }
  196. return query + " AND " + conditions[0], args
  197. }
  198. // buildRequestTypeFilterCondition 在 request_type 过滤时兼容 legacy 字段,避免历史数据漏查。
  199. func buildRequestTypeFilterCondition(startArgIndex int, requestType int16) (string, []any) {
  200. return buildRequestTypeFilterConditionWithAlias(startArgIndex, requestType, "")
  201. }
  202. func buildRequestTypeFilterConditionWithAlias(startArgIndex int, requestType int16, alias string) (string, []any) {
  203. normalized := service.RequestTypeFromInt16(requestType)
  204. requestTypeArg := int16(normalized)
  205. prefix := ""
  206. if alias != "" {
  207. prefix = alias + "."
  208. }
  209. switch normalized {
  210. case service.RequestTypeSync:
  211. return fmt.Sprintf("(%srequest_type = $%d OR (%srequest_type = %d AND %sstream = FALSE AND %sopenai_ws_mode = FALSE))", prefix, startArgIndex, prefix, int16(service.RequestTypeUnknown), prefix, prefix), []any{requestTypeArg}
  212. case service.RequestTypeStream:
  213. return fmt.Sprintf("(%srequest_type = $%d OR (%srequest_type = %d AND %sstream = TRUE AND %sopenai_ws_mode = FALSE))", prefix, startArgIndex, prefix, int16(service.RequestTypeUnknown), prefix, prefix), []any{requestTypeArg}
  214. case service.RequestTypeWSV2:
  215. return fmt.Sprintf("(%srequest_type = $%d OR (%srequest_type = %d AND %sopenai_ws_mode = TRUE))", prefix, startArgIndex, prefix, int16(service.RequestTypeUnknown), prefix), []any{requestTypeArg}
  216. default:
  217. return fmt.Sprintf("%srequest_type = $%d", prefix, startArgIndex), []any{requestTypeArg}
  218. }
  219. }