channel_monitor_aggregator.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. )
  7. // 渠道监控聚合层:把 latest + availability 拼成 admin/user 视图所需的 summary / detail。
  8. // 所有方法都遵守"失败仅日志,返回零值"的原则,避免 N+1 查询失败拖垮列表渲染。
  9. // BatchMonitorStatusSummary 批量聚合多个监控的 latest + 7d 可用率(admin/user list 用,消除 N+1)。
  10. // 失败时返回空 map,错误仅日志,不影响列表渲染。
  11. //
  12. // 参数:
  13. // - ids: 要聚合的 monitor ID 列表
  14. // - primaryByID: monitor ID -> primary model(用于读 7d 可用率与 latest 状态)
  15. // - extrasByID: monitor ID -> extra models 列表(用于读 latest 状态填充 ExtraModels)
  16. func (s *ChannelMonitorService) BatchMonitorStatusSummary(
  17. ctx context.Context,
  18. ids []int64,
  19. primaryByID map[int64]string,
  20. extrasByID map[int64][]string,
  21. ) map[int64]MonitorStatusSummary {
  22. out := make(map[int64]MonitorStatusSummary, len(ids))
  23. if len(ids) == 0 {
  24. return out
  25. }
  26. latestMap, err := s.repo.ListLatestForMonitorIDs(ctx, ids)
  27. if err != nil {
  28. slog.Warn("channel_monitor: batch load latest failed", "error", err)
  29. latestMap = map[int64][]*ChannelMonitorLatest{}
  30. }
  31. availMap, err := s.repo.ComputeAvailabilityForMonitors(ctx, ids, monitorAvailability7Days)
  32. if err != nil {
  33. slog.Warn("channel_monitor: batch compute availability failed", "error", err)
  34. availMap = map[int64][]*ChannelMonitorAvailability{}
  35. }
  36. for _, id := range ids {
  37. out[id] = buildStatusSummary(
  38. indexLatestByModel(latestMap[id]),
  39. indexAvailabilityByModel(availMap[id]),
  40. primaryByID[id],
  41. extrasByID[id],
  42. )
  43. }
  44. return out
  45. }
  46. // ListUserView 用户只读视图:列出所有 enabled 监控的概览。
  47. // 使用批量聚合接口避免 N+1:
  48. //
  49. // 1 次查 monitors;
  50. // 1 次批量 latest(含 ping_latency_ms);
  51. // 1 次批量 7d availability;
  52. // 1 次批量 timeline(主模型最近 N 条)。
  53. func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonitorView, error) {
  54. monitors, err := s.repo.ListEnabled(ctx)
  55. if err != nil {
  56. return nil, fmt.Errorf("list enabled monitors: %w", err)
  57. }
  58. if len(monitors) == 0 {
  59. return []*UserMonitorView{}, nil
  60. }
  61. ids, primaryByID, extrasByID := collectMonitorIndexes(monitors)
  62. summaries := s.BatchMonitorStatusSummary(ctx, ids, primaryByID, extrasByID)
  63. latestMap := s.batchLatest(ctx, ids)
  64. timelineMap := s.batchTimeline(ctx, ids, primaryByID)
  65. views := make([]*UserMonitorView, 0, len(monitors))
  66. for _, m := range monitors {
  67. primaryLatest := pickLatest(latestMap[m.ID], m.PrimaryModel)
  68. views = append(views, buildUserViewFromSummary(m, summaries[m.ID], primaryLatest, timelineMap[m.ID]))
  69. }
  70. return views, nil
  71. }
  72. // collectMonitorIndexes 把 monitors 列表按 ID 展开为聚合查询所需的三个索引结构。
  73. func collectMonitorIndexes(monitors []*ChannelMonitor) ([]int64, map[int64]string, map[int64][]string) {
  74. ids := make([]int64, 0, len(monitors))
  75. primaryByID := make(map[int64]string, len(monitors))
  76. extrasByID := make(map[int64][]string, len(monitors))
  77. for _, m := range monitors {
  78. ids = append(ids, m.ID)
  79. primaryByID[m.ID] = m.PrimaryModel
  80. extrasByID[m.ID] = m.ExtraModels
  81. }
  82. return ids, primaryByID, extrasByID
  83. }
  84. // batchLatest 批量取 latest per model,失败仅日志(与现有 BatchMonitorStatusSummary 一致,不阻断列表渲染)。
  85. func (s *ChannelMonitorService) batchLatest(ctx context.Context, ids []int64) map[int64][]*ChannelMonitorLatest {
  86. latestMap, err := s.repo.ListLatestForMonitorIDs(ctx, ids)
  87. if err != nil {
  88. slog.Warn("channel_monitor: user view batch latest failed", "error", err)
  89. return map[int64][]*ChannelMonitorLatest{}
  90. }
  91. return latestMap
  92. }
  93. // batchTimeline 批量取每个 monitor 主模型最近 monitorTimelineMaxPoints 条历史。
  94. func (s *ChannelMonitorService) batchTimeline(
  95. ctx context.Context,
  96. ids []int64,
  97. primaryByID map[int64]string,
  98. ) map[int64][]*ChannelMonitorHistoryEntry {
  99. timelineMap, err := s.repo.ListRecentHistoryForMonitors(ctx, ids, primaryByID, monitorTimelineMaxPoints)
  100. if err != nil {
  101. slog.Warn("channel_monitor: user view batch timeline failed", "error", err)
  102. return map[int64][]*ChannelMonitorHistoryEntry{}
  103. }
  104. return timelineMap
  105. }
  106. // pickLatest 从 latest 切片中挑出指定 model 对应项,未命中返回 nil。
  107. func pickLatest(rows []*ChannelMonitorLatest, model string) *ChannelMonitorLatest {
  108. if model == "" {
  109. return nil
  110. }
  111. for _, r := range rows {
  112. if r.Model == model {
  113. return r
  114. }
  115. }
  116. return nil
  117. }
  118. // GetUserDetail 用户只读视图:单个监控详情(每个模型 7d/15d/30d 可用率与平均延迟)。
  119. // 不暴露 api_key。
  120. func (s *ChannelMonitorService) GetUserDetail(ctx context.Context, id int64) (*UserMonitorDetail, error) {
  121. m, err := s.repo.GetByID(ctx, id)
  122. if err != nil {
  123. return nil, err
  124. }
  125. if !m.Enabled {
  126. return nil, ErrChannelMonitorNotFound
  127. }
  128. latest, err := s.repo.ListLatestPerModel(ctx, id)
  129. if err != nil {
  130. return nil, fmt.Errorf("list latest per model: %w", err)
  131. }
  132. availMap, err := s.collectAvailabilityWindows(ctx, id)
  133. if err != nil {
  134. return nil, err
  135. }
  136. models := mergeModelDetails(m, latest, availMap)
  137. return &UserMonitorDetail{
  138. ID: m.ID,
  139. Name: m.Name,
  140. Provider: m.Provider,
  141. GroupName: m.GroupName,
  142. Models: models,
  143. }, nil
  144. }
  145. // collectAvailabilityWindows 一次性查询 7/15/30 天三个窗口,按模型组织。
  146. func (s *ChannelMonitorService) collectAvailabilityWindows(ctx context.Context, monitorID int64) (map[int]map[string]*ChannelMonitorAvailability, error) {
  147. out := make(map[int]map[string]*ChannelMonitorAvailability, 3)
  148. windows := []int{monitorAvailability7Days, monitorAvailability15Days, monitorAvailability30Days}
  149. for _, w := range windows {
  150. rows, err := s.repo.ComputeAvailability(ctx, monitorID, w)
  151. if err != nil {
  152. return nil, fmt.Errorf("compute availability %dd: %w", w, err)
  153. }
  154. out[w] = indexAvailabilityByModel(rows)
  155. }
  156. return out, nil
  157. }
  158. // ---------- 纯函数 helper(无 IO,可在 batch / 单 monitor / detail 路径复用)----------
  159. // indexLatestByModel 把 latest 切片按 model 索引(小工具,避免在 hot path 重复写)。
  160. func indexLatestByModel(rows []*ChannelMonitorLatest) map[string]*ChannelMonitorLatest {
  161. m := make(map[string]*ChannelMonitorLatest, len(rows))
  162. for _, r := range rows {
  163. m[r.Model] = r
  164. }
  165. return m
  166. }
  167. // indexAvailabilityByModel 把 availability 切片按 model 索引。
  168. func indexAvailabilityByModel(rows []*ChannelMonitorAvailability) map[string]*ChannelMonitorAvailability {
  169. m := make(map[string]*ChannelMonitorAvailability, len(rows))
  170. for _, r := range rows {
  171. m[r.Model] = r
  172. }
  173. return m
  174. }
  175. // buildStatusSummary 由 latest + availability 字典构造 MonitorStatusSummary。
  176. // 不做任何 IO,纯组装,便于在 batch 与单 monitor 路径复用。
  177. func buildStatusSummary(
  178. latestByModel map[string]*ChannelMonitorLatest,
  179. availByModel map[string]*ChannelMonitorAvailability,
  180. primary string,
  181. extras []string,
  182. ) MonitorStatusSummary {
  183. summary := MonitorStatusSummary{ExtraModels: make([]ExtraModelStatus, 0, len(extras))}
  184. if primary != "" {
  185. if l, ok := latestByModel[primary]; ok {
  186. summary.PrimaryStatus = l.Status
  187. summary.PrimaryLatencyMs = l.LatencyMs
  188. // 配额快照只挂主模型行(quota 模式唯一行 / quota_probe 的主行)。
  189. summary.LatestQuota = l.Quota
  190. }
  191. if a, ok := availByModel[primary]; ok {
  192. summary.Availability7d = a.AvailabilityPct
  193. }
  194. }
  195. for _, model := range extras {
  196. entry := ExtraModelStatus{Model: model}
  197. if l, ok := latestByModel[model]; ok {
  198. entry.Status = l.Status
  199. entry.LatencyMs = l.LatencyMs
  200. }
  201. summary.ExtraModels = append(summary.ExtraModels, entry)
  202. }
  203. return summary
  204. }
  205. // buildUserViewFromSummary 用预聚合好的 MonitorStatusSummary + 主模型 latest + timeline 装填 UserMonitorView(无 IO)。
  206. // primaryLatest 可能为 nil(该监控尚无历史);timelineEntries 可能为空。
  207. func buildUserViewFromSummary(
  208. m *ChannelMonitor,
  209. summary MonitorStatusSummary,
  210. primaryLatest *ChannelMonitorLatest,
  211. timelineEntries []*ChannelMonitorHistoryEntry,
  212. ) *UserMonitorView {
  213. view := &UserMonitorView{
  214. ID: m.ID,
  215. Name: m.Name,
  216. Provider: m.Provider,
  217. GroupName: m.GroupName,
  218. PrimaryModel: m.PrimaryModel,
  219. PrimaryStatus: summary.PrimaryStatus,
  220. PrimaryLatencyMs: summary.PrimaryLatencyMs,
  221. Availability7d: summary.Availability7d,
  222. ExtraModels: summary.ExtraModels,
  223. Timeline: buildTimelinePoints(timelineEntries),
  224. }
  225. if primaryLatest != nil {
  226. view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs
  227. view.LatestQuota = primaryLatest.Quota
  228. }
  229. return view
  230. }
  231. // buildTimelinePoints 把 history entry 裁剪为 timeline 点(去除 message/ID/Model,减小响应体)。
  232. func buildTimelinePoints(entries []*ChannelMonitorHistoryEntry) []UserMonitorTimelinePoint {
  233. out := make([]UserMonitorTimelinePoint, 0, len(entries))
  234. for _, e := range entries {
  235. out = append(out, UserMonitorTimelinePoint{
  236. Status: e.Status,
  237. LatencyMs: e.LatencyMs,
  238. PingLatencyMs: e.PingLatencyMs,
  239. CheckedAt: e.CheckedAt,
  240. })
  241. }
  242. return out
  243. }
  244. // mergeModelDetails 合并 latest + availability 三个窗口为 ModelDetail 列表。
  245. // 复用 indexLatestByModel,避免在多处重复写 build map 逻辑。
  246. func mergeModelDetails(
  247. m *ChannelMonitor,
  248. latest []*ChannelMonitorLatest,
  249. availMap map[int]map[string]*ChannelMonitorAvailability,
  250. ) []ModelDetail {
  251. all := append([]string{m.PrimaryModel}, m.ExtraModels...)
  252. latestByModel := indexLatestByModel(latest)
  253. out := make([]ModelDetail, 0, len(all))
  254. for _, model := range all {
  255. d := ModelDetail{Model: model}
  256. if l, ok := latestByModel[model]; ok {
  257. d.LatestStatus = l.Status
  258. d.LatestLatencyMs = l.LatencyMs
  259. }
  260. if a, ok := availMap[monitorAvailability7Days][model]; ok {
  261. d.Availability7d = a.AvailabilityPct
  262. d.AvgLatency7dMs = a.AvgLatencyMs
  263. }
  264. if a, ok := availMap[monitorAvailability15Days][model]; ok {
  265. d.Availability15d = a.AvailabilityPct
  266. }
  267. if a, ok := availMap[monitorAvailability30Days][model]; ok {
  268. d.Availability30d = a.AvailabilityPct
  269. }
  270. out = append(out, d)
  271. }
  272. return out
  273. }