ops_repo_request_details.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. )
  10. func (r *opsRepository) ListRequestDetails(ctx context.Context, filter *service.OpsRequestDetailFilter) ([]*service.OpsRequestDetail, int64, error) {
  11. if r == nil || r.db == nil {
  12. return nil, 0, fmt.Errorf("nil ops repository")
  13. }
  14. page, pageSize, startTime, endTime := filter.Normalize()
  15. offset := (page - 1) * pageSize
  16. conditions := make([]string, 0, 16)
  17. args := make([]any, 0, 24)
  18. // Placeholders $1/$2 reserved for time window inside the CTE.
  19. args = append(args, startTime.UTC(), endTime.UTC())
  20. addCondition := func(condition string, values ...any) {
  21. conditions = append(conditions, condition)
  22. args = append(args, values...)
  23. }
  24. if filter != nil {
  25. if kind := strings.TrimSpace(strings.ToLower(filter.Kind)); kind != "" && kind != "all" {
  26. if kind != string(service.OpsRequestKindSuccess) && kind != string(service.OpsRequestKindError) {
  27. return nil, 0, fmt.Errorf("invalid kind")
  28. }
  29. addCondition(fmt.Sprintf("kind = $%d", len(args)+1), kind)
  30. }
  31. if platform := strings.TrimSpace(strings.ToLower(filter.Platform)); platform != "" {
  32. addCondition(fmt.Sprintf("platform = $%d", len(args)+1), platform)
  33. }
  34. if filter.GroupID != nil && *filter.GroupID > 0 {
  35. addCondition(fmt.Sprintf("group_id = $%d", len(args)+1), *filter.GroupID)
  36. }
  37. if filter.UserID != nil && *filter.UserID > 0 {
  38. addCondition(fmt.Sprintf("user_id = $%d", len(args)+1), *filter.UserID)
  39. }
  40. if filter.APIKeyID != nil && *filter.APIKeyID > 0 {
  41. addCondition(fmt.Sprintf("api_key_id = $%d", len(args)+1), *filter.APIKeyID)
  42. }
  43. if filter.AccountID != nil && *filter.AccountID > 0 {
  44. addCondition(fmt.Sprintf("account_id = $%d", len(args)+1), *filter.AccountID)
  45. }
  46. if model := strings.TrimSpace(filter.Model); model != "" {
  47. addCondition(fmt.Sprintf("model = $%d", len(args)+1), model)
  48. }
  49. if requestID := strings.TrimSpace(filter.RequestID); requestID != "" {
  50. addCondition(fmt.Sprintf("request_id = $%d", len(args)+1), requestID)
  51. }
  52. if q := strings.TrimSpace(filter.Query); q != "" {
  53. like := "%" + strings.ToLower(q) + "%"
  54. startIdx := len(args) + 1
  55. addCondition(
  56. fmt.Sprintf("(LOWER(COALESCE(request_id,'')) LIKE $%d OR LOWER(COALESCE(model,'')) LIKE $%d OR LOWER(COALESCE(message,'')) LIKE $%d)",
  57. startIdx, startIdx+1, startIdx+2,
  58. ),
  59. like, like, like,
  60. )
  61. }
  62. if filter.MinDurationMs != nil {
  63. addCondition(fmt.Sprintf("duration_ms >= $%d", len(args)+1), *filter.MinDurationMs)
  64. }
  65. if filter.MaxDurationMs != nil {
  66. addCondition(fmt.Sprintf("duration_ms <= $%d", len(args)+1), *filter.MaxDurationMs)
  67. }
  68. }
  69. where := ""
  70. if len(conditions) > 0 {
  71. where = "WHERE " + strings.Join(conditions, " AND ")
  72. }
  73. cte := `
  74. WITH combined AS (
  75. SELECT
  76. 'success'::TEXT AS kind,
  77. ul.created_at AS created_at,
  78. ul.request_id AS request_id,
  79. COALESCE(NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
  80. ul.model AS model,
  81. ul.duration_ms AS duration_ms,
  82. NULL::INT AS status_code,
  83. NULL::BIGINT AS error_id,
  84. NULL::TEXT AS phase,
  85. NULL::TEXT AS severity,
  86. NULL::TEXT AS message,
  87. ul.user_id AS user_id,
  88. ul.api_key_id AS api_key_id,
  89. ul.account_id AS account_id,
  90. ul.group_id AS group_id,
  91. ul.stream AS stream
  92. FROM usage_logs ul
  93. LEFT JOIN groups g ON g.id = ul.group_id
  94. LEFT JOIN accounts a ON a.id = ul.account_id
  95. WHERE ul.created_at >= $1 AND ul.created_at < $2
  96. UNION ALL
  97. SELECT
  98. 'error'::TEXT AS kind,
  99. o.created_at AS created_at,
  100. COALESCE(NULLIF(o.request_id,''), NULLIF(o.client_request_id,''), '') AS request_id,
  101. COALESCE(NULLIF(o.platform, ''), NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
  102. o.model AS model,
  103. o.duration_ms AS duration_ms,
  104. o.status_code AS status_code,
  105. o.id AS error_id,
  106. o.error_phase AS phase,
  107. o.severity AS severity,
  108. o.error_message AS message,
  109. o.user_id AS user_id,
  110. o.api_key_id AS api_key_id,
  111. o.account_id AS account_id,
  112. o.group_id AS group_id,
  113. o.stream AS stream
  114. FROM ops_error_logs o
  115. LEFT JOIN groups g ON g.id = o.group_id
  116. LEFT JOIN accounts a ON a.id = o.account_id
  117. WHERE o.created_at >= $1 AND o.created_at < $2
  118. AND COALESCE(o.status_code, 0) >= 400
  119. )
  120. `
  121. countQuery := fmt.Sprintf(`%s SELECT COUNT(1) FROM combined %s`, cte, where)
  122. var total int64
  123. if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
  124. if err == sql.ErrNoRows {
  125. total = 0
  126. } else {
  127. return nil, 0, err
  128. }
  129. }
  130. sort := "ORDER BY created_at DESC"
  131. if filter != nil {
  132. switch strings.TrimSpace(strings.ToLower(filter.Sort)) {
  133. case "", "created_at_desc":
  134. // default
  135. case "duration_desc":
  136. sort = "ORDER BY duration_ms DESC NULLS LAST, created_at DESC"
  137. default:
  138. return nil, 0, fmt.Errorf("invalid sort")
  139. }
  140. }
  141. listQuery := fmt.Sprintf(`
  142. %s
  143. SELECT
  144. kind,
  145. created_at,
  146. request_id,
  147. platform,
  148. model,
  149. duration_ms,
  150. status_code,
  151. error_id,
  152. phase,
  153. severity,
  154. message,
  155. user_id,
  156. api_key_id,
  157. account_id,
  158. group_id,
  159. stream
  160. FROM combined
  161. %s
  162. %s
  163. LIMIT $%d OFFSET $%d
  164. `, cte, where, sort, len(args)+1, len(args)+2)
  165. listArgs := append(append([]any{}, args...), pageSize, offset)
  166. rows, err := r.db.QueryContext(ctx, listQuery, listArgs...)
  167. if err != nil {
  168. return nil, 0, err
  169. }
  170. defer func() { _ = rows.Close() }()
  171. toIntPtr := func(v sql.NullInt64) *int {
  172. if !v.Valid {
  173. return nil
  174. }
  175. i := int(v.Int64)
  176. return &i
  177. }
  178. toInt64Ptr := func(v sql.NullInt64) *int64 {
  179. if !v.Valid {
  180. return nil
  181. }
  182. i := v.Int64
  183. return &i
  184. }
  185. out := make([]*service.OpsRequestDetail, 0, pageSize)
  186. for rows.Next() {
  187. var (
  188. kind string
  189. createdAt time.Time
  190. requestID sql.NullString
  191. platform sql.NullString
  192. model sql.NullString
  193. durationMs sql.NullInt64
  194. statusCode sql.NullInt64
  195. errorID sql.NullInt64
  196. phase sql.NullString
  197. severity sql.NullString
  198. message sql.NullString
  199. userID sql.NullInt64
  200. apiKeyID sql.NullInt64
  201. accountID sql.NullInt64
  202. groupID sql.NullInt64
  203. stream bool
  204. )
  205. if err := rows.Scan(
  206. &kind,
  207. &createdAt,
  208. &requestID,
  209. &platform,
  210. &model,
  211. &durationMs,
  212. &statusCode,
  213. &errorID,
  214. &phase,
  215. &severity,
  216. &message,
  217. &userID,
  218. &apiKeyID,
  219. &accountID,
  220. &groupID,
  221. &stream,
  222. ); err != nil {
  223. return nil, 0, err
  224. }
  225. item := &service.OpsRequestDetail{
  226. Kind: service.OpsRequestKind(kind),
  227. CreatedAt: createdAt,
  228. RequestID: strings.TrimSpace(requestID.String),
  229. Platform: strings.TrimSpace(platform.String),
  230. Model: strings.TrimSpace(model.String),
  231. DurationMs: toIntPtr(durationMs),
  232. StatusCode: toIntPtr(statusCode),
  233. ErrorID: toInt64Ptr(errorID),
  234. Phase: phase.String,
  235. Severity: severity.String,
  236. Message: message.String,
  237. UserID: toInt64Ptr(userID),
  238. APIKeyID: toInt64Ptr(apiKeyID),
  239. AccountID: toInt64Ptr(accountID),
  240. GroupID: toInt64Ptr(groupID),
  241. Stream: stream,
  242. }
  243. if item.Platform == "" {
  244. item.Platform = "unknown"
  245. }
  246. out = append(out, item)
  247. }
  248. if err := rows.Err(); err != nil {
  249. return nil, 0, err
  250. }
  251. return out, total, nil
  252. }