84463394d4
清单里那批小毛病,逐条复核后修(「审计详情列缺失」那条已不成立,早补上了)。
1. 审计筛选只在当前页生效 —— 影响最大的一条。分页是服务端的,筛选却在
前端对已取回的 50 条做,于是搜一个用户 ID 显示"无结果"时,后面几页
可能还有几百条。审计的用途就是查证,"搜不到"会被读成"没发生过"。
改为 action/path/q 三个条件全部落到 SQL,前端只管发条件(防抖 300ms)。
2. 服务状态页把 mcp-go/mcp-py 的工具数写死成 23/4 —— 增删工具后一直骗人,
且服务离线时照样显示,看不出工具其实一个都没注册上。改取实际上报值。
3. 模型删除一点即删,无任何确认。补二次确认,并对"正在使用中"的模型
单独说明后果(删掉会立刻打断线上对话/向量能力)。
审计筛选补了 4 组回归测试,两条是踩出来的坑:
- q 的 OR 组必须带括号:gorm 以 AND 拼接各 Where,裸 OR 会让 action
条件被绕过(测试里用"同 IP 不同方法"两行钉死这个语义);
- LIKE 必须显式写 ESCAPE '\':Postgres 默认拿反斜杠当转义符,SQLite
不写就没有转义符——原来的写法在单测里静默失效,搜 "100%" 命中 0 条。
顺带把 ILIKE 换成 LOWER()+LIKE,这段才能被内存库覆盖。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
3.3 KiB
Go
89 lines
3.3 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
)
|
||
|
||
// AppendAudit 追加一条审计留痕(best-effort:审计失败不应影响主流程,调用方忽略返回)。
|
||
func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error {
|
||
if p.db == nil || a == nil {
|
||
return errStoreDisabled
|
||
}
|
||
return p.db.WithContext(ctx).Create(a).Error
|
||
}
|
||
|
||
// AuditFilter 是审计流的服务端筛选条件。筛选必须落到 SQL:审计的用途是查证,
|
||
// 若只在“当前页”里筛,搜不到就等于给出“没有这条记录”的错误结论。
|
||
type AuditFilter struct {
|
||
Action string // HTTP 方法,精确匹配
|
||
Path string // 路径前缀
|
||
Q string // 跨 actor / ip / detail / path 的模糊匹配
|
||
}
|
||
|
||
// escapeLike 转义 LIKE 的通配符,让用户输入的 % 和 _ 按字面量匹配
|
||
// (否则搜 "100%" 会退化成匹配任意串)。
|
||
// 配套的 SQL 必须显式写 ESCAPE '\\':Postgres 默认就拿反斜杠当转义符,但 SQLite 不写
|
||
// ESCAPE 就压根没有转义符——依赖隐式默认会在换库/单测时静默失效。
|
||
func escapeLike(s string) string {
|
||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||
}
|
||
|
||
// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页,筛选走 SQL)。
|
||
func (p *Postgres) ListAudit(ctx context.Context, limit, offset int, f AuditFilter) ([]AuditLog, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
limit, offset = clampPage(limit, offset)
|
||
q := p.db.WithContext(ctx).Model(&AuditLog{})
|
||
if f.Action != "" {
|
||
q = q.Where("action = ?", f.Action)
|
||
}
|
||
if f.Path != "" {
|
||
q = q.Where(`path LIKE ? ESCAPE '\'`, escapeLike(f.Path)+"%")
|
||
}
|
||
if f.Q != "" {
|
||
like := "%" + strings.ToLower(escapeLike(f.Q)) + "%"
|
||
// 用 LOWER()+LIKE 而非 Postgres 专有的 ILIKE:语义一样,但 SQLite 也支持,
|
||
// 于是这段能被内存库单测覆盖(审计搜索量小,放弃索引可忽略)。
|
||
// 括号不能省:gorm 把每个 Where 以 AND 拼接,裸的 OR 串会让优先级变成
|
||
// `action = ? AND actor LIKE ? OR ip LIKE ? ...`,前面的条件直接失效。
|
||
q = q.Where(`(LOWER(actor) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\' `+
|
||
`OR LOWER(detail) LIKE ? ESCAPE '\' OR LOWER(path) LIKE ? ESCAPE '\')`,
|
||
like, like, like, like)
|
||
}
|
||
var out []AuditLog
|
||
err := q.Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error
|
||
return out, err
|
||
}
|
||
|
||
// AppendGuardrailEvent 追加一条护栏命中(best-effort)。
|
||
func (p *Postgres) AppendGuardrailEvent(ctx context.Context, e *GuardrailEvent) error {
|
||
if p.db == nil || e == nil {
|
||
return errStoreDisabled
|
||
}
|
||
return p.db.WithContext(ctx).Create(e).Error
|
||
}
|
||
|
||
// ListGuardrailEvents 倒序列出护栏事件(管理端安全事件流)。
|
||
func (p *Postgres) ListGuardrailEvents(ctx context.Context, limit, offset int) ([]GuardrailEvent, error) {
|
||
if p.db == nil {
|
||
return nil, errStoreDisabled
|
||
}
|
||
limit, offset = clampPage(limit, offset)
|
||
var out []GuardrailEvent
|
||
err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error
|
||
return out, err
|
||
}
|
||
|
||
// clampPage 归一分页参数:limit ∈ [1,200](默认 50),offset ≥ 0。
|
||
func clampPage(limit, offset int) (int, int) {
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
return limit, offset
|
||
}
|