feat(traffic): 正文入库 + 全文搜索,删除改为 SQL 事务

流量录制不再镜像 URL 路径写文件树,改为整条 exchange 落 SQLite:

- record: 元数据/头/正文写库;>256KB 的 body 才溢出到 _blobs(单层
  桶、按 hash 去重),库里留 8KB 预览 + 指针。文本正文全量喂 trigram
  FTS 索引(二进制按 content-type 跳过),支持任意子串与中文全文搜索。
- 删除退化为一个 SQL 事务级联删四张表,host 树后台异步回收,不再在
  写锁内 RemoveAll + 全树扫描——删除从数小时降到毫秒级,期间不再停摆录制。
- gcBlobs 改走 blob_refs 反查,顺带清理空桶目录;保留对旧文件树的
  引用扫描与 Get 回落,历史数据免迁移仍可读、可搜(元数据)、可删。
- 新增 traffic_blob 工具与 GET /api/traffic/blob 流式下载大 body;
  traffic_search 增加 body_contains 全文参数。

新表均为 CREATE TABLE IF NOT EXISTS,旧库自动补建,无需 ALTER 迁移。

Claude-Session: https://claude.ai/code/session_01RtPQYJ8W1mcaace3sHWGDm
This commit is contained in:
Autumn-27 committed 2026-08-22 11:36:58 -04:00
1 parent 22ae280584
commit fb666c03cc
4 files changed
+1174 -193

No files matched your search

+30
View File
@@ -717,6 +717,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/traffic", s.deleteTraffic)
mux.HandleFunc("DELETE /api/traffic/hosts", s.deleteTrafficHosts)
mux.HandleFunc("GET /api/traffic/exchange", s.getTrafficExchange)
mux.HandleFunc("GET /api/traffic/blob", s.getTrafficBlob)
mux.HandleFunc("GET /api/commands", s.pgListCommands)
mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords)
mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords)
@@ -2934,6 +2935,35 @@ func (s *Server) getTrafficExchange(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"req": req, "resp": resp})
}
// getTrafficBlob streams one oversized body by its sha256. Bodies past the inline
// threshold are not carried by the exchange endpoint — it returns a preview plus
// an "@blob sha256:<hash>" pointer — so this is how the UI fetches them whole.
// Streamed rather than buffered: these are the bodies too large to hold in memory.
func (s *Server) getTrafficBlob(w http.ResponseWriter, r *http.Request) {
tr := s.m.Traffic()
if tr == nil {
writeErr(w, 404, "traffic disabled")
return
}
hash := strings.TrimSpace(r.URL.Query().Get("hash"))
if hash == "" {
writeErr(w, 400, "missing hash")
return
}
f, size, err := tr.Blob(hash)
if err != nil {
writeErr(w, 404, err.Error())
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", hash+".bin"))
if _, err := io.Copy(w, f); err != nil {
log.Printf("[traffic] 下载 blob %s 中断:%v", hash, err)
}
}
// getSettings returns the runtime app settings the UI toggles. The Brave API key
// is returned as a boolean presence flag (brave_key_set), never the value itself,
// so the UI can show "configured" without echoing the secret back.