Files
sub2api/backend/internal/service/channel_monitor_aggregator.go
T
Randark c44711ac98 feat(channel-monitor): quota mode service layer (fetcher + dispatch + repo)
- 常量:MonitorProvider{Antigravity,Kimi,Zhipu,Deepseek}、MonitorCheckMode 三态、
  配额缓存/告警阈值参数;新增 check_mode 相关业务错误
- 校验:monitorProviders(8)/probeCapableProviders(7) 集合替代 adapter 表判定,
  validateCheckMode 矩阵(antigravity 仅 quota);quota 模式 primary_model
  默认占位 "quota",endpoint/api_key 条件必填
- checker:kimi/deepseek 复用 /v1/chat/completions、zhipu 走
  /api/paas/v4/chat/completions;merge 黑名单与 replace 校验同步扩容
- ChannelMonitorQuotaFetcher:窄接口聚合账号侧三个用量服务 + 账号加载,
  归一为 domain.MonitorQuotaSnapshot;成功快照 5min TTL 缓存防配额端点风暴;
  Fetch 永不返回 error(失败降级 Success=false 快照);状态推导
  operational/degraded(≥90% 或余额耗尽/账号未关联)/failed(401/403)/error
- RunCheck 按 check_mode 分派:quota 单行结果、quota_probe 探活+配额挂主行、
  probe 不变;Update 组合校验 + 关联账号复核(probe 自动解绑失效账号);
  Duplicate 拷贝新字段且 quota 模式空明文重加密(防 runner 误停摆)
- repo:Create/Update/entToServiceMonitor 透传 check_mode/account_id;
  InsertHistoryBatch 写 quota;ListLatestForMonitorIDs 裸 SQL 加 quota 列 +
  scanMonitorQuota JSONB 解包(NULL 安全);ListHistory 透传 quota
2026-08-18 04:03:13 +00:00

296 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"log/slog"
)
// 渠道监控聚合层:把 latest + availability 拼成 admin/user 视图所需的 summary / detail。
// 所有方法都遵守"失败仅日志,返回零值"的原则,避免 N+1 查询失败拖垮列表渲染。
// BatchMonitorStatusSummary 批量聚合多个监控的 latest + 7d 可用率(admin/user list 用,消除 N+1)。
// 失败时返回空 map,错误仅日志,不影响列表渲染。
//
// 参数:
// - ids: 要聚合的 monitor ID 列表
// - primaryByID: monitor ID -> primary model(用于读 7d 可用率与 latest 状态)
// - extrasByID: monitor ID -> extra models 列表(用于读 latest 状态填充 ExtraModels)
func (s *ChannelMonitorService) BatchMonitorStatusSummary(
ctx context.Context,
ids []int64,
primaryByID map[int64]string,
extrasByID map[int64][]string,
) map[int64]MonitorStatusSummary {
out := make(map[int64]MonitorStatusSummary, len(ids))
if len(ids) == 0 {
return out
}
latestMap, err := s.repo.ListLatestForMonitorIDs(ctx, ids)
if err != nil {
slog.Warn("channel_monitor: batch load latest failed", "error", err)
latestMap = map[int64][]*ChannelMonitorLatest{}
}
availMap, err := s.repo.ComputeAvailabilityForMonitors(ctx, ids, monitorAvailability7Days)
if err != nil {
slog.Warn("channel_monitor: batch compute availability failed", "error", err)
availMap = map[int64][]*ChannelMonitorAvailability{}
}
for _, id := range ids {
out[id] = buildStatusSummary(
indexLatestByModel(latestMap[id]),
indexAvailabilityByModel(availMap[id]),
primaryByID[id],
extrasByID[id],
)
}
return out
}
// ListUserView 用户只读视图:列出所有 enabled 监控的概览。
// 使用批量聚合接口避免 N+1:
//
// 1 次查 monitors;
// 1 次批量 latest(含 ping_latency_ms);
// 1 次批量 7d availability;
// 1 次批量 timeline(主模型最近 N 条)。
func (s *ChannelMonitorService) ListUserView(ctx context.Context) ([]*UserMonitorView, error) {
monitors, err := s.repo.ListEnabled(ctx)
if err != nil {
return nil, fmt.Errorf("list enabled monitors: %w", err)
}
if len(monitors) == 0 {
return []*UserMonitorView{}, nil
}
ids, primaryByID, extrasByID := collectMonitorIndexes(monitors)
summaries := s.BatchMonitorStatusSummary(ctx, ids, primaryByID, extrasByID)
latestMap := s.batchLatest(ctx, ids)
timelineMap := s.batchTimeline(ctx, ids, primaryByID)
views := make([]*UserMonitorView, 0, len(monitors))
for _, m := range monitors {
primaryLatest := pickLatest(latestMap[m.ID], m.PrimaryModel)
views = append(views, buildUserViewFromSummary(m, summaries[m.ID], primaryLatest, timelineMap[m.ID]))
}
return views, nil
}
// collectMonitorIndexes 把 monitors 列表按 ID 展开为聚合查询所需的三个索引结构。
func collectMonitorIndexes(monitors []*ChannelMonitor) ([]int64, map[int64]string, map[int64][]string) {
ids := make([]int64, 0, len(monitors))
primaryByID := make(map[int64]string, len(monitors))
extrasByID := make(map[int64][]string, len(monitors))
for _, m := range monitors {
ids = append(ids, m.ID)
primaryByID[m.ID] = m.PrimaryModel
extrasByID[m.ID] = m.ExtraModels
}
return ids, primaryByID, extrasByID
}
// batchLatest 批量取 latest per model,失败仅日志(与现有 BatchMonitorStatusSummary 一致,不阻断列表渲染)。
func (s *ChannelMonitorService) batchLatest(ctx context.Context, ids []int64) map[int64][]*ChannelMonitorLatest {
latestMap, err := s.repo.ListLatestForMonitorIDs(ctx, ids)
if err != nil {
slog.Warn("channel_monitor: user view batch latest failed", "error", err)
return map[int64][]*ChannelMonitorLatest{}
}
return latestMap
}
// batchTimeline 批量取每个 monitor 主模型最近 monitorTimelineMaxPoints 条历史。
func (s *ChannelMonitorService) batchTimeline(
ctx context.Context,
ids []int64,
primaryByID map[int64]string,
) map[int64][]*ChannelMonitorHistoryEntry {
timelineMap, err := s.repo.ListRecentHistoryForMonitors(ctx, ids, primaryByID, monitorTimelineMaxPoints)
if err != nil {
slog.Warn("channel_monitor: user view batch timeline failed", "error", err)
return map[int64][]*ChannelMonitorHistoryEntry{}
}
return timelineMap
}
// pickLatest 从 latest 切片中挑出指定 model 对应项,未命中返回 nil。
func pickLatest(rows []*ChannelMonitorLatest, model string) *ChannelMonitorLatest {
if model == "" {
return nil
}
for _, r := range rows {
if r.Model == model {
return r
}
}
return nil
}
// GetUserDetail 用户只读视图:单个监控详情(每个模型 7d/15d/30d 可用率与平均延迟)。
// 不暴露 api_key。
func (s *ChannelMonitorService) GetUserDetail(ctx context.Context, id int64) (*UserMonitorDetail, error) {
m, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if !m.Enabled {
return nil, ErrChannelMonitorNotFound
}
latest, err := s.repo.ListLatestPerModel(ctx, id)
if err != nil {
return nil, fmt.Errorf("list latest per model: %w", err)
}
availMap, err := s.collectAvailabilityWindows(ctx, id)
if err != nil {
return nil, err
}
models := mergeModelDetails(m, latest, availMap)
return &UserMonitorDetail{
ID: m.ID,
Name: m.Name,
Provider: m.Provider,
GroupName: m.GroupName,
Models: models,
}, nil
}
// collectAvailabilityWindows 一次性查询 7/15/30 天三个窗口,按模型组织。
func (s *ChannelMonitorService) collectAvailabilityWindows(ctx context.Context, monitorID int64) (map[int]map[string]*ChannelMonitorAvailability, error) {
out := make(map[int]map[string]*ChannelMonitorAvailability, 3)
windows := []int{monitorAvailability7Days, monitorAvailability15Days, monitorAvailability30Days}
for _, w := range windows {
rows, err := s.repo.ComputeAvailability(ctx, monitorID, w)
if err != nil {
return nil, fmt.Errorf("compute availability %dd: %w", w, err)
}
out[w] = indexAvailabilityByModel(rows)
}
return out, nil
}
// ---------- 纯函数 helper(无 IO,可在 batch / 单 monitor / detail 路径复用)----------
// indexLatestByModel 把 latest 切片按 model 索引(小工具,避免在 hot path 重复写)。
func indexLatestByModel(rows []*ChannelMonitorLatest) map[string]*ChannelMonitorLatest {
m := make(map[string]*ChannelMonitorLatest, len(rows))
for _, r := range rows {
m[r.Model] = r
}
return m
}
// indexAvailabilityByModel 把 availability 切片按 model 索引。
func indexAvailabilityByModel(rows []*ChannelMonitorAvailability) map[string]*ChannelMonitorAvailability {
m := make(map[string]*ChannelMonitorAvailability, len(rows))
for _, r := range rows {
m[r.Model] = r
}
return m
}
// buildStatusSummary 由 latest + availability 字典构造 MonitorStatusSummary。
// 不做任何 IO,纯组装,便于在 batch 与单 monitor 路径复用。
func buildStatusSummary(
latestByModel map[string]*ChannelMonitorLatest,
availByModel map[string]*ChannelMonitorAvailability,
primary string,
extras []string,
) MonitorStatusSummary {
summary := MonitorStatusSummary{ExtraModels: make([]ExtraModelStatus, 0, len(extras))}
if primary != "" {
if l, ok := latestByModel[primary]; ok {
summary.PrimaryStatus = l.Status
summary.PrimaryLatencyMs = l.LatencyMs
// 配额快照只挂主模型行(quota 模式唯一行 / quota_probe 的主行)。
summary.LatestQuota = l.Quota
}
if a, ok := availByModel[primary]; ok {
summary.Availability7d = a.AvailabilityPct
}
}
for _, model := range extras {
entry := ExtraModelStatus{Model: model}
if l, ok := latestByModel[model]; ok {
entry.Status = l.Status
entry.LatencyMs = l.LatencyMs
}
summary.ExtraModels = append(summary.ExtraModels, entry)
}
return summary
}
// buildUserViewFromSummary 用预聚合好的 MonitorStatusSummary + 主模型 latest + timeline 装填 UserMonitorView(无 IO)。
// primaryLatest 可能为 nil(该监控尚无历史);timelineEntries 可能为空。
func buildUserViewFromSummary(
m *ChannelMonitor,
summary MonitorStatusSummary,
primaryLatest *ChannelMonitorLatest,
timelineEntries []*ChannelMonitorHistoryEntry,
) *UserMonitorView {
view := &UserMonitorView{
ID: m.ID,
Name: m.Name,
Provider: m.Provider,
GroupName: m.GroupName,
PrimaryModel: m.PrimaryModel,
PrimaryStatus: summary.PrimaryStatus,
PrimaryLatencyMs: summary.PrimaryLatencyMs,
Availability7d: summary.Availability7d,
ExtraModels: summary.ExtraModels,
Timeline: buildTimelinePoints(timelineEntries),
}
if primaryLatest != nil {
view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs
view.LatestQuota = primaryLatest.Quota
}
return view
}
// buildTimelinePoints 把 history entry 裁剪为 timeline 点(去除 message/ID/Model,减小响应体)。
func buildTimelinePoints(entries []*ChannelMonitorHistoryEntry) []UserMonitorTimelinePoint {
out := make([]UserMonitorTimelinePoint, 0, len(entries))
for _, e := range entries {
out = append(out, UserMonitorTimelinePoint{
Status: e.Status,
LatencyMs: e.LatencyMs,
PingLatencyMs: e.PingLatencyMs,
CheckedAt: e.CheckedAt,
})
}
return out
}
// mergeModelDetails 合并 latest + availability 三个窗口为 ModelDetail 列表。
// 复用 indexLatestByModel,避免在多处重复写 build map 逻辑。
func mergeModelDetails(
m *ChannelMonitor,
latest []*ChannelMonitorLatest,
availMap map[int]map[string]*ChannelMonitorAvailability,
) []ModelDetail {
all := append([]string{m.PrimaryModel}, m.ExtraModels...)
latestByModel := indexLatestByModel(latest)
out := make([]ModelDetail, 0, len(all))
for _, model := range all {
d := ModelDetail{Model: model}
if l, ok := latestByModel[model]; ok {
d.LatestStatus = l.Status
d.LatestLatencyMs = l.LatencyMs
}
if a, ok := availMap[monitorAvailability7Days][model]; ok {
d.Availability7d = a.AvailabilityPct
d.AvgLatency7dMs = a.AvgLatencyMs
}
if a, ok := availMap[monitorAvailability15Days][model]; ok {
d.Availability15d = a.AvailabilityPct
}
if a, ok := availMap[monitorAvailability30Days][model]; ok {
d.Availability30d = a.AvailabilityPct
}
out = append(out, d)
}
return out
}