channel_monitor_repo.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. "github.com/Wei-Shaw/sub2api/ent/channelmonitor"
  11. "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
  12. "github.com/Wei-Shaw/sub2api/internal/domain"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/lib/pq"
  15. entsql "entgo.io/ent/dialect/sql"
  16. "entgo.io/ent/dialect/sql/sqljson"
  17. )
  18. // channelMonitorRepository 实现 service.ChannelMonitorRepository。
  19. //
  20. // 选型说明:
  21. // - CRUD 走 ent,复用项目的事务上下文支持
  22. // - 聚合查询(latest per model / availability)走原生 SQL,避免 ent 在 GROUP BY 上
  23. // 的样板代码,并保证索引能被命中
  24. type channelMonitorRepository struct {
  25. client *dbent.Client
  26. db *sql.DB
  27. }
  28. // NewChannelMonitorRepository 创建仓储实例。
  29. func NewChannelMonitorRepository(client *dbent.Client, db *sql.DB) service.ChannelMonitorRepository {
  30. return &channelMonitorRepository{client: client, db: db}
  31. }
  32. // ---------- CRUD ----------
  33. func (r *channelMonitorRepository) Create(ctx context.Context, m *service.ChannelMonitor) error {
  34. client := clientFromContext(ctx, r.client)
  35. builder := client.ChannelMonitor.Create().
  36. SetName(m.Name).
  37. SetProvider(channelmonitor.Provider(m.Provider)).
  38. SetAPIMode(defaultAPIModeRepo(m.APIMode)).
  39. SetEndpoint(m.Endpoint).
  40. SetAPIKeyEncrypted(m.APIKey). // 调用方传入的已是密文
  41. SetPrimaryModel(m.PrimaryModel).
  42. SetExtraModels(emptySliceIfNil(m.ExtraModels)).
  43. SetGroupName(m.GroupName).
  44. SetEnabled(m.Enabled).
  45. SetIntervalSeconds(m.IntervalSeconds).
  46. SetJitterSeconds(m.JitterSeconds).
  47. SetCreatedBy(m.CreatedBy).
  48. SetExtraHeaders(channelMonitorHeadersForPersistence(m)).
  49. SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)).
  50. SetCheckMode(defaultCheckModeRepo(m.CheckMode))
  51. if m.TemplateID != nil {
  52. builder = builder.SetTemplateID(*m.TemplateID)
  53. }
  54. if m.AccountID != nil {
  55. builder = builder.SetAccountID(*m.AccountID)
  56. }
  57. if m.BodyOverride != nil {
  58. builder = builder.SetBodyOverride(m.BodyOverride)
  59. }
  60. created, err := builder.Save(ctx)
  61. if err != nil {
  62. return translatePersistenceError(err, service.ErrChannelMonitorNotFound, nil)
  63. }
  64. m.ID = created.ID
  65. m.CreatedAt = created.CreatedAt
  66. m.UpdatedAt = created.UpdatedAt
  67. return nil
  68. }
  69. func (r *channelMonitorRepository) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.ChannelMonitor, error) {
  70. if strings.TrimSpace(operationID) == "" {
  71. return nil, nil
  72. }
  73. client := clientFromContext(ctx, r.client)
  74. row, err := client.ChannelMonitor.Query().
  75. Where(func(selector *entsql.Selector) {
  76. selector.Where(sqljson.ValueEQ(
  77. channelmonitor.FieldExtraHeaders,
  78. operationID,
  79. sqljson.Path(service.ChannelMonitorDuplicateOperationIDMetadataKey),
  80. ))
  81. }).
  82. Order(dbent.Asc(channelmonitor.FieldID)).
  83. First(ctx)
  84. if dbent.IsNotFound(err) {
  85. return nil, nil
  86. }
  87. if err != nil {
  88. return nil, fmt.Errorf("find channel monitor duplicate operation: %w", err)
  89. }
  90. return entToServiceMonitor(row), nil
  91. }
  92. func (r *channelMonitorRepository) GetByID(ctx context.Context, id int64) (*service.ChannelMonitor, error) {
  93. row, err := r.client.ChannelMonitor.Query().
  94. Where(channelmonitor.IDEQ(id)).
  95. Only(ctx)
  96. if err != nil {
  97. return nil, translatePersistenceError(err, service.ErrChannelMonitorNotFound, nil)
  98. }
  99. return entToServiceMonitor(row), nil
  100. }
  101. func (r *channelMonitorRepository) Update(ctx context.Context, m *service.ChannelMonitor) error {
  102. client := clientFromContext(ctx, r.client)
  103. updater := client.ChannelMonitor.UpdateOneID(m.ID).
  104. SetName(m.Name).
  105. SetProvider(channelmonitor.Provider(m.Provider)).
  106. SetAPIMode(defaultAPIModeRepo(m.APIMode)).
  107. SetEndpoint(m.Endpoint).
  108. SetAPIKeyEncrypted(m.APIKey).
  109. SetPrimaryModel(m.PrimaryModel).
  110. SetExtraModels(emptySliceIfNil(m.ExtraModels)).
  111. SetGroupName(m.GroupName).
  112. SetEnabled(m.Enabled).
  113. SetIntervalSeconds(m.IntervalSeconds).
  114. SetJitterSeconds(m.JitterSeconds).
  115. SetExtraHeaders(channelMonitorHeadersForPersistence(m)).
  116. SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)).
  117. SetCheckMode(defaultCheckModeRepo(m.CheckMode))
  118. if m.TemplateID != nil {
  119. updater = updater.SetTemplateID(*m.TemplateID)
  120. } else {
  121. updater = updater.ClearTemplateID()
  122. }
  123. if m.AccountID != nil {
  124. updater = updater.SetAccountID(*m.AccountID)
  125. } else {
  126. updater = updater.ClearAccountID()
  127. }
  128. if m.BodyOverride != nil {
  129. updater = updater.SetBodyOverride(m.BodyOverride)
  130. } else {
  131. updater = updater.ClearBodyOverride()
  132. }
  133. updated, err := updater.Save(ctx)
  134. if err != nil {
  135. return translatePersistenceError(err, service.ErrChannelMonitorNotFound, nil)
  136. }
  137. m.UpdatedAt = updated.UpdatedAt
  138. return nil
  139. }
  140. func (r *channelMonitorRepository) Delete(ctx context.Context, id int64) error {
  141. client := clientFromContext(ctx, r.client)
  142. if err := client.ChannelMonitor.DeleteOneID(id).Exec(ctx); err != nil {
  143. return translatePersistenceError(err, service.ErrChannelMonitorNotFound, nil)
  144. }
  145. return nil
  146. }
  147. func (r *channelMonitorRepository) List(ctx context.Context, params service.ChannelMonitorListParams) ([]*service.ChannelMonitor, int64, error) {
  148. q := r.client.ChannelMonitor.Query()
  149. if params.Provider != "" {
  150. q = q.Where(channelmonitor.ProviderEQ(channelmonitor.Provider(params.Provider)))
  151. }
  152. if params.Enabled != nil {
  153. q = q.Where(channelmonitor.EnabledEQ(*params.Enabled))
  154. }
  155. if s := strings.TrimSpace(params.Search); s != "" {
  156. q = q.Where(channelmonitor.Or(
  157. channelmonitor.NameContainsFold(s),
  158. channelmonitor.GroupNameContainsFold(s),
  159. channelmonitor.PrimaryModelContainsFold(s),
  160. ))
  161. }
  162. total, err := q.Count(ctx)
  163. if err != nil {
  164. return nil, 0, fmt.Errorf("count monitors: %w", err)
  165. }
  166. pageSize := params.PageSize
  167. if pageSize <= 0 {
  168. pageSize = 20
  169. }
  170. page := params.Page
  171. if page <= 0 {
  172. page = 1
  173. }
  174. rows, err := q.
  175. Order(dbent.Desc(channelmonitor.FieldID)).
  176. Offset((page - 1) * pageSize).
  177. Limit(pageSize).
  178. All(ctx)
  179. if err != nil {
  180. return nil, 0, fmt.Errorf("list monitors: %w", err)
  181. }
  182. out := make([]*service.ChannelMonitor, 0, len(rows))
  183. for _, row := range rows {
  184. out = append(out, entToServiceMonitor(row))
  185. }
  186. return out, int64(total), nil
  187. }
  188. // ---------- 调度器辅助 ----------
  189. func (r *channelMonitorRepository) ListEnabled(ctx context.Context) ([]*service.ChannelMonitor, error) {
  190. rows, err := r.client.ChannelMonitor.Query().
  191. Where(channelmonitor.EnabledEQ(true)).
  192. All(ctx)
  193. if err != nil {
  194. return nil, fmt.Errorf("list enabled monitors: %w", err)
  195. }
  196. out := make([]*service.ChannelMonitor, 0, len(rows))
  197. for _, row := range rows {
  198. out = append(out, entToServiceMonitor(row))
  199. }
  200. return out, nil
  201. }
  202. func (r *channelMonitorRepository) MarkChecked(ctx context.Context, id int64, checkedAt time.Time) error {
  203. client := clientFromContext(ctx, r.client)
  204. if err := client.ChannelMonitor.UpdateOneID(id).
  205. SetLastCheckedAt(checkedAt).
  206. Exec(ctx); err != nil {
  207. return translatePersistenceError(err, service.ErrChannelMonitorNotFound, nil)
  208. }
  209. return nil
  210. }
  211. func (r *channelMonitorRepository) InsertHistoryBatch(ctx context.Context, rows []*service.ChannelMonitorHistoryRow) error {
  212. if len(rows) == 0 {
  213. return nil
  214. }
  215. client := clientFromContext(ctx, r.client)
  216. bulk := make([]*dbent.ChannelMonitorHistoryCreate, 0, len(rows))
  217. for _, row := range rows {
  218. c := client.ChannelMonitorHistory.Create().
  219. SetMonitorID(row.MonitorID).
  220. SetModel(row.Model).
  221. SetStatus(channelmonitorhistory.Status(row.Status)).
  222. SetMessage(row.Message).
  223. SetCheckedAt(row.CheckedAt)
  224. if row.LatencyMs != nil {
  225. c = c.SetLatencyMs(*row.LatencyMs)
  226. }
  227. if row.PingLatencyMs != nil {
  228. c = c.SetPingLatencyMs(*row.PingLatencyMs)
  229. }
  230. if row.Quota != nil {
  231. c = c.SetQuota(row.Quota)
  232. }
  233. bulk = append(bulk, c)
  234. }
  235. if _, err := client.ChannelMonitorHistory.CreateBulk(bulk...).Save(ctx); err != nil {
  236. return fmt.Errorf("insert history bulk: %w", err)
  237. }
  238. return nil
  239. }
  240. // DeleteHistoryBefore 物理删 checked_at < before 的明细,分批 channelMonitorPruneBatchSize 行一批,
  241. // 避免单事务删除过多引起锁/WAL 压力。借助 (checked_at) 索引定位小批 id,再按 id 删。
  242. func (r *channelMonitorRepository) DeleteHistoryBefore(ctx context.Context, before time.Time) (int64, error) {
  243. return deleteChannelMonitorBatched(ctx, r.db, channelMonitorPruneHistorySQL, before)
  244. }
  245. // ListHistory 按 checked_at 倒序返回某个监控的最近 N 条历史记录。
  246. // model 为空时不过滤;非空时只返回该模型的记录。
  247. func (r *channelMonitorRepository) ListHistory(ctx context.Context, monitorID int64, model string, limit int) ([]*service.ChannelMonitorHistoryEntry, error) {
  248. q := r.client.ChannelMonitorHistory.Query().
  249. Where(channelmonitorhistory.MonitorIDEQ(monitorID))
  250. if strings.TrimSpace(model) != "" {
  251. q = q.Where(channelmonitorhistory.ModelEQ(model))
  252. }
  253. rows, err := q.
  254. Order(dbent.Desc(channelmonitorhistory.FieldCheckedAt)).
  255. Limit(limit).
  256. All(ctx)
  257. if err != nil {
  258. return nil, fmt.Errorf("list history: %w", err)
  259. }
  260. out := make([]*service.ChannelMonitorHistoryEntry, 0, len(rows))
  261. for _, row := range rows {
  262. entry := &service.ChannelMonitorHistoryEntry{
  263. ID: row.ID,
  264. Model: row.Model,
  265. Status: string(row.Status),
  266. LatencyMs: row.LatencyMs,
  267. PingLatencyMs: row.PingLatencyMs,
  268. Message: row.Message,
  269. CheckedAt: row.CheckedAt,
  270. Quota: row.Quota,
  271. }
  272. out = append(out, entry)
  273. }
  274. return out, nil
  275. }
  276. // ---------- 用户视图聚合(原生 SQL) ----------
  277. // ListLatestPerModel 用 DISTINCT ON 取每个 (monitor_id, model) 的最近一条记录。
  278. // 借助 (monitor_id, model, checked_at DESC) 索引可走 Index Scan。
  279. func (r *channelMonitorRepository) ListLatestPerModel(ctx context.Context, monitorID int64) ([]*service.ChannelMonitorLatest, error) {
  280. const q = `
  281. SELECT DISTINCT ON (model)
  282. model, status, latency_ms, ping_latency_ms, checked_at
  283. FROM channel_monitor_histories
  284. WHERE monitor_id = $1
  285. ORDER BY model, checked_at DESC
  286. `
  287. rows, err := r.db.QueryContext(ctx, q, monitorID)
  288. if err != nil {
  289. return nil, fmt.Errorf("query latest per model: %w", err)
  290. }
  291. defer func() { _ = rows.Close() }()
  292. out := make([]*service.ChannelMonitorLatest, 0)
  293. for rows.Next() {
  294. l := &service.ChannelMonitorLatest{}
  295. var latency, ping sql.NullInt64
  296. if err := rows.Scan(&l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil {
  297. return nil, fmt.Errorf("scan latest row: %w", err)
  298. }
  299. assignNullInt(&l.LatencyMs, latency)
  300. assignNullInt(&l.PingLatencyMs, ping)
  301. out = append(out, l)
  302. }
  303. return out, rows.Err()
  304. }
  305. // assignNullInt 把 sql.NullInt64 解包到 *int 指针目标(valid 才分配新 int)。
  306. // 集中实现避免 latency / ping 两处重复 if latency.Valid { v := int(...) ... } 模板。
  307. func assignNullInt(dst **int, n sql.NullInt64) {
  308. if !n.Valid {
  309. return
  310. }
  311. v := int(n.Int64)
  312. *dst = &v
  313. }
  314. // scanMonitorQuota 把裸 SQL 读出的 JSONB quota 列解包为配额快照。
  315. // NULL(探活模式旧行)返回 nil;解析失败也返回 nil 并由调用方日志感知,
  316. // 不阻断列表渲染(与聚合层"失败仅日志"的原则一致)。
  317. func scanMonitorQuota(data []byte) *domain.MonitorQuotaSnapshot {
  318. if len(data) == 0 {
  319. return nil
  320. }
  321. snapshot := &domain.MonitorQuotaSnapshot{}
  322. if err := json.Unmarshal(data, snapshot); err != nil {
  323. return nil
  324. }
  325. return snapshot
  326. }
  327. // ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。
  328. // "可用" = status IN (operational, degraded)。
  329. //
  330. // 数据来源:明细表只保留 1 天;窗口前其余天数走聚合表。
  331. // 明细保留 30 天(monitorHistoryRetentionDays),窗口 <= 30 天时直接扫 histories,
  332. // 精度到秒,避免与聚合表 UNION 带来的 UTC 日切精度损失。
  333. func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) {
  334. if windowDays <= 0 {
  335. windowDays = 7
  336. }
  337. const q = `
  338. SELECT model,
  339. COUNT(*) AS total,
  340. COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok,
  341. CASE WHEN COUNT(latency_ms) > 0
  342. THEN SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL)::float8 / COUNT(latency_ms)
  343. ELSE NULL END AS avg_latency_ms
  344. FROM channel_monitor_histories
  345. WHERE monitor_id = $1
  346. AND checked_at >= NOW() - ($2::int || ' days')::interval
  347. GROUP BY model
  348. `
  349. rows, err := r.db.QueryContext(ctx, q, monitorID, windowDays)
  350. if err != nil {
  351. return nil, fmt.Errorf("query availability: %w", err)
  352. }
  353. defer func() { _ = rows.Close() }()
  354. out := make([]*service.ChannelMonitorAvailability, 0)
  355. for rows.Next() {
  356. row, err := scanAvailabilityRow(rows, windowDays)
  357. if err != nil {
  358. return nil, err
  359. }
  360. out = append(out, row)
  361. }
  362. return out, rows.Err()
  363. }
  364. // scanAvailabilityRow 把单行 (model, total, ok, avg_latency) 扫描为 ChannelMonitorAvailability。
  365. // 仅服务于 ComputeAvailability(4 列);批量版本因为多一列 monitor_id 直接 inline 调 finalizeAvailabilityRow。
  366. func scanAvailabilityRow(rows interface{ Scan(...any) error }, windowDays int) (*service.ChannelMonitorAvailability, error) {
  367. row := &service.ChannelMonitorAvailability{WindowDays: windowDays}
  368. var avgLatency sql.NullFloat64
  369. if err := rows.Scan(&row.Model, &row.TotalChecks, &row.OperationalChecks, &avgLatency); err != nil {
  370. return nil, fmt.Errorf("scan availability row: %w", err)
  371. }
  372. finalizeAvailabilityRow(row, avgLatency)
  373. return row, nil
  374. }
  375. // finalizeAvailabilityRow 根据 OperationalChecks/TotalChecks 算出可用率,
  376. // 并把 sql.NullFloat64 的平均延迟解包为 *int。两处复用避免维护漂移。
  377. func finalizeAvailabilityRow(row *service.ChannelMonitorAvailability, avgLatency sql.NullFloat64) {
  378. if row.TotalChecks > 0 {
  379. row.AvailabilityPct = float64(row.OperationalChecks) * 100.0 / float64(row.TotalChecks)
  380. }
  381. if avgLatency.Valid {
  382. v := int(avgLatency.Float64)
  383. row.AvgLatencyMs = &v
  384. }
  385. }
  386. // ListLatestForMonitorIDs 一次性查询多个监控的"每个 (monitor_id, model) 最近一条"记录。
  387. // 利用 PG 的 DISTINCT ON 特性,借助 (monitor_id, model, checked_at DESC) 索引可走 Index Scan。
  388. func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, ids []int64) (map[int64][]*service.ChannelMonitorLatest, error) {
  389. out := make(map[int64][]*service.ChannelMonitorLatest, len(ids))
  390. if len(ids) == 0 {
  391. return out, nil
  392. }
  393. const q = `
  394. SELECT DISTINCT ON (monitor_id, model)
  395. monitor_id, model, status, latency_ms, ping_latency_ms, checked_at, quota
  396. FROM channel_monitor_histories
  397. WHERE monitor_id = ANY($1)
  398. ORDER BY monitor_id, model, checked_at DESC
  399. `
  400. rows, err := r.db.QueryContext(ctx, q, pq.Array(ids))
  401. if err != nil {
  402. return nil, fmt.Errorf("query latest batch: %w", err)
  403. }
  404. defer func() { _ = rows.Close() }()
  405. for rows.Next() {
  406. var monitorID int64
  407. l := &service.ChannelMonitorLatest{}
  408. var latency, ping sql.NullInt64
  409. var quota []byte
  410. if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt, &quota); err != nil {
  411. return nil, fmt.Errorf("scan latest batch row: %w", err)
  412. }
  413. assignNullInt(&l.LatencyMs, latency)
  414. assignNullInt(&l.PingLatencyMs, ping)
  415. l.Quota = scanMonitorQuota(quota)
  416. out[monitorID] = append(out[monitorID], l)
  417. }
  418. if err := rows.Err(); err != nil {
  419. return nil, err
  420. }
  421. return out, nil
  422. }
  423. // ListRecentHistoryForMonitors 为多个 monitor 批量取各自"指定模型"最近 N 条历史(按 checked_at DESC,最新在前)。
  424. // primaryModels[monitorID] 指定该监控要过滤的模型名;monitor 不在 primaryModels 中的记录不返回。
  425. // 通过 CTE + unnest(两个 int8/text 数组) 构造 (monitor_id, model) 白名单,
  426. // 再用 ROW_NUMBER() OVER (PARTITION BY monitor_id) 取各自前 N 条。
  427. //
  428. // 返回值:map[monitorID] -> []*ChannelMonitorHistoryEntry(不含 message,减少网络开销)。
  429. // 空 ids / 空 primaryModels 返回空 map,不报错。
  430. func (r *channelMonitorRepository) ListRecentHistoryForMonitors(
  431. ctx context.Context,
  432. ids []int64,
  433. primaryModels map[int64]string,
  434. perMonitorLimit int,
  435. ) (map[int64][]*service.ChannelMonitorHistoryEntry, error) {
  436. out := make(map[int64][]*service.ChannelMonitorHistoryEntry, len(ids))
  437. pairIDs, pairModels := buildMonitorModelPairs(ids, primaryModels)
  438. if len(pairIDs) == 0 {
  439. return out, nil
  440. }
  441. perMonitorLimit = clampTimelineLimit(perMonitorLimit)
  442. const q = `
  443. WITH targets AS (
  444. SELECT unnest($1::bigint[]) AS monitor_id,
  445. unnest($2::text[]) AS model
  446. ),
  447. ranked AS (
  448. SELECT h.monitor_id,
  449. h.status,
  450. h.latency_ms,
  451. h.ping_latency_ms,
  452. h.checked_at,
  453. ROW_NUMBER() OVER (PARTITION BY h.monitor_id ORDER BY h.checked_at DESC) AS rn
  454. FROM channel_monitor_histories h
  455. JOIN targets t
  456. ON t.monitor_id = h.monitor_id AND t.model = h.model
  457. )
  458. SELECT monitor_id, status, latency_ms, ping_latency_ms, checked_at
  459. FROM ranked
  460. WHERE rn <= $3
  461. ORDER BY monitor_id, checked_at DESC
  462. `
  463. rows, err := r.db.QueryContext(ctx, q, pq.Array(pairIDs), pq.Array(pairModels), perMonitorLimit)
  464. if err != nil {
  465. return nil, fmt.Errorf("query recent history batch: %w", err)
  466. }
  467. defer func() { _ = rows.Close() }()
  468. for rows.Next() {
  469. var monitorID int64
  470. entry := &service.ChannelMonitorHistoryEntry{}
  471. var latency, ping sql.NullInt64
  472. if err := rows.Scan(&monitorID, &entry.Status, &latency, &ping, &entry.CheckedAt); err != nil {
  473. return nil, fmt.Errorf("scan recent history row: %w", err)
  474. }
  475. assignNullInt(&entry.LatencyMs, latency)
  476. assignNullInt(&entry.PingLatencyMs, ping)
  477. out[monitorID] = append(out[monitorID], entry)
  478. }
  479. if err := rows.Err(); err != nil {
  480. return nil, err
  481. }
  482. return out, nil
  483. }
  484. // buildMonitorModelPairs 基于 ids 过滤出有效的 (monitor_id, model) 对,model 为空时跳过。
  485. // 保证两个数组长度一致且一一对应,供 unnest 展开。
  486. func buildMonitorModelPairs(ids []int64, primaryModels map[int64]string) ([]int64, []string) {
  487. if len(ids) == 0 || len(primaryModels) == 0 {
  488. return nil, nil
  489. }
  490. pairIDs := make([]int64, 0, len(ids))
  491. pairModels := make([]string, 0, len(ids))
  492. for _, id := range ids {
  493. model, ok := primaryModels[id]
  494. if !ok || strings.TrimSpace(model) == "" {
  495. continue
  496. }
  497. pairIDs = append(pairIDs, id)
  498. pairModels = append(pairModels, model)
  499. }
  500. return pairIDs, pairModels
  501. }
  502. // timelineLimit* 批量 timeline 查询的 perMonitorLimit 夹紧范围。
  503. // 下限 1 表示至少返回最近一条;上限 200 控制单次响应体与 SQL 内存占用(ROW_NUMBER 窗口上限)。
  504. const (
  505. timelineLimitMin = 1
  506. timelineLimitMax = 200
  507. )
  508. // clampTimelineLimit 把 perMonitorLimit 夹紧到 [timelineLimitMin, timelineLimitMax],避免非法值或超大查询。
  509. func clampTimelineLimit(n int) int {
  510. if n < timelineLimitMin {
  511. return timelineLimitMin
  512. }
  513. if n > timelineLimitMax {
  514. return timelineLimitMax
  515. }
  516. return n
  517. }
  518. // ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。
  519. // 明细保留 30 天,直接扫 histories(窗口 <= 30 天时无需聚合)。
  520. func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) {
  521. out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids))
  522. if len(ids) == 0 {
  523. return out, nil
  524. }
  525. if windowDays <= 0 {
  526. windowDays = 7
  527. }
  528. const q = `
  529. SELECT monitor_id,
  530. model,
  531. COUNT(*) AS total,
  532. COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok,
  533. CASE WHEN COUNT(latency_ms) > 0
  534. THEN SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL)::float8 / COUNT(latency_ms)
  535. ELSE NULL END AS avg_latency_ms
  536. FROM channel_monitor_histories
  537. WHERE monitor_id = ANY($1)
  538. AND checked_at >= NOW() - ($2::int || ' days')::interval
  539. GROUP BY monitor_id, model
  540. `
  541. rows, err := r.db.QueryContext(ctx, q, pq.Array(ids), windowDays)
  542. if err != nil {
  543. return nil, fmt.Errorf("query availability batch: %w", err)
  544. }
  545. defer func() { _ = rows.Close() }()
  546. for rows.Next() {
  547. var monitorID int64
  548. row := &service.ChannelMonitorAvailability{WindowDays: windowDays}
  549. var avgLatency sql.NullFloat64
  550. if err := rows.Scan(&monitorID, &row.Model, &row.TotalChecks, &row.OperationalChecks, &avgLatency); err != nil {
  551. return nil, fmt.Errorf("scan availability batch row: %w", err)
  552. }
  553. // 批量查询多了首列 monitor_id;其余字段的可用率/平均延迟换算与单 monitor 版本一致,
  554. // 抽出 finalizeAvailabilityRow 复用,避免两处分别维护除法与 NullFloat 解包。
  555. finalizeAvailabilityRow(row, avgLatency)
  556. out[monitorID] = append(out[monitorID], row)
  557. }
  558. if err := rows.Err(); err != nil {
  559. return nil, err
  560. }
  561. return out, nil
  562. }
  563. // ---------- 聚合维护 ----------
  564. // UpsertDailyRollupsFor 把 targetDate 当天([targetDate, targetDate+1d))的明细
  565. // 按 (monitor_id, model, bucket_date) 聚合写入 channel_monitor_daily_rollups。
  566. // - 用 ON CONFLICT (monitor_id, model, bucket_date) DO UPDATE 实现幂等回填,
  567. // 重复执行只会用最新统计覆盖;
  568. // - $1::date 让 PG 自动把入参 truncate 到 UTC 日期,调用方不需要预处理 targetDate。
  569. func (r *channelMonitorRepository) UpsertDailyRollupsFor(ctx context.Context, targetDate time.Time) (int64, error) {
  570. const q = `
  571. INSERT INTO channel_monitor_daily_rollups (
  572. monitor_id, model, bucket_date,
  573. total_checks, ok_count,
  574. operational_count, degraded_count, failed_count, error_count,
  575. sum_latency_ms, count_latency,
  576. sum_ping_latency_ms, count_ping_latency,
  577. computed_at
  578. )
  579. SELECT
  580. monitor_id,
  581. model,
  582. $1::date AS bucket_date,
  583. COUNT(*) AS total_checks,
  584. COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok_count,
  585. COUNT(*) FILTER (WHERE status = 'operational') AS operational_count,
  586. COUNT(*) FILTER (WHERE status = 'degraded') AS degraded_count,
  587. COUNT(*) FILTER (WHERE status = 'failed') AS failed_count,
  588. COUNT(*) FILTER (WHERE status = 'error') AS error_count,
  589. COALESCE(SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0) AS sum_latency_ms,
  590. COUNT(latency_ms) AS count_latency,
  591. COALESCE(SUM(ping_latency_ms) FILTER (WHERE ping_latency_ms IS NOT NULL), 0) AS sum_ping_latency_ms,
  592. COUNT(ping_latency_ms) AS count_ping_latency,
  593. NOW()
  594. FROM channel_monitor_histories
  595. WHERE checked_at >= $1::date
  596. AND checked_at < ($1::date + INTERVAL '1 day')
  597. GROUP BY monitor_id, model
  598. ON CONFLICT (monitor_id, model, bucket_date) DO UPDATE SET
  599. total_checks = EXCLUDED.total_checks,
  600. ok_count = EXCLUDED.ok_count,
  601. operational_count = EXCLUDED.operational_count,
  602. degraded_count = EXCLUDED.degraded_count,
  603. failed_count = EXCLUDED.failed_count,
  604. error_count = EXCLUDED.error_count,
  605. sum_latency_ms = EXCLUDED.sum_latency_ms,
  606. count_latency = EXCLUDED.count_latency,
  607. sum_ping_latency_ms = EXCLUDED.sum_ping_latency_ms,
  608. count_ping_latency = EXCLUDED.count_ping_latency,
  609. computed_at = NOW()
  610. `
  611. res, err := r.db.ExecContext(ctx, q, targetDate)
  612. if err != nil {
  613. return 0, fmt.Errorf("upsert daily rollups for %s: %w", targetDate.Format("2006-01-02"), err)
  614. }
  615. n, err := res.RowsAffected()
  616. if err != nil {
  617. return 0, fmt.Errorf("rows affected (upsert rollups): %w", err)
  618. }
  619. return n, nil
  620. }
  621. // DeleteRollupsBefore 物理删 bucket_date < beforeDate 的聚合行,同样分批。
  622. func (r *channelMonitorRepository) DeleteRollupsBefore(ctx context.Context, beforeDate time.Time) (int64, error) {
  623. return deleteChannelMonitorBatched(ctx, r.db, channelMonitorPruneRollupSQL, beforeDate)
  624. }
  625. // channelMonitorPruneBatchSize 单批删除上限。与 ops_cleanup_service 保持一致的 5000,
  626. // 在大表上按 id 小批删可以避免长事务和 WAL 堆积。
  627. const channelMonitorPruneBatchSize = 5000
  628. // channelMonitorPruneHistorySQL 分批物理删明细表过期行。
  629. const channelMonitorPruneHistorySQL = `
  630. WITH batch AS (
  631. SELECT id FROM channel_monitor_histories
  632. WHERE checked_at < $1
  633. ORDER BY id
  634. LIMIT $2
  635. )
  636. DELETE FROM channel_monitor_histories
  637. WHERE id IN (SELECT id FROM batch)
  638. `
  639. // channelMonitorPruneRollupSQL 分批物理删 rollup 表过期行。bucket_date 需要 ::date 转型
  640. // 保证与 DATE 列一致比较。
  641. const channelMonitorPruneRollupSQL = `
  642. WITH batch AS (
  643. SELECT id FROM channel_monitor_daily_rollups
  644. WHERE bucket_date < $1::date
  645. ORDER BY id
  646. LIMIT $2
  647. )
  648. DELETE FROM channel_monitor_daily_rollups
  649. WHERE id IN (SELECT id FROM batch)
  650. `
  651. // deleteChannelMonitorBatched 循环执行分批 DELETE,直到影响行为 0。返回累计删除行数。
  652. // cutoff 由调用方按列类型传入(明细用 time.Time 对 TIMESTAMPTZ,rollup 用 time.Time SQL 侧 ::date 转型)。
  653. func deleteChannelMonitorBatched(ctx context.Context, db *sql.DB, query string, cutoff time.Time) (int64, error) {
  654. var total int64
  655. for {
  656. res, err := db.ExecContext(ctx, query, cutoff, channelMonitorPruneBatchSize)
  657. if err != nil {
  658. return total, fmt.Errorf("channel_monitor prune batch: %w", err)
  659. }
  660. affected, err := res.RowsAffected()
  661. if err != nil {
  662. return total, fmt.Errorf("channel_monitor prune rows affected: %w", err)
  663. }
  664. total += affected
  665. if affected == 0 {
  666. break
  667. }
  668. }
  669. return total, nil
  670. }
  671. // LoadAggregationWatermark 读 watermark 表(id=1)。
  672. // watermark 表不是 ent schema(只有一行),直接走原生 SQL。
  673. // - 行不存在或 last_aggregated_date IS NULL:返回 (nil, nil),由调用方决定首次回填策略
  674. func (r *channelMonitorRepository) LoadAggregationWatermark(ctx context.Context) (*time.Time, error) {
  675. const q = `SELECT last_aggregated_date FROM channel_monitor_aggregation_watermark WHERE id = 1`
  676. var t sql.NullTime
  677. if err := r.db.QueryRowContext(ctx, q).Scan(&t); err != nil {
  678. if err == sql.ErrNoRows {
  679. return nil, nil
  680. }
  681. return nil, fmt.Errorf("load aggregation watermark: %w", err)
  682. }
  683. if !t.Valid {
  684. return nil, nil
  685. }
  686. return &t.Time, nil
  687. }
  688. // UpdateAggregationWatermark 更新 watermark(UPSERT 到 id=1)。
  689. // $1::date 让 PG 把入参 truncate 到 UTC 日期,与 last_aggregated_date 列的 DATE 类型一致。
  690. func (r *channelMonitorRepository) UpdateAggregationWatermark(ctx context.Context, date time.Time) error {
  691. const q = `
  692. INSERT INTO channel_monitor_aggregation_watermark (id, last_aggregated_date, updated_at)
  693. VALUES (1, $1::date, NOW())
  694. ON CONFLICT (id) DO UPDATE SET
  695. last_aggregated_date = EXCLUDED.last_aggregated_date,
  696. updated_at = NOW()
  697. `
  698. if _, err := r.db.ExecContext(ctx, q, date); err != nil {
  699. return fmt.Errorf("update aggregation watermark: %w", err)
  700. }
  701. return nil
  702. }
  703. // ---------- helpers ----------
  704. func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor {
  705. if row == nil {
  706. return nil
  707. }
  708. extras := row.ExtraModels
  709. if extras == nil {
  710. extras = []string{}
  711. }
  712. headers := make(map[string]string, len(row.ExtraHeaders))
  713. for key, value := range row.ExtraHeaders {
  714. headers[key] = value
  715. }
  716. duplicateOperationID := headers[service.ChannelMonitorDuplicateOperationIDMetadataKey]
  717. delete(headers, service.ChannelMonitorDuplicateOperationIDMetadataKey)
  718. out := &service.ChannelMonitor{
  719. ID: row.ID,
  720. Name: row.Name,
  721. Provider: string(row.Provider),
  722. APIMode: defaultAPIModeRepo(row.APIMode),
  723. Endpoint: row.Endpoint,
  724. APIKey: row.APIKeyEncrypted, // 仍为密文,service 层负责解密
  725. PrimaryModel: row.PrimaryModel,
  726. ExtraModels: extras,
  727. GroupName: row.GroupName,
  728. Enabled: row.Enabled,
  729. IntervalSeconds: row.IntervalSeconds,
  730. JitterSeconds: row.JitterSeconds,
  731. LastCheckedAt: row.LastCheckedAt,
  732. CreatedBy: row.CreatedBy,
  733. CreatedAt: row.CreatedAt,
  734. UpdatedAt: row.UpdatedAt,
  735. ExtraHeaders: headers,
  736. BodyOverrideMode: row.BodyOverrideMode,
  737. BodyOverride: row.BodyOverride,
  738. CheckMode: defaultCheckModeRepo(row.CheckMode),
  739. DuplicateOperationID: duplicateOperationID,
  740. }
  741. if row.TemplateID != nil {
  742. id := *row.TemplateID
  743. out.TemplateID = &id
  744. }
  745. if row.AccountID != nil {
  746. id := *row.AccountID
  747. out.AccountID = &id
  748. }
  749. return out
  750. }
  751. func channelMonitorHeadersForPersistence(m *service.ChannelMonitor) map[string]string {
  752. if m == nil {
  753. return map[string]string{}
  754. }
  755. headers := make(map[string]string, len(m.ExtraHeaders)+1)
  756. for key, value := range m.ExtraHeaders {
  757. if key == service.ChannelMonitorDuplicateOperationIDMetadataKey {
  758. continue
  759. }
  760. headers[key] = value
  761. }
  762. if operationID := strings.TrimSpace(m.DuplicateOperationID); operationID != "" {
  763. headers[service.ChannelMonitorDuplicateOperationIDMetadataKey] = operationID
  764. }
  765. return headers
  766. }
  767. // emptyHeadersIfNilRepo 与 service.emptyHeadersIfNil 功能一致,
  768. // repo 独立一份避免 import 循环。
  769. func emptyHeadersIfNilRepo(h map[string]string) map[string]string {
  770. if h == nil {
  771. return map[string]string{}
  772. }
  773. return h
  774. }
  775. // defaultBodyModeRepo 空串归一为 off(同上不循环)。
  776. func defaultBodyModeRepo(mode string) string {
  777. if mode == "" {
  778. return "off"
  779. }
  780. return mode
  781. }
  782. func defaultAPIModeRepo(apiMode string) string {
  783. if apiMode == "" {
  784. return "chat_completions"
  785. }
  786. return apiMode
  787. }
  788. // defaultCheckModeRepo 空串归一为 probe(存量行有列默认值,这里兜底防御)。
  789. func defaultCheckModeRepo(checkMode string) string {
  790. if checkMode == "" {
  791. return "probe"
  792. }
  793. return checkMode
  794. }
  795. func emptySliceIfNil(in []string) []string {
  796. if in == nil {
  797. return []string{}
  798. }
  799. return in
  800. }