Files
LokiStack/backend/routers/router_test.go
T
opsx 7be41f0a6b
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
feat(aiops): 新增「智能运维」——AI 日志分析(P0,不扫日志内容)
=== 为什么这样设计(成本优先)===
全集群 370GB/天,把日志原文丢给模型是不可能的(一次几百万 token)。
所以整条链路只做一件事:**把 GB 级日志压成 KB 级信号**。

P0 的信号来源全部是索引/快照,**零日志内容扫描**:
  体积 + 趋势   → index/volume_range(实测 24h 仅 54ms)
  条数/流数     → index/stats,且只在 ≤2 小时窗口用
                  (全天 index/stats 实测 20s,绝不能进 AI 路径)
  应用排行      → 平台小时级预聚合快照(毫秒)
  基线对比      → 上一个等长窗口的 volume_range
  告警          → 平台投递记录
实测一次分析:3~6 秒、输入 600~1400 tokens(约 ¥0.005/次)。

=== 后端 ===
models/ai.go            AIConfig(APIKey 加密存储)+ AIAnalysisRun(审计)
services/ai_config.go   读写配置、掩码返回、每日 token 用量统计
services/ai_provider.go OpenAI 兼容客户端 + 连通性测试
                        (DeepSeek/通义/内网 vLLM/Ollama 通用)
services/ai_redact.go   脱敏:凭据/邮箱/手机号/身份证 → 占位符,
                        **保留内网 IP 与端口**(排查必需)
services/ai_signal.go   信号采集 + 渲染成紧凑文本(省 token)
services/ai_analyze.go  结构化输出解析、配额闸门、审计落库
controllers/ai.go       /ai/config /ai/config/test /ai/analyze /ai/runs

成本闸门(三层):
  1) 单次:时间范围 ≤24h、maxTokens ≤1500
  2) 每用户每日 token 上限(默认 50 万,可配,超限直接拒绝并说明)
  3) 审计只留最近 500 条,避免表膨胀

=== 前端 ===
新页面 /aiops(侧栏「智能运维」,管理员),页内单层 tab:
  日志分析 / 模型配置 / 分析历史
- 分析:数据源 + 时间预设(15m/1h/6h/24h) + 可选提问 → 结论/证据/建议/不确定 四区
       顶部显示本次消耗(in/out tokens、耗时),可查看「发给模型的完整内容」
- 配置:启用开关、Base URL、模型、API Key(掩码)、温度、输出上限、超时、
       每日配额、额外脱敏正则;按钮顺序改为「测试连接 → 保存配置」
- 历史:服务端分页(20/页),点行看详情与 payload

=== 两个真实踩坑(已修)===
1) 密码含 @ 导致脱敏漏掉(mysql://user:P@ssw0rd@host)
   → URL basic-auth 规则改为贪婪匹配到最后一个 @,由单测锁定
2) 结论里的时间是 UTC(容器无 tzdata,time.Local=UTC),
   模型写"凌晨 6 点下跌"而运维当时是下午 2 点
   → 显式用东八区展示(LOKISTACK_DISPLAY_TZ=UTC 可覆盖)

=== 实测(69147008fcf4)===
  配置保存 → key 掩码 sk-***e286;连通性测试返回「正常」
  分析 15 分钟窗口:4.1s,结论识别出"窗口末段体积断崖约 -60%"
  信号时间 14:23~14:38(本地时间正确)
  历史分页 total=3,tokens/耗时字段完整
  单测:脱敏 2 个用例 + 模型输出解析 4 个用例 + 6 条 AI 路由断言
2026-10-31 06:39:01 +00:00

96 lines
3.2 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 routers
import (
"testing"
"github.com/gin-gonic/gin"
)
// 回归:接口的 **HTTP 方法与路径** 必须和前端调用一致。
//
// 线上事故:一次模糊匹配的 patch 把
//
// logs.POST("/histogram", ...) → logs.GET("/histogram", ...)
//
// 前端 logsApi.histogram 用的是 POST(body 里带 sourceId/query/start/end),
// 于是所有直方图请求 404:
// - 日志查询页:直方图不显示
// - 日志分析页:直接报 "API endpoint not found",整页不可用
//
// 这类"方法写错"编译期完全查不出来,只能靠路由表断言。
func TestCriticalRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := SetupRouter()
// 期望的 method + path(注意 gin 会把 :param 保留为 :id)
expect := []struct{ method, path string }{
// 日志查询 / 分析页的数据来源:必须是 POST(前端传 body)
{"POST", "/api/v1/logs/histogram"},
{"POST", "/api/v1/logs/stats"},
{"POST", "/api/v1/logs/metrics"},
{"POST", "/api/v1/logs/query"},
{"POST", "/api/v1/logs/context"},
// 查询辅助接口:GET + query string
{"GET", "/api/v1/logs/sources"},
{"GET", "/api/v1/logs/labels"},
{"GET", "/api/v1/logs/label/:name/values"},
// 批量 label 值(下拉联动一次请求)—— 路由写错就是前端全部下拉空
{"GET", "/api/v1/logs/label-values"},
{"GET", "/api/v1/logs/my-scope"},
{"GET", "/api/v1/logs/daily-volume"},
{"GET", "/api/v1/logs/health"},
// 告警
{"GET", "/api/v1/alerts/rules"},
{"POST", "/api/v1/alerts/rules"},
{"GET", "/api/v1/alerts/channels"},
{"POST", "/api/v1/alerts/check"},
{"GET", "/api/v1/alerts/deliveries"},
// 审计 / 自检
{"GET", "/api/v1/audit-logs"},
{"GET", "/api/v1/system/self-check"},
// 设置类:保留期必须同时有读和写(写要管理员)
{"GET", "/api/v1/settings/retention"},
{"PUT", "/api/v1/settings/retention"},
// AIOps:模型配置(管理员)+ 分析 + 审计历史
{"GET", "/api/v1/ai/config"},
{"PUT", "/api/v1/ai/config"},
{"POST", "/api/v1/ai/config/test"},
{"POST", "/api/v1/ai/analyze"},
{"GET", "/api/v1/ai/runs"},
{"GET", "/api/v1/ai/runs/:id"},
// 备份恢复接口已在上游被移除(commit f87fb4b),这里不再断言
}
registered := map[string]bool{}
for _, route := range engine.Routes() {
registered[route.Method+" "+route.Path] = true
}
for _, item := range expect {
key := item.method + " " + item.path
if !registered[key] {
t.Errorf("缺少路由: %s(前端按这个方法和路径调用,缺了就是 404)", key)
}
}
}
// 回归:前端用 POST 的接口不能只有 GET 版本。
// 单独断言一次,避免以后又有人"顺手"把 POST 改成 GET。
func TestHistogramAcceptsPost(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := SetupRouter()
methods := map[string]bool{}
for _, route := range engine.Routes() {
if route.Path == "/api/v1/logs/histogram" {
methods[route.Method] = true
}
}
if !methods["POST"] {
t.Fatal("POST /api/v1/logs/histogram 未注册:前端 logsApi.histogram 用 POST,会 404")
}
if !methods["GET"] {
t.Fatal("GET /api/v1/logs/histogram 未注册:curl 调试入口丢了")
}
}