三处遗留(M37/M38/M39)同落 internal/services/gateway:
① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
(自动抽取路径不放宽)。
② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
原样日志等于把上传内容写进 viewer 可读的日志面。
③ 读侧脱敏走既有 helper,不另写一套:
- adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
- adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
status/耗时/时间/名字一律保住。
测试(每条都验过「删掉实现 ⇒ FAIL」):
- 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
内容走 Store.Add 仍判 temp;
- 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
来源四类);
- 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
- trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。
验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
148 lines
4.9 KiB
Go
148 lines
4.9 KiB
Go
package gateway
|
||
|
||
import (
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// 日志尾部端点的调参。编译期常量(项目规范 §10)。
|
||
const (
|
||
defaultLogLimit = 200
|
||
maxLogLimit = 1000
|
||
)
|
||
|
||
// AdminLogEntry 是 GET /api/v1/admin/observability/logs 中一个条目的
|
||
// 线上结构:{time,level,source,msg}。`time` 是
|
||
// RFC3339 UTC(与 trace/audit 一致);`level` ∈ debug|info|warn|error;`source`
|
||
// 是记录的来源标签,记录未携带时为空。
|
||
type AdminLogEntry struct {
|
||
Time string `json:"time"`
|
||
Level string `json:"level"`
|
||
Source string `json:"source"`
|
||
Msg string `json:"msg"`
|
||
}
|
||
|
||
// SetLogBuffer 注入 GET /api/v1/admin/observability/logs 背后的
|
||
// 进程级日志环形缓冲。组合根在网关存在后调用它
|
||
// (logger 先于网关构建);在那之前该
|
||
// 端点返回 501。并发调用是安全的。
|
||
func (s *Server) SetLogBuffer(buf *logger.RingBuffer) {
|
||
if s == nil || s.obs == nil {
|
||
return
|
||
}
|
||
s.obs.logBuf.Store(buf)
|
||
}
|
||
|
||
// logBuffer 返回已注入的日志环形缓冲,未注入时为 nil。
|
||
func (s *Server) logBuffer() *logger.RingBuffer {
|
||
if s == nil || s.obs == nil {
|
||
return nil
|
||
}
|
||
return s.obs.logBuf.Load()
|
||
}
|
||
|
||
// adminObservabilityLogs 返回最近的后端日志记录,最新
|
||
// 优先,可选按最低级别过滤(管理读)。它是面板日志
|
||
// 尾部背后的后端:记录来自 logger 自身的环形
|
||
// 缓冲,没有任何东西被重新渲染或编造。
|
||
//
|
||
// 读取同时充当实时推送的订阅握手(裁决 05.4.3 + 16.4.3-WS,见
|
||
// observability_push.go):此后新日志由 observability_log 帧推来,
|
||
// 面板不再轮询,分级过滤改在本地进行(帧是广播的,无法按连接分级)。
|
||
func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) {
|
||
s.watchObservability(r)
|
||
minLevel, filtered, ok := parseLogLevel(r.URL.Query().Get("level"))
|
||
if !ok {
|
||
writeError(w, &apperr.AppError{
|
||
Code: "invalid_level",
|
||
Message: "level 只能为 debug/info/warn/error 或留空",
|
||
HTTPCode: http.StatusBadRequest,
|
||
})
|
||
return
|
||
}
|
||
buf := s.logBuffer()
|
||
if buf == nil {
|
||
writeError(w, &apperr.AppError{
|
||
Code: "not_implemented",
|
||
Message: "日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)",
|
||
HTTPCode: http.StatusNotImplemented,
|
||
})
|
||
return
|
||
}
|
||
limit := parseLogLimit(r)
|
||
entries := buf.Snapshot(0)
|
||
out := make([]AdminLogEntry, 0, min(len(entries), limit))
|
||
for _, e := range entries {
|
||
if filtered && logger.ParseLevel(e.Level) < minLevel {
|
||
continue
|
||
}
|
||
out = append(out, adminLogOf(e))
|
||
if len(out) == limit {
|
||
break
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// adminLogOf 把捕获的日志条目映射为驼峰式管理线上结构。
|
||
//
|
||
// 🔴 读侧脱敏(M42 #3):`msg` 是日志正文,也是唯一可能夹带凭据/正文的字段
|
||
// (本仓有过「把上游报文整段打进日志」的先例),因此出面板前过一遍
|
||
// `trace.RedactText` —— 与写侧(trace.JSONRecorder)**同一个函数**,不另写一套;
|
||
// 两套脱敏必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。
|
||
//
|
||
// 排障字段一律保住:`time`(RFC3339)、`level`、`source`(模块来源标签,编译期
|
||
// 常量构成,不含内容)原样透出,且 `msg` 的打码不改变条目条数、排序与级别过滤
|
||
// —— 面板因此照旧可诊断「谁在什么时候按什么级别报了哪一类错」。
|
||
//
|
||
// 注意 `RedactText` 同时按 `maxSummaryRunes` 截断(与出图/落盘同一口径):超长
|
||
// 正文会带省略号。这是复用同一个 helper 的既定代价,远小于「凭据原样上面板」。
|
||
func adminLogOf(e logger.Entry) AdminLogEntry {
|
||
return AdminLogEntry{
|
||
Time: e.Time.UTC().Format(time.RFC3339),
|
||
Level: e.Level,
|
||
Source: e.Source,
|
||
Msg: trace.RedactText(e.Msg),
|
||
}
|
||
}
|
||
|
||
// parseLogLevel 解析 ?level= 过滤器。空值关闭过滤
|
||
// (filtered=false);debug/info/warn/error 选中该级别及以上;其他任何
|
||
// 取值都以 ok=false 拒绝,使 handler 返回 400。
|
||
func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "":
|
||
return 0, false, true
|
||
case "debug":
|
||
return slog.LevelDebug, true, true
|
||
case "info":
|
||
return slog.LevelInfo, true, true
|
||
case "warn":
|
||
return slog.LevelWarn, true, true
|
||
case "error":
|
||
return slog.LevelError, true, true
|
||
default:
|
||
return 0, false, false
|
||
}
|
||
}
|
||
|
||
// parseLogLimit 解析 ?limit= 查询参数,夹取到 [1, maxLogLimit] 并在
|
||
// 缺失或非法时选用 defaultLogLimit。
|
||
func parseLogLimit(r *http.Request) int {
|
||
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||
if err != nil || n <= 0 {
|
||
return defaultLogLimit
|
||
}
|
||
if n > maxLogLimit {
|
||
return maxLogLimit
|
||
}
|
||
return n
|
||
}
|