ops_repo.go 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/lib/pq"
  11. )
  12. type opsRepository struct {
  13. db *sql.DB
  14. }
  15. const insertOpsErrorLogSQL = `
  16. INSERT INTO ops_error_logs (
  17. request_id,
  18. client_request_id,
  19. user_id,
  20. api_key_id,
  21. account_id,
  22. group_id,
  23. client_ip,
  24. platform,
  25. model,
  26. request_path,
  27. stream,
  28. inbound_endpoint,
  29. upstream_endpoint,
  30. requested_model,
  31. upstream_model,
  32. request_type,
  33. user_agent,
  34. error_phase,
  35. error_type,
  36. severity,
  37. status_code,
  38. is_business_limited,
  39. is_count_tokens,
  40. error_message,
  41. error_body,
  42. error_source,
  43. error_owner,
  44. upstream_status_code,
  45. upstream_error_message,
  46. upstream_error_detail,
  47. upstream_errors,
  48. auth_latency_ms,
  49. routing_latency_ms,
  50. upstream_latency_ms,
  51. response_latency_ms,
  52. time_to_first_token_ms,
  53. created_at,
  54. api_key_prefix
  55. ) VALUES (
  56. $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38
  57. )`
  58. func NewOpsRepository(db *sql.DB) service.OpsRepository {
  59. return &opsRepository{db: db}
  60. }
  61. func (r *opsRepository) InsertErrorLog(ctx context.Context, input *service.OpsInsertErrorLogInput) (int64, error) {
  62. if r == nil || r.db == nil {
  63. return 0, fmt.Errorf("nil ops repository")
  64. }
  65. if input == nil {
  66. return 0, fmt.Errorf("nil input")
  67. }
  68. var id int64
  69. err := r.db.QueryRowContext(
  70. ctx,
  71. insertOpsErrorLogSQL+" RETURNING id",
  72. opsInsertErrorLogArgs(input)...,
  73. ).Scan(&id)
  74. if err != nil {
  75. return 0, err
  76. }
  77. return id, nil
  78. }
  79. func (r *opsRepository) BatchInsertErrorLogs(ctx context.Context, inputs []*service.OpsInsertErrorLogInput) (int64, error) {
  80. if r == nil || r.db == nil {
  81. return 0, fmt.Errorf("nil ops repository")
  82. }
  83. if len(inputs) == 0 {
  84. return 0, nil
  85. }
  86. tx, err := r.db.BeginTx(ctx, nil)
  87. if err != nil {
  88. return 0, err
  89. }
  90. defer func() {
  91. if err != nil {
  92. _ = tx.Rollback()
  93. }
  94. }()
  95. stmt, err := tx.PrepareContext(ctx, insertOpsErrorLogSQL)
  96. if err != nil {
  97. return 0, err
  98. }
  99. defer func() {
  100. _ = stmt.Close()
  101. }()
  102. var inserted int64
  103. for _, input := range inputs {
  104. if input == nil {
  105. continue
  106. }
  107. if _, err = stmt.ExecContext(ctx, opsInsertErrorLogArgs(input)...); err != nil {
  108. return inserted, err
  109. }
  110. inserted++
  111. }
  112. if err = tx.Commit(); err != nil {
  113. return inserted, err
  114. }
  115. return inserted, nil
  116. }
  117. func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any {
  118. return []any{
  119. opsNullString(input.RequestID),
  120. opsNullString(input.ClientRequestID),
  121. opsNullInt64(input.UserID),
  122. opsNullInt64(input.APIKeyID),
  123. opsNullInt64(input.AccountID),
  124. opsNullInt64(input.GroupID),
  125. opsNullString(input.ClientIP),
  126. opsNullString(input.Platform),
  127. opsNullString(input.Model),
  128. opsNullString(input.RequestPath),
  129. input.Stream,
  130. opsNullString(input.InboundEndpoint),
  131. opsNullString(input.UpstreamEndpoint),
  132. opsNullString(input.RequestedModel),
  133. opsNullString(input.UpstreamModel),
  134. opsNullInt16(input.RequestType),
  135. opsNullString(input.UserAgent),
  136. input.ErrorPhase,
  137. input.ErrorType,
  138. opsNullString(input.Severity),
  139. opsNullInt(input.StatusCode),
  140. input.IsBusinessLimited,
  141. input.IsCountTokens,
  142. opsNullString(input.ErrorMessage),
  143. opsNullString(input.ErrorBody),
  144. opsNullString(input.ErrorSource),
  145. opsNullString(input.ErrorOwner),
  146. opsNullableIntPointer(input.UpstreamStatusCode),
  147. opsNullString(input.UpstreamErrorMessage),
  148. opsNullString(input.UpstreamErrorDetail),
  149. opsNullString(input.UpstreamErrorsJSON),
  150. opsNullInt64(input.AuthLatencyMs),
  151. opsNullInt64(input.RoutingLatencyMs),
  152. opsNullInt64(input.UpstreamLatencyMs),
  153. opsNullInt64(input.ResponseLatencyMs),
  154. opsNullInt64(input.TimeToFirstTokenMs),
  155. input.CreatedAt,
  156. opsNullString(input.APIKeyPrefix),
  157. }
  158. }
  159. // opsErrorLogsOrderBy builds the ORDER BY clause from a whitelist, mirroring
  160. // usageLogOrderBy semantics. Unknown SortBy falls back to created_at; e.id is
  161. // always appended as tiebreaker for stable pagination.
  162. func opsErrorLogsOrderBy(filter *service.OpsErrorLogFilter) string {
  163. sortBy := ""
  164. sortOrder := ""
  165. if filter != nil {
  166. sortBy = strings.ToLower(strings.TrimSpace(filter.SortBy))
  167. sortOrder = strings.ToLower(strings.TrimSpace(filter.SortOrder))
  168. }
  169. var column string
  170. switch sortBy {
  171. case "model":
  172. column = "COALESCE(NULLIF(TRIM(e.requested_model), ''), e.model)"
  173. case "status_code":
  174. // 与展示列/过滤保持同义:列表展示 COALESCE(upstream_status_code, status_code, 0),
  175. // status_code 过滤也用同一表达式,故排序必须一致——否则 recovered upstream 行
  176. //(status_code<400 但展示上游 5xx)排序键与显示值/分页切分不符。
  177. column = "COALESCE(e.upstream_status_code, e.status_code, 0)"
  178. default:
  179. column = "e.created_at"
  180. }
  181. dir := "DESC"
  182. if sortOrder == "asc" {
  183. dir = "ASC"
  184. }
  185. return fmt.Sprintf("%s %s, e.id %s", column, dir, dir)
  186. }
  187. func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) {
  188. if r == nil || r.db == nil {
  189. return nil, fmt.Errorf("nil ops repository")
  190. }
  191. if filter == nil {
  192. filter = &service.OpsErrorLogFilter{}
  193. }
  194. page := filter.Page
  195. if page <= 0 {
  196. page = 1
  197. }
  198. pageSize := filter.PageSize
  199. if pageSize <= 0 {
  200. pageSize = 20
  201. }
  202. if pageSize > 500 {
  203. pageSize = 500
  204. }
  205. where, args := buildOpsErrorLogsWhere(filter)
  206. countSQL := "SELECT COUNT(*) FROM ops_error_logs e " + where
  207. var total int
  208. if err := r.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
  209. return nil, err
  210. }
  211. offset := (page - 1) * pageSize
  212. argsWithLimit := append(args, pageSize, offset)
  213. selectSQL := `
  214. SELECT
  215. e.id,
  216. e.created_at,
  217. e.error_phase,
  218. e.error_type,
  219. COALESCE(e.error_owner, ''),
  220. COALESCE(e.error_source, ''),
  221. e.severity,
  222. COALESCE(e.upstream_status_code, e.status_code, 0),
  223. COALESCE(e.platform, ''),
  224. COALESCE(e.model, ''),
  225. COALESCE(e.resolved, false),
  226. e.resolved_at,
  227. e.resolved_by_user_id,
  228. COALESCE(u2.email, ''),
  229. COALESCE(e.client_request_id, ''),
  230. COALESCE(e.request_id, ''),
  231. COALESCE(e.error_message, ''),
  232. e.user_id,
  233. COALESCE(u.email, ''),
  234. e.api_key_id,
  235. e.account_id,
  236. COALESCE(a.name, ''),
  237. e.group_id,
  238. COALESCE(g.name, ''),
  239. CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
  240. COALESCE(e.request_path, ''),
  241. e.stream,
  242. COALESCE(e.inbound_endpoint, ''),
  243. COALESCE(e.upstream_endpoint, ''),
  244. COALESCE(e.requested_model, ''),
  245. COALESCE(e.upstream_model, ''),
  246. COALESCE(e.user_agent, ''),
  247. e.request_type,
  248. COALESCE(ak.name, ''),
  249. ak.deleted_at
  250. FROM ops_error_logs e
  251. LEFT JOIN accounts a ON e.account_id = a.id
  252. LEFT JOIN groups g ON e.group_id = g.id
  253. LEFT JOIN users u ON e.user_id = u.id
  254. LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id
  255. LEFT JOIN api_keys ak ON ak.id = e.api_key_id
  256. ` + where + `
  257. ORDER BY ` + opsErrorLogsOrderBy(filter) + `
  258. LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
  259. rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...)
  260. if err != nil {
  261. return nil, err
  262. }
  263. defer func() { _ = rows.Close() }()
  264. out := make([]*service.OpsErrorLog, 0, pageSize)
  265. for rows.Next() {
  266. var item service.OpsErrorLog
  267. var statusCode sql.NullInt64
  268. var clientIP sql.NullString
  269. var userID sql.NullInt64
  270. var apiKeyID sql.NullInt64
  271. var accountID sql.NullInt64
  272. var accountName string
  273. var groupID sql.NullInt64
  274. var groupName string
  275. var userEmail string
  276. var resolvedAt sql.NullTime
  277. var resolvedBy sql.NullInt64
  278. var resolvedByName string
  279. var requestType sql.NullInt64
  280. var apiKeyName string
  281. var apiKeyDeletedAt sql.NullTime
  282. if err := rows.Scan(
  283. &item.ID,
  284. &item.CreatedAt,
  285. &item.Phase,
  286. &item.Type,
  287. &item.Owner,
  288. &item.Source,
  289. &item.Severity,
  290. &statusCode,
  291. &item.Platform,
  292. &item.Model,
  293. &item.Resolved,
  294. &resolvedAt,
  295. &resolvedBy,
  296. &resolvedByName,
  297. &item.ClientRequestID,
  298. &item.RequestID,
  299. &item.Message,
  300. &userID,
  301. &userEmail,
  302. &apiKeyID,
  303. &accountID,
  304. &accountName,
  305. &groupID,
  306. &groupName,
  307. &clientIP,
  308. &item.RequestPath,
  309. &item.Stream,
  310. &item.InboundEndpoint,
  311. &item.UpstreamEndpoint,
  312. &item.RequestedModel,
  313. &item.UpstreamModel,
  314. &item.UserAgent,
  315. &requestType,
  316. &apiKeyName,
  317. &apiKeyDeletedAt,
  318. ); err != nil {
  319. return nil, err
  320. }
  321. if resolvedAt.Valid {
  322. t := resolvedAt.Time
  323. item.ResolvedAt = &t
  324. }
  325. if resolvedBy.Valid {
  326. v := resolvedBy.Int64
  327. item.ResolvedByUserID = &v
  328. }
  329. item.ResolvedByUserName = resolvedByName
  330. item.StatusCode = int(statusCode.Int64)
  331. if clientIP.Valid {
  332. s := clientIP.String
  333. item.ClientIP = &s
  334. }
  335. if userID.Valid {
  336. v := userID.Int64
  337. item.UserID = &v
  338. }
  339. item.UserEmail = userEmail
  340. if apiKeyID.Valid {
  341. v := apiKeyID.Int64
  342. item.APIKeyID = &v
  343. }
  344. if accountID.Valid {
  345. v := accountID.Int64
  346. item.AccountID = &v
  347. }
  348. item.AccountName = accountName
  349. if groupID.Valid {
  350. v := groupID.Int64
  351. item.GroupID = &v
  352. }
  353. item.GroupName = groupName
  354. if requestType.Valid {
  355. v := int16(requestType.Int64)
  356. item.RequestType = &v
  357. }
  358. item.APIKeyName = apiKeyName
  359. item.APIKeyDeleted = apiKeyDeletedAt.Valid
  360. out = append(out, &item)
  361. }
  362. if err := rows.Err(); err != nil {
  363. return nil, err
  364. }
  365. return &service.OpsErrorLogList{
  366. Errors: out,
  367. Total: total,
  368. Page: page,
  369. PageSize: pageSize,
  370. }, nil
  371. }
  372. func (r *opsRepository) GetErrorLogByID(ctx context.Context, id int64) (*service.OpsErrorLogDetail, error) {
  373. if r == nil || r.db == nil {
  374. return nil, fmt.Errorf("nil ops repository")
  375. }
  376. if id <= 0 {
  377. return nil, fmt.Errorf("invalid id")
  378. }
  379. q := `
  380. SELECT
  381. e.id,
  382. e.created_at,
  383. e.error_phase,
  384. e.error_type,
  385. COALESCE(e.error_owner, ''),
  386. COALESCE(e.error_source, ''),
  387. e.severity,
  388. COALESCE(e.upstream_status_code, e.status_code, 0),
  389. COALESCE(e.platform, ''),
  390. COALESCE(e.model, ''),
  391. COALESCE(e.resolved, false),
  392. e.resolved_at,
  393. e.resolved_by_user_id,
  394. COALESCE(e.client_request_id, ''),
  395. COALESCE(e.request_id, ''),
  396. COALESCE(e.error_message, ''),
  397. COALESCE(e.error_body, ''),
  398. e.upstream_status_code,
  399. COALESCE(e.upstream_error_message, ''),
  400. COALESCE(e.upstream_error_detail, ''),
  401. COALESCE(e.upstream_errors::text, ''),
  402. e.is_business_limited,
  403. e.user_id,
  404. COALESCE(u.email, ''),
  405. e.api_key_id,
  406. e.account_id,
  407. COALESCE(a.name, ''),
  408. e.group_id,
  409. COALESCE(g.name, ''),
  410. CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
  411. COALESCE(e.request_path, ''),
  412. e.stream,
  413. COALESCE(e.inbound_endpoint, ''),
  414. COALESCE(e.upstream_endpoint, ''),
  415. COALESCE(e.requested_model, ''),
  416. COALESCE(e.upstream_model, ''),
  417. e.request_type,
  418. COALESCE(e.user_agent, ''),
  419. e.auth_latency_ms,
  420. e.routing_latency_ms,
  421. e.upstream_latency_ms,
  422. e.response_latency_ms,
  423. e.time_to_first_token_ms,
  424. COALESCE(e.api_key_prefix, ''),
  425. COALESCE(ak.name, ''),
  426. ak.deleted_at
  427. FROM ops_error_logs e
  428. LEFT JOIN users u ON e.user_id = u.id
  429. LEFT JOIN accounts a ON e.account_id = a.id
  430. LEFT JOIN groups g ON e.group_id = g.id
  431. LEFT JOIN api_keys ak ON ak.id = e.api_key_id
  432. WHERE e.id = $1
  433. LIMIT 1`
  434. var out service.OpsErrorLogDetail
  435. var statusCode sql.NullInt64
  436. var upstreamStatusCode sql.NullInt64
  437. var resolvedAt sql.NullTime
  438. var resolvedBy sql.NullInt64
  439. var clientIP sql.NullString
  440. var userID sql.NullInt64
  441. var apiKeyID sql.NullInt64
  442. var accountID sql.NullInt64
  443. var groupID sql.NullInt64
  444. var authLatency sql.NullInt64
  445. var routingLatency sql.NullInt64
  446. var upstreamLatency sql.NullInt64
  447. var responseLatency sql.NullInt64
  448. var ttft sql.NullInt64
  449. var requestType sql.NullInt64
  450. var detailAPIKeyName string
  451. var detailAPIKeyDeletedAt sql.NullTime
  452. err := r.db.QueryRowContext(ctx, q, id).Scan(
  453. &out.ID,
  454. &out.CreatedAt,
  455. &out.Phase,
  456. &out.Type,
  457. &out.Owner,
  458. &out.Source,
  459. &out.Severity,
  460. &statusCode,
  461. &out.Platform,
  462. &out.Model,
  463. &out.Resolved,
  464. &resolvedAt,
  465. &resolvedBy,
  466. &out.ClientRequestID,
  467. &out.RequestID,
  468. &out.Message,
  469. &out.ErrorBody,
  470. &upstreamStatusCode,
  471. &out.UpstreamErrorMessage,
  472. &out.UpstreamErrorDetail,
  473. &out.UpstreamErrors,
  474. &out.IsBusinessLimited,
  475. &userID,
  476. &out.UserEmail,
  477. &apiKeyID,
  478. &accountID,
  479. &out.AccountName,
  480. &groupID,
  481. &out.GroupName,
  482. &clientIP,
  483. &out.RequestPath,
  484. &out.Stream,
  485. &out.InboundEndpoint,
  486. &out.UpstreamEndpoint,
  487. &out.RequestedModel,
  488. &out.UpstreamModel,
  489. &requestType,
  490. &out.UserAgent,
  491. &authLatency,
  492. &routingLatency,
  493. &upstreamLatency,
  494. &responseLatency,
  495. &ttft,
  496. &out.APIKeyPrefix,
  497. &detailAPIKeyName,
  498. &detailAPIKeyDeletedAt,
  499. )
  500. if err != nil {
  501. return nil, err
  502. }
  503. out.StatusCode = int(statusCode.Int64)
  504. if resolvedAt.Valid {
  505. t := resolvedAt.Time
  506. out.ResolvedAt = &t
  507. }
  508. if resolvedBy.Valid {
  509. v := resolvedBy.Int64
  510. out.ResolvedByUserID = &v
  511. }
  512. if clientIP.Valid {
  513. s := clientIP.String
  514. out.ClientIP = &s
  515. }
  516. if upstreamStatusCode.Valid {
  517. v := int(upstreamStatusCode.Int64)
  518. out.UpstreamStatusCode = &v
  519. }
  520. if userID.Valid {
  521. v := userID.Int64
  522. out.UserID = &v
  523. }
  524. if apiKeyID.Valid {
  525. v := apiKeyID.Int64
  526. out.APIKeyID = &v
  527. }
  528. if accountID.Valid {
  529. v := accountID.Int64
  530. out.AccountID = &v
  531. }
  532. if groupID.Valid {
  533. v := groupID.Int64
  534. out.GroupID = &v
  535. }
  536. if authLatency.Valid {
  537. v := authLatency.Int64
  538. out.AuthLatencyMs = &v
  539. }
  540. if routingLatency.Valid {
  541. v := routingLatency.Int64
  542. out.RoutingLatencyMs = &v
  543. }
  544. if upstreamLatency.Valid {
  545. v := upstreamLatency.Int64
  546. out.UpstreamLatencyMs = &v
  547. }
  548. if responseLatency.Valid {
  549. v := responseLatency.Int64
  550. out.ResponseLatencyMs = &v
  551. }
  552. if ttft.Valid {
  553. v := ttft.Int64
  554. out.TimeToFirstTokenMs = &v
  555. }
  556. if requestType.Valid {
  557. v := int16(requestType.Int64)
  558. out.RequestType = &v
  559. }
  560. out.APIKeyName = detailAPIKeyName
  561. out.APIKeyDeleted = detailAPIKeyDeletedAt.Valid
  562. // Normalize upstream_errors to empty string when stored as JSON null.
  563. out.UpstreamErrors = strings.TrimSpace(out.UpstreamErrors)
  564. if out.UpstreamErrors == "null" {
  565. out.UpstreamErrors = ""
  566. }
  567. return &out, nil
  568. }
  569. func (r *opsRepository) UpdateErrorResolution(ctx context.Context, errorID int64, resolved bool, resolvedByUserID *int64, resolvedAt *time.Time) error {
  570. if r == nil || r.db == nil {
  571. return fmt.Errorf("nil ops repository")
  572. }
  573. if errorID <= 0 {
  574. return fmt.Errorf("invalid error id")
  575. }
  576. q := `
  577. UPDATE ops_error_logs
  578. SET
  579. resolved = $2,
  580. resolved_at = $3,
  581. resolved_by_user_id = $4
  582. WHERE id = $1`
  583. at := sql.NullTime{}
  584. if resolvedAt != nil && !resolvedAt.IsZero() {
  585. at = sql.NullTime{Time: resolvedAt.UTC(), Valid: true}
  586. } else if resolved {
  587. now := time.Now().UTC()
  588. at = sql.NullTime{Time: now, Valid: true}
  589. }
  590. _, err := r.db.ExecContext(
  591. ctx,
  592. q,
  593. errorID,
  594. resolved,
  595. at,
  596. nullInt64(resolvedByUserID),
  597. )
  598. return err
  599. }
  600. func (r *opsRepository) BatchInsertSystemLogs(ctx context.Context, inputs []*service.OpsInsertSystemLogInput) (int64, error) {
  601. if r == nil || r.db == nil {
  602. return 0, fmt.Errorf("nil ops repository")
  603. }
  604. if len(inputs) == 0 {
  605. return 0, nil
  606. }
  607. tx, err := r.db.BeginTx(ctx, nil)
  608. if err != nil {
  609. return 0, err
  610. }
  611. stmt, err := tx.PrepareContext(ctx, pq.CopyIn(
  612. "ops_system_logs",
  613. "created_at",
  614. "host",
  615. "level",
  616. "component",
  617. "message",
  618. "request_id",
  619. "client_request_id",
  620. "user_id",
  621. "api_key_id",
  622. "account_id",
  623. "platform",
  624. "model",
  625. "extra",
  626. ))
  627. if err != nil {
  628. _ = tx.Rollback()
  629. return 0, err
  630. }
  631. var inserted int64
  632. for _, input := range inputs {
  633. if input == nil {
  634. continue
  635. }
  636. createdAt := input.CreatedAt
  637. if createdAt.IsZero() {
  638. createdAt = time.Now().UTC()
  639. }
  640. component := strings.TrimSpace(input.Component)
  641. level := strings.ToLower(strings.TrimSpace(input.Level))
  642. message := strings.TrimSpace(input.Message)
  643. if level == "" || message == "" {
  644. continue
  645. }
  646. if component == "" {
  647. component = "app"
  648. }
  649. extra := strings.TrimSpace(input.ExtraJSON)
  650. if extra == "" {
  651. extra = "{}"
  652. }
  653. if _, err := stmt.ExecContext(
  654. ctx,
  655. createdAt.UTC(),
  656. opsNullString(input.Host),
  657. level,
  658. component,
  659. message,
  660. opsNullString(input.RequestID),
  661. opsNullString(input.ClientRequestID),
  662. opsNullInt64(input.UserID),
  663. opsNullInt64(input.APIKeyID),
  664. opsNullInt64(input.AccountID),
  665. opsNullString(input.Platform),
  666. opsNullString(input.Model),
  667. extra,
  668. ); err != nil {
  669. _ = stmt.Close()
  670. _ = tx.Rollback()
  671. return inserted, err
  672. }
  673. inserted++
  674. }
  675. if _, err := stmt.ExecContext(ctx); err != nil {
  676. _ = stmt.Close()
  677. _ = tx.Rollback()
  678. return inserted, err
  679. }
  680. if err := stmt.Close(); err != nil {
  681. _ = tx.Rollback()
  682. return inserted, err
  683. }
  684. if err := tx.Commit(); err != nil {
  685. return inserted, err
  686. }
  687. return inserted, nil
  688. }
  689. func (r *opsRepository) ListSystemLogs(ctx context.Context, filter *service.OpsSystemLogFilter) (*service.OpsSystemLogList, error) {
  690. if r == nil || r.db == nil {
  691. return nil, fmt.Errorf("nil ops repository")
  692. }
  693. if filter == nil {
  694. filter = &service.OpsSystemLogFilter{}
  695. }
  696. page := filter.Page
  697. if page <= 0 {
  698. page = 1
  699. }
  700. pageSize := filter.PageSize
  701. if pageSize <= 0 {
  702. pageSize = 50
  703. }
  704. if pageSize > 200 {
  705. pageSize = 200
  706. }
  707. where, args, _ := buildOpsSystemLogsWhere(filter)
  708. countSQL := "SELECT COUNT(*) FROM ops_system_logs l " + where
  709. var total int
  710. if err := r.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
  711. return nil, err
  712. }
  713. offset := (page - 1) * pageSize
  714. argsWithLimit := append(args, pageSize, offset)
  715. query := `
  716. SELECT
  717. l.id,
  718. l.created_at,
  719. COALESCE(l.host, ''),
  720. l.level,
  721. COALESCE(l.component, ''),
  722. COALESCE(l.message, ''),
  723. COALESCE(l.request_id, ''),
  724. COALESCE(l.client_request_id, ''),
  725. l.user_id,
  726. l.api_key_id,
  727. l.account_id,
  728. COALESCE(l.platform, ''),
  729. COALESCE(l.model, ''),
  730. COALESCE(l.extra::text, '{}')
  731. FROM ops_system_logs l
  732. ` + where + `
  733. ORDER BY l.created_at DESC, l.id DESC
  734. LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
  735. rows, err := r.db.QueryContext(ctx, query, argsWithLimit...)
  736. if err != nil {
  737. return nil, err
  738. }
  739. defer func() { _ = rows.Close() }()
  740. logs := make([]*service.OpsSystemLog, 0, pageSize)
  741. for rows.Next() {
  742. item := &service.OpsSystemLog{}
  743. var userID sql.NullInt64
  744. var apiKeyID sql.NullInt64
  745. var accountID sql.NullInt64
  746. var extraRaw string
  747. if err := rows.Scan(
  748. &item.ID,
  749. &item.CreatedAt,
  750. &item.Host,
  751. &item.Level,
  752. &item.Component,
  753. &item.Message,
  754. &item.RequestID,
  755. &item.ClientRequestID,
  756. &userID,
  757. &apiKeyID,
  758. &accountID,
  759. &item.Platform,
  760. &item.Model,
  761. &extraRaw,
  762. ); err != nil {
  763. return nil, err
  764. }
  765. if userID.Valid {
  766. v := userID.Int64
  767. item.UserID = &v
  768. }
  769. if apiKeyID.Valid {
  770. v := apiKeyID.Int64
  771. item.APIKeyID = &v
  772. }
  773. if accountID.Valid {
  774. v := accountID.Int64
  775. item.AccountID = &v
  776. }
  777. extraRaw = strings.TrimSpace(extraRaw)
  778. if extraRaw != "" && extraRaw != "null" && extraRaw != "{}" {
  779. extra := make(map[string]any)
  780. if err := json.Unmarshal([]byte(extraRaw), &extra); err == nil {
  781. item.Extra = extra
  782. }
  783. }
  784. logs = append(logs, item)
  785. }
  786. if err := rows.Err(); err != nil {
  787. return nil, err
  788. }
  789. return &service.OpsSystemLogList{
  790. Logs: logs,
  791. Total: total,
  792. Page: page,
  793. PageSize: pageSize,
  794. }, nil
  795. }
  796. func (r *opsRepository) DeleteSystemLogs(ctx context.Context, filter *service.OpsSystemLogCleanupFilter) (int64, error) {
  797. if r == nil || r.db == nil {
  798. return 0, fmt.Errorf("nil ops repository")
  799. }
  800. if filter == nil {
  801. filter = &service.OpsSystemLogCleanupFilter{}
  802. }
  803. where, args, hasConstraint := buildOpsSystemLogsCleanupWhere(filter)
  804. if !hasConstraint {
  805. return 0, fmt.Errorf("cleanup requires at least one filter condition")
  806. }
  807. query := "DELETE FROM ops_system_logs l " + where
  808. res, err := r.db.ExecContext(ctx, query, args...)
  809. if err != nil {
  810. return 0, err
  811. }
  812. return res.RowsAffected()
  813. }
  814. func (r *opsRepository) InsertSystemLogCleanupAudit(ctx context.Context, input *service.OpsSystemLogCleanupAudit) error {
  815. if r == nil || r.db == nil {
  816. return fmt.Errorf("nil ops repository")
  817. }
  818. if input == nil {
  819. return fmt.Errorf("nil input")
  820. }
  821. createdAt := input.CreatedAt
  822. if createdAt.IsZero() {
  823. createdAt = time.Now().UTC()
  824. }
  825. _, err := r.db.ExecContext(ctx, `
  826. INSERT INTO ops_system_log_cleanup_audits (
  827. created_at,
  828. operator_id,
  829. conditions,
  830. deleted_rows
  831. ) VALUES ($1,$2,$3,$4)
  832. `, createdAt.UTC(), input.OperatorID, input.Conditions, input.DeletedRows)
  833. return err
  834. }
  835. var likePatternReplacer = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
  836. // escapeLikePattern 转义 LIKE/ILIKE 通配符(\ % _),避免用户输入被当作通配符。
  837. // Postgres 默认以反斜杠为转义符,无需额外 ESCAPE 子句。
  838. func escapeLikePattern(s string) string {
  839. return likePatternReplacer.Replace(s)
  840. }
  841. func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
  842. clauses := make([]string, 0, 12)
  843. args := make([]any, 0, 12)
  844. clauses = append(clauses, "1=1")
  845. phaseFilter := ""
  846. if filter != nil {
  847. phaseFilter = strings.TrimSpace(strings.ToLower(filter.Phase))
  848. }
  849. // ops_error_logs stores client-visible error requests (status>=400),
  850. // but we also persist "recovered" upstream errors (status<400) for upstream health visibility.
  851. // If Resolved is not specified, do not filter by resolved state (backward-compatible).
  852. resolvedFilter := (*bool)(nil)
  853. if filter != nil {
  854. resolvedFilter = filter.Resolved
  855. }
  856. // Keep list endpoints scoped to client errors unless the caller explicitly opts
  857. // into recovered provider-health rows (upstream/account_auth). Request-error
  858. // endpoints never set the opt-in and retain this guard.
  859. // cyber_policy is exempt from the status >= 400 guard: streaming cyber hits arrive with
  860. // status 200 (the SSE stream opened successfully before upstream returned response.failed),
  861. // but they are always client-visible blocked requests that belong in admin + user error
  862. // lists. Without the exemption the entire streaming-path cyber sink would be invisible.
  863. if !opsFilterIncludesRecoveredProviderRows(filter, phaseFilter) {
  864. clauses = append(clauses, "(COALESCE(e.status_code, 0) >= 400 OR e.error_type = 'cyber_policy')")
  865. }
  866. if filter.StartTime != nil && !filter.StartTime.IsZero() {
  867. args = append(args, filter.StartTime.UTC())
  868. clauses = append(clauses, "e.created_at >= $"+itoa(len(args)))
  869. }
  870. if filter.EndTime != nil && !filter.EndTime.IsZero() {
  871. args = append(args, filter.EndTime.UTC())
  872. // Keep time-window semantics consistent with other ops queries: [start, end)
  873. clauses = append(clauses, "e.created_at < $"+itoa(len(args)))
  874. }
  875. if p := strings.TrimSpace(filter.Platform); p != "" {
  876. args = append(args, p)
  877. clauses = append(clauses, "e.platform = $"+itoa(len(args)))
  878. }
  879. if filter.GroupID != nil && *filter.GroupID > 0 {
  880. args = append(args, *filter.GroupID)
  881. clauses = append(clauses, "e.group_id = $"+itoa(len(args)))
  882. }
  883. if filter.AccountID != nil && *filter.AccountID > 0 {
  884. args = append(args, *filter.AccountID)
  885. clauses = append(clauses, "e.account_id = $"+itoa(len(args)))
  886. }
  887. if phase := phaseFilter; phase != "" {
  888. args = append(args, phase)
  889. clauses = append(clauses, "e.error_phase = $"+itoa(len(args)))
  890. }
  891. if filter != nil {
  892. if owner := strings.TrimSpace(strings.ToLower(filter.Owner)); owner != "" {
  893. args = append(args, owner)
  894. clauses = append(clauses, "LOWER(COALESCE(e.error_owner,'')) = $"+itoa(len(args)))
  895. }
  896. if source := strings.TrimSpace(strings.ToLower(filter.Source)); source != "" {
  897. args = append(args, source)
  898. clauses = append(clauses, "LOWER(COALESCE(e.error_source,'')) = $"+itoa(len(args)))
  899. }
  900. }
  901. if resolvedFilter != nil {
  902. args = append(args, *resolvedFilter)
  903. clauses = append(clauses, "COALESCE(e.resolved,false) = $"+itoa(len(args)))
  904. }
  905. // View filter: errors vs excluded vs all.
  906. // Excluded = business-limited errors (quota/concurrency/billing).
  907. // Upstream 429/529 are included in errors view to match SLA calculation.
  908. view := ""
  909. if filter != nil {
  910. view = strings.ToLower(strings.TrimSpace(filter.View))
  911. }
  912. switch view {
  913. case "", "errors":
  914. clauses = append(clauses, "COALESCE(e.is_business_limited,false) = false")
  915. case "excluded":
  916. clauses = append(clauses, "COALESCE(e.is_business_limited,false) = true")
  917. case "all":
  918. // no-op
  919. default:
  920. // treat unknown as default 'errors'
  921. clauses = append(clauses, "COALESCE(e.is_business_limited,false) = false")
  922. }
  923. if len(filter.StatusCodes) > 0 {
  924. args = append(args, pq.Array(filter.StatusCodes))
  925. clauses = append(clauses, "COALESCE(e.upstream_status_code, e.status_code, 0) = ANY($"+itoa(len(args))+")")
  926. } else if filter.StatusCodesOther {
  927. // "Other" means: status codes not in the common list.
  928. known := []int{400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529}
  929. args = append(args, pq.Array(known))
  930. clauses = append(clauses, "NOT (COALESCE(e.upstream_status_code, e.status_code, 0) = ANY($"+itoa(len(args))+"))")
  931. }
  932. // Exact correlation keys (preferred for request↔upstream linkage).
  933. if rid := strings.TrimSpace(filter.RequestID); rid != "" {
  934. args = append(args, rid)
  935. clauses = append(clauses, "COALESCE(e.request_id,'') = $"+itoa(len(args)))
  936. }
  937. if crid := strings.TrimSpace(filter.ClientRequestID); crid != "" {
  938. args = append(args, crid)
  939. clauses = append(clauses, "COALESCE(e.client_request_id,'') = $"+itoa(len(args)))
  940. }
  941. if q := strings.TrimSpace(filter.Query); q != "" {
  942. like := "%" + q + "%"
  943. args = append(args, like)
  944. n := itoa(len(args))
  945. clauses = append(clauses, "(e.request_id ILIKE $"+n+" OR e.client_request_id ILIKE $"+n+" OR e.error_message ILIKE $"+n+")")
  946. }
  947. if userQuery := strings.TrimSpace(filter.UserQuery); userQuery != "" {
  948. like := "%" + userQuery + "%"
  949. args = append(args, like)
  950. n := itoa(len(args))
  951. clauses = append(clauses, "EXISTS (SELECT 1 FROM users u WHERE u.id = e.user_id AND u.email ILIKE $"+n+")")
  952. }
  953. if filter.UserID != nil && *filter.UserID > 0 {
  954. args = append(args, *filter.UserID)
  955. n := itoa(len(args))
  956. clauses = append(clauses, "e.user_id = $"+n)
  957. }
  958. if filter.APIKeyID != nil && *filter.APIKeyID > 0 {
  959. args = append(args, *filter.APIKeyID)
  960. clauses = append(clauses, "e.api_key_id = $"+itoa(len(args)))
  961. }
  962. if m := strings.TrimSpace(filter.Model); m != "" {
  963. if filter.ModelFuzzy {
  964. args = append(args, "%"+escapeLikePattern(m)+"%")
  965. clauses = append(clauses, "COALESCE(e.requested_model, e.model, '') ILIKE $"+itoa(len(args)))
  966. } else {
  967. args = append(args, m)
  968. clauses = append(clauses, "COALESCE(e.requested_model, e.model, '') = $"+itoa(len(args)))
  969. }
  970. }
  971. if filter.ExcludeCountTokens {
  972. clauses = append(clauses, "COALESCE(e.is_count_tokens, false) = false")
  973. }
  974. if len(filter.ErrorPhasesAny) > 0 {
  975. args = append(args, pq.Array(filter.ErrorPhasesAny))
  976. clauses = append(clauses, "e.error_phase = ANY($"+itoa(len(args))+")")
  977. }
  978. if len(filter.ErrorTypesAny) > 0 {
  979. args = append(args, pq.Array(filter.ErrorTypesAny))
  980. clauses = append(clauses, "e.error_type = ANY($"+itoa(len(args))+")")
  981. }
  982. return "WHERE " + strings.Join(clauses, " AND "), args
  983. }
  984. func opsFilterIncludesRecoveredProviderRows(filter *service.OpsErrorLogFilter, phaseFilter string) bool {
  985. if filter == nil || !filter.IncludeRecoveredUpstream {
  986. return false
  987. }
  988. if phaseFilter != "" {
  989. return phaseFilter == "upstream" || phaseFilter == "account_auth"
  990. }
  991. if len(filter.ErrorPhasesAny) == 0 {
  992. return false
  993. }
  994. sawProviderPhase := false
  995. for _, rawPhase := range filter.ErrorPhasesAny {
  996. switch strings.TrimSpace(strings.ToLower(rawPhase)) {
  997. case "upstream", "account_auth":
  998. sawProviderPhase = true
  999. default:
  1000. return false
  1001. }
  1002. }
  1003. return sawProviderPhase
  1004. }
  1005. func buildOpsSystemLogsWhere(filter *service.OpsSystemLogFilter) (string, []any, bool) {
  1006. clauses := make([]string, 0, 10)
  1007. args := make([]any, 0, 10)
  1008. clauses = append(clauses, "1=1")
  1009. hasConstraint := false
  1010. if filter != nil && filter.StartTime != nil && !filter.StartTime.IsZero() {
  1011. args = append(args, filter.StartTime.UTC())
  1012. clauses = append(clauses, "l.created_at >= $"+itoa(len(args)))
  1013. hasConstraint = true
  1014. }
  1015. if filter != nil && filter.EndTime != nil && !filter.EndTime.IsZero() {
  1016. args = append(args, filter.EndTime.UTC())
  1017. clauses = append(clauses, "l.created_at < $"+itoa(len(args)))
  1018. hasConstraint = true
  1019. }
  1020. if filter != nil {
  1021. if v := strings.TrimSpace(filter.Host); v != "" {
  1022. args = append(args, v)
  1023. clauses = append(clauses, "l.host = $"+itoa(len(args)))
  1024. hasConstraint = true
  1025. }
  1026. if v := strings.ToLower(strings.TrimSpace(filter.Level)); v != "" {
  1027. args = append(args, v)
  1028. clauses = append(clauses, "LOWER(COALESCE(l.level,'')) = $"+itoa(len(args)))
  1029. hasConstraint = true
  1030. }
  1031. if v := strings.TrimSpace(filter.Component); v != "" {
  1032. args = append(args, v)
  1033. clauses = append(clauses, "COALESCE(l.component,'') = $"+itoa(len(args)))
  1034. hasConstraint = true
  1035. }
  1036. if v := strings.TrimSpace(filter.RequestID); v != "" {
  1037. args = append(args, v)
  1038. clauses = append(clauses, "COALESCE(l.request_id,'') = $"+itoa(len(args)))
  1039. hasConstraint = true
  1040. }
  1041. if v := strings.TrimSpace(filter.ClientRequestID); v != "" {
  1042. args = append(args, v)
  1043. clauses = append(clauses, "COALESCE(l.client_request_id,'') = $"+itoa(len(args)))
  1044. hasConstraint = true
  1045. }
  1046. if filter.UserID != nil && *filter.UserID > 0 {
  1047. args = append(args, *filter.UserID)
  1048. clauses = append(clauses, "l.user_id = $"+itoa(len(args)))
  1049. hasConstraint = true
  1050. }
  1051. if filter.APIKeyID != nil && *filter.APIKeyID > 0 {
  1052. args = append(args, *filter.APIKeyID)
  1053. clauses = append(clauses, "l.api_key_id = $"+itoa(len(args)))
  1054. hasConstraint = true
  1055. }
  1056. if filter.AccountID != nil && *filter.AccountID > 0 {
  1057. args = append(args, *filter.AccountID)
  1058. clauses = append(clauses, "l.account_id = $"+itoa(len(args)))
  1059. hasConstraint = true
  1060. }
  1061. if v := strings.TrimSpace(filter.Platform); v != "" {
  1062. args = append(args, v)
  1063. clauses = append(clauses, "COALESCE(l.platform,'') = $"+itoa(len(args)))
  1064. hasConstraint = true
  1065. }
  1066. if v := strings.TrimSpace(filter.Model); v != "" {
  1067. args = append(args, v)
  1068. clauses = append(clauses, "COALESCE(l.model,'') = $"+itoa(len(args)))
  1069. hasConstraint = true
  1070. }
  1071. if v := strings.TrimSpace(filter.Query); v != "" {
  1072. like := "%" + v + "%"
  1073. args = append(args, like)
  1074. n := itoa(len(args))
  1075. clauses = append(clauses, "(l.message ILIKE $"+n+" OR COALESCE(l.request_id,'') ILIKE $"+n+" OR COALESCE(l.client_request_id,'') ILIKE $"+n+" OR COALESCE(l.extra::text,'') ILIKE $"+n+")")
  1076. hasConstraint = true
  1077. }
  1078. }
  1079. return "WHERE " + strings.Join(clauses, " AND "), args, hasConstraint
  1080. }
  1081. func buildOpsSystemLogsCleanupWhere(filter *service.OpsSystemLogCleanupFilter) (string, []any, bool) {
  1082. if filter == nil {
  1083. filter = &service.OpsSystemLogCleanupFilter{}
  1084. }
  1085. listFilter := &service.OpsSystemLogFilter{
  1086. StartTime: filter.StartTime,
  1087. EndTime: filter.EndTime,
  1088. Host: filter.Host,
  1089. Level: filter.Level,
  1090. Component: filter.Component,
  1091. RequestID: filter.RequestID,
  1092. ClientRequestID: filter.ClientRequestID,
  1093. UserID: filter.UserID,
  1094. APIKeyID: filter.APIKeyID,
  1095. AccountID: filter.AccountID,
  1096. Platform: filter.Platform,
  1097. Model: filter.Model,
  1098. Query: filter.Query,
  1099. }
  1100. return buildOpsSystemLogsWhere(listFilter)
  1101. }
  1102. // Helpers for nullable args
  1103. func opsNullString(v any) any {
  1104. switch s := v.(type) {
  1105. case nil:
  1106. return sql.NullString{}
  1107. case *string:
  1108. if s == nil || strings.TrimSpace(*s) == "" {
  1109. return sql.NullString{}
  1110. }
  1111. return sql.NullString{String: strings.TrimSpace(*s), Valid: true}
  1112. case string:
  1113. if strings.TrimSpace(s) == "" {
  1114. return sql.NullString{}
  1115. }
  1116. return sql.NullString{String: strings.TrimSpace(s), Valid: true}
  1117. default:
  1118. return sql.NullString{}
  1119. }
  1120. }
  1121. func opsNullInt64(v *int64) any {
  1122. if v == nil || *v == 0 {
  1123. return sql.NullInt64{}
  1124. }
  1125. return sql.NullInt64{Int64: *v, Valid: true}
  1126. }
  1127. func opsNullInt(v any) any {
  1128. switch n := v.(type) {
  1129. case nil:
  1130. return sql.NullInt64{}
  1131. case *int:
  1132. if n == nil || *n == 0 {
  1133. return sql.NullInt64{}
  1134. }
  1135. return sql.NullInt64{Int64: int64(*n), Valid: true}
  1136. case *int64:
  1137. if n == nil || *n == 0 {
  1138. return sql.NullInt64{}
  1139. }
  1140. return sql.NullInt64{Int64: *n, Valid: true}
  1141. case int:
  1142. if n == 0 {
  1143. return sql.NullInt64{}
  1144. }
  1145. return sql.NullInt64{Int64: int64(n), Valid: true}
  1146. default:
  1147. return sql.NullInt64{}
  1148. }
  1149. }
  1150. // opsNullableIntPointer distinguishes an absent value from an explicitly
  1151. // observed zero. Credential-stage failures intentionally persist upstream
  1152. // status 0 because no inference request was sent.
  1153. func opsNullableIntPointer(v *int) any {
  1154. if v == nil {
  1155. return sql.NullInt64{}
  1156. }
  1157. return sql.NullInt64{Int64: int64(*v), Valid: true}
  1158. }
  1159. func opsNullInt16(v *int16) any {
  1160. if v == nil {
  1161. return sql.NullInt64{}
  1162. }
  1163. return sql.NullInt64{Int64: int64(*v), Valid: true}
  1164. }