Files
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

65 lines
2.4 KiB
Go
Raw Permalink 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 services
import (
"regexp"
"strings"
)
// 脱敏:日志里经常夹带凭据,送给外部模型前必须处理。
//
// 原则:
// - 宁可多脱一点,也不能漏掉凭据;
// - 但**不要脱掉排查必需的信息**:内网 IP、端口、主机名、命名空间、
// Pod 名、错误码都要保留,否则模型给不出有用结论。
// - 每条规则都替换成带类型的占位符(<IP> / <TOKEN>),
// 这样模型仍能看出"这里原本是个 token",语义不丢。
type redactRule struct {
pattern *regexp.Regexp
replacement string
}
var builtinRedactRules = []redactRule{
// Bearer / JWT —— 一定是凭据
{regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9\-._~+/]{12,}=*`), "Bearer <TOKEN>"},
{regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}`), "<JWT>"},
// key=value 形式的敏感字段
{regexp.MustCompile(`(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|secret[_-]?key)\b\s*[=:]\s*("?[^\s"',;]{4,}"?)`), "$1=<REDACTED>"},
// URL 里的 basic auth:scheme://user:pass@host
// 密码里可能含 @(如 P@ssw0rd),所以取到**最后一个 @** 之前的所有内容:
// 用贪婪 [^\s/]+ 再回溯到最后一个 @,能正确切出 password 与 host。
{regexp.MustCompile(`://([^:/\s]+):([^\s/]+)@`), "://$1:<REDACTED>@"},
// AK/SK 形式(云厂商常见 16~40 位字母数字混合,带已知前缀)
{regexp.MustCompile(`\b(AKIA|LTAI|AKID)[A-Za-z0-9]{12,}\b`), "<CLOUD_AK>"},
// 手机号 / 身份证
{regexp.MustCompile(`\b1[3-9]\d{9}\b`), "<PHONE>"},
{regexp.MustCompile(`\b\d{17}[\dXx]\b`), "<ID_CARD>"},
// 邮箱
{regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`), "<EMAIL>"},
}
// RedactText 对文本做脱敏,extra 是用户在设置里加的额外正则。
//
// 额外正则写错(如括号不闭合)不能让整个分析失败:静默跳过,
// 但上层会把这些失效规则一并提示给用户。
func RedactText(text string, extra []string) (string, []string) {
out := text
for _, rule := range builtinRedactRules {
out = rule.pattern.ReplaceAllString(out, rule.replacement)
}
var invalid []string
for _, raw := range extra {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
re, err := regexp.Compile(raw)
if err != nil {
invalid = append(invalid, raw)
continue
}
out = re.ReplaceAllString(out, "<REDACTED>")
}
return out, invalid
}