package gateway import ( "errors" "fmt" "io" "net/http" "os" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" 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/memory" ) // 本文件承载记忆整理「面板调优」的**管理读写端点**(契约待塔登记,见回报): // // GET /api/v1/admin/memories/tidy/tuning 读:盘上的 7 键值 + 取值规则 // PUT /api/v1/admin/memories/tidy/tuning 写:三态补丁 + 原子落盘 + 热生效 // // 它回答的是主人的两句原话(逐字,见 M4 mission Context): // // 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走 // 面板配置文件。这些键优先从面板配置上取」 // 「频率必须调 可以调 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到了 // 静默期稍微长一点」 // // 此前 `$DATA_DIR/config/memory-tidy.json` **只有读路径**(`cmd/cyrene/wire_memory_tidy.go` // 在装配期读一次 + 启动播种):主人要改频率只能 SSH 改文件 + 重启后端。本切片把 // 「读回来 → 改 → 落盘」这条面板路径补上,**零新 env 键**(`.env` 已冻结, // design/13 §2.1)、**不新开文件家族**。 // // 四条不可走样的口径: // // 1. **三态**(硬要求,与契约里 `plugin.saveProvider` 同族语义):键**缺位** ⇒ 不动; // **合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**。 // `null` 是这一族的清除哨兵(= 删掉该键、回到下一档兜底)。三态的实现与 // 「非法值不落盘」的证据在 `admin_memory_tuning_file.go` 的 // `parseMemoryTidyTuningPatch`(解析阶段就拒,落盘只在解析成功之后发生)。 // 2. **写端点带审计**(动作 `memory.tidy.tuning`),且**不改** `.env`、不重启后端。 // 3. **热生效**:保存后把新值当场叠到运行期(可选能力,见 MemoryTidyTuningApplier)。 // 能力不存在或失败时,响应 `hot:false` + `hotError`,面板**如实**说「已落盘、 // 要重启后端」——绝不把「落盘了」说成「生效了」。 // 4. **诚实回话**:写成功后的响应是**重新读盘**的结果(真值回显),不是「我以为 // 写成了什么」;盘上文件解不开时读/写**都**拒绝(`409 tuning_corrupt`)并说明 // 运行期此刻正回落 `.env`/代码默认。 // // 权限:GET 与 PUT 都走 **writeAuth(owner|admin)**(与本族其它记忆端点 // `/admin/memories`、`POST /admin/memories/{id}/revert` 同档)——它是私有记忆面的 // 运行期旋钮,不是给 viewer 的监控投影。 // memoryTuningLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。 const memoryTuningLogSource = "gateway/memory-tuning" // memoryTuningAuditAction 是写端点落审计的动作名(与 memory.tidy.apply / // memory.tidy.reject 同一族命名)。 const memoryTuningAuditAction = "memory.tidy.tuning" // memoryTuningMaxBodyBytes 是写端点请求体的墙钟上限。这个文件是给人改的小配置 // (11 个标量键),正常请求体不到 200 字节;给上限只为让畸形/超大请求不占内存, // 不是业务上限。 const memoryTuningMaxBodyBytes = 16 << 10 // MemoryTidyTuningApplier 是「把面板值当场叠到**整理自动档**」的可选能力。 // // 它**不是**必需依赖:组合根注入的整理服务(`deps.MemoryTidy` = `*memory.TidyService`) // 实现了它 ⇒ 保存后立刻生效;没实现 ⇒ 值照旧落盘、但要等后端重启,响应里 // `hot:false` 如实说出来(口径 3)。 // // base 是**`.env` 层**(由 `memory.TidyConfigFromConfig` 从进程生效配置投影而来, // 与 wireMemoryTidy 用的是同一个唯一实现点);panel 是刚落盘的那一份。实现必须按 // 「面板 > `.env` > 代码默认」逐键重算,且**缺位键保持 base 的值** —— 也就是 // wireMemoryTidy 当初那一遍的同一口径,只是可以随时再跑一次。 type MemoryTidyTuningApplier interface { ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error } // MemoryIngestTuningApplier 同理,服务于**同步入库闸 / 入库前审查**一侧 // (`ingestGate` / `tempTtlSec`)——它的持有者是记忆存储,因此由 `deps.Memories` // 那个出口解析(网关不为此新开依赖面)。 type MemoryIngestTuningApplier interface { ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error } // AdminMemoryTidyTuningView 是 GET / PUT `/admin/memories/tidy/tuning` 的 data。 type AdminMemoryTidyTuningView struct { // Path 是面板配置文件的落点(**相对 `$DATA_DIR`**,如 `config/memory-tidy.json`): // 面板据此如实告诉主人改的是哪个文件,而不必把部署绝对路径下发。 Path string `json:"path"` // Present 报告那份文件是否已存在且有内容。false = 还没播种(后端**下次启动** // 会自动按当时生效值创建它,见 runtimeconfig.SeedMemoryTidy)。 Present bool `json:"present"` // Hot 报告这次结果是否**当场生效**(true)。false = 已落盘,但要等后端重启 —— // 面板必须照实说,不得显示成「已生效」。GET 里恒为 false:只读不改变任何东西, // 「生效」这个问题在只读路径上没有意义。 Hot bool `json:"hot"` // HotError 在「已保存、但没能在运行期立刻生效」时给出原因(`hot:false` 时才有)。 HotError string `json:"hotError,omitempty"` // Specs 是这 7 个键的**取值规则**(类型/单位/上下界/兜底来源),随读下发 ⇒ // 面板的表单与就地校验都由它驱动,不会出现「面板允许、后端 400」的两套规则。 Specs []memoryTidyTuningSpec `json:"specs"` // Values 是**盘上**的值:键缺位 ⇒ 整个键不出现(= 跟随兜底),不编一个 0 —— // 「写了 0」(坏值,运行期会 WARN 回落)与「没写」在面板上必须看得出区别。 Values map[string]any `json:"values"` // EnvValues 是缺位时**下一档(`.env`)**的进程生效值(只读下发):面板据此写 // 「留空 ⇒ 跟随 `.env` 的 86400」,而不必自己猜一个数。与 // GET /admin/config-facts 同源(都取 `s.cfg`,网关绝不直接 fopen `.env`)。 // 某个键**连 `.env` 档也没有值**(未填 / 结构性无此键)时它不出现。 EnvValues map[string]int `json:"envValues,omitempty"` } // adminMemoryTidyTuningRead 是 GET 的处理函数:只读、不审计(与 // /admin/config-facts、tidy 试算同一档:读事实不改状态)。 func (s *Server) adminMemoryTidyTuningRead(w http.ResponseWriter, _ *http.Request) { path, ok := s.memoryTidyTuningPath() if !ok { writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定")) return } view, err := s.buildMemoryTidyTuningView(path) if err != nil { s.writeTuningCorrupt(w, err) return } writeJSON(w, http.StatusOK, view) } // adminMemoryTidyTuningWrite 是 PUT 的处理函数:三态补丁 → 原子落盘 → 热生效 → // 审计。顺序是刻意的:**校验全部发生在落盘之前**(口径 1),落盘之后才谈生效。 func (s *Server) adminMemoryTidyTuningWrite(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, memoryTuningMaxBodyBytes)) if err != nil { s.auditClaims(r, memoryTuningAuditAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败")) return } patch, err := parseMemoryTidyTuningPatch(body) if err != nil { // 非法值/未知键:**盘上一个字节都不动**(解析阶段就退出,写盘在下面)。 s.auditClaims(r, memoryTuningAuditAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err)) return } path, ok := s.memoryTidyTuningPath() if !ok { s.auditClaims(r, memoryTuningAuditAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定")) return } // 落盘(补丁 + 原子写)交给 runtimeconfig 家族:它同时是「盘上那份能不能被 // 运行期读走」的**唯一**判定点(读不走的文件拒绝覆盖,见 ErrInvalidMemoryTidy)。 if !patch.empty() { if err := s.commitMemoryTidyTuningPatch(path, patch); err != nil { s.auditClaims(r, memoryTuningAuditAction, "error") s.writeTuningWriteFailure(w, err) return } } view, err := s.buildMemoryTidyTuningView(path) if err != nil { // 刚落盘就解不开:这是本端点的编程/磁盘错误,如实回 500,绝不回一个 // 「已保存」的假成功。 s.auditClaims(r, memoryTuningAuditAction, "error") s.writeTuningCorrupt(w, err) return } hot, hotErr := s.applyMemoryTidyTuningHot(path) view.Hot = hot if hotErr != nil { view.HotError = hotErr.Error() } s.auditClaims(r, memoryTuningAuditAction, "ok") writeJSON(w, http.StatusOK, view) } // commitMemoryTidyTuningPatch 把补丁交给 runtimeconfig 家族原子落盘。值一个都没变 // 时不落盘(「保存同样的值」不该在盘上留一次无意义的改写)。 func (s *Server) commitMemoryTidyTuningPatch(path string, patch memoryTidyTuningPatch) error { _, wrote, err := runtimeconfig.PatchMemoryTidyTuning(path, patch.set, patch.clear) if err != nil { return err } log := logger.WithSource(s.log, memoryTuningLogSource) if !wrote { log.Info("memory tidy tuning unchanged (no bytes written)", "path", path, "keys", patch.changed) return nil } log.Info("memory tidy tuning saved", "path", path, "keys", patch.changed) return nil } // buildMemoryTidyTuningView 读盘并组装面板视图(口径 4:响应就是**盘上真值**)。 func (s *Server) buildMemoryTidyTuningView(path string) (AdminMemoryTidyTuningView, error) { tuning, err := runtimeconfig.LoadMemoryTidyTuning(path) if err != nil { return AdminMemoryTidyTuningView{}, err } present, err := memoryTidyTuningPresent(path) if err != nil { return AdminMemoryTidyTuningView{}, err } return AdminMemoryTidyTuningView{ Path: runtimeconfig.MemoryTidyConfigRelPath, Present: present, Specs: memoryTidyTuningSpecs(), Values: memoryTidyTuningValues(tuning), EnvValues: s.memoryTidyTuningEnvValues(), }, nil } // memoryTidyTuningPresent 报告面板配置文件是否已存在且**有内容**(全空白 = 还没配过, // 与 runtimeconfig.LoadMemoryTidyTuning 的口径一致)。 func memoryTidyTuningPresent(path string) (bool, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("读取 %s:%w", path, err) } return len(strings.TrimSpace(string(raw))) > 0, nil } // memoryTidyTuningEnvValues 返回缺位时的**下一档(`.env`)**进程生效值。 // // 只取 `s.cfg`(配置层已消化「进程环境 → `.env` → 声明默认」):网关**绝不** // 直接 fopen `.env`,否则面板看到的是文件原文而不是运行实例在用的数。 // // 三处刻意省略(省略 = 「这一档也没值,继续落到代码默认」,而不是编一个 0): // - `MEMORY_TIDY_INTERVAL_SEC` ≤ 0:非正数是坏值(运行期会 WARN 并回落), // 报出来会让人以为「留空就得到 0 秒」; // - 安静时段两端**都为 0**:0/0 是「未填」,运行期会回落到设计默认窗口, // 此时报 0/0 等于把「未填」谎报成一个午夜窗口。 // // 其余四个键(`quietIntervalSec` / `ingestGate` / `autoApply` / `tempTtlSec`)在 // `.env` 里**结构性没有出处**(`.env` 已冻结,design/13 §2.1),因此不存在第二档, // 它们的下一档就是代码默认(specs 里的 fallback 已说明)。 func (s *Server) memoryTidyTuningEnvValues() map[string]int { out := map[string]int{} if s.cfg == nil { return out } if v := s.cfg.MemoryTidy.IntervalSec; v > 0 { out["intervalSec"] = v } if start, end := s.cfg.MemoryTidy.QuietStartHour, s.cfg.MemoryTidy.QuietEndHour; start != 0 || end != 0 { out["quietStartHour"] = start out["quietEndHour"] = end } return out } // memoryTidyTuningPath 解析面板配置文件的绝对落点(`$DATA_DIR/config/memory-tidy.json`)。 // // 数据目录的来源与图片上传端点同一口径:优先已解析的 `deps.DataDir`,为空时回退 // `cfg.DataDir`;两者都空(未接线)时报 false,调用方如实回 `tuning_unavailable`, // 绝不猜一个目录去写。 func (s *Server) memoryTidyTuningPath() (string, bool) { dataDir := s.dataDir if dataDir == "" && s.cfg != nil { dataDir = s.cfg.DataDir } if dataDir == "" { return "", false } return runtimeconfig.DefaultMemoryTidyPath(dataDir), true } // applyMemoryTidyTuningHot 把刚落盘的面板值当场叠到运行期(口径 3)。 // // 返回 (hot, err):hot=false 表示这次**没能在运行期生效**(能力未接线,或叠的过程 // 报错),err 说明原因。两者只用于如实回话,**不影响**「已经落盘」这个事实 —— // 值不会因为热生效失败而丢。 // // 能力用类型断言探测(可选能力,与 `plugin` 侧的可选接口同一手法):网关**不为此** // 新增 Deps 字段/Server 字段 —— 整理服务本来就在 `s.memoryTidy` 里,审查器挂在 // `deps.Memories` 的实现上。这样「有没有热生效」是一件可观测的事实,而不是一句 // 靠不住的断言。 func (s *Server) applyMemoryTidyTuningHot(path string) (hot bool, hotErr error) { tuning, err := runtimeconfig.LoadMemoryTidyTuning(path) if err != nil { return false, fmt.Errorf("重读面板配置失败:%v", err) } applied := false if applier, ok := s.memoryTidy.(MemoryTidyTuningApplier); ok { base := memory.TidyConfig{} if s.cfg != nil { base = memory.TidyConfigFromConfig(s.cfg.MemoryTidy) } if err := applier.ApplyPanelTuning(base, tuning); err != nil { return false, fmt.Errorf("整理自动档未能热生效:%v", err) } applied = true } if applier, ok := s.memories.(MemoryIngestTuningApplier); ok { if err := applier.ApplyIngestPanelTuning(tuning); err != nil { return false, fmt.Errorf("入库审查未能热生效:%v", err) } applied = true } if !applied { return false, fmt.Errorf("运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效") } return true, nil } // writeTuningCorrupt 把「盘上那份读不走」映射成 409 tuning_corrupt,并把**运行期的 // 实际后果**写进文案:此刻它正整体回落 `.env`/代码默认(见 runtimeconfig 的读取口径), // 而面板**拒绝**在这个文件上覆盖 —— 坏文件请人工修复或删除。 func (s *Server) writeTuningCorrupt(w http.ResponseWriter, cause error) { logger.WithSource(s.log, memoryTuningLogSource).Warn( "memory tidy tuning file unusable", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause) writeError(w, apperr.Wrap(apperr.ErrConflict, "tuning_corrupt", "面板配置文件 %s 解不开(运行期正回落 .env / 代码默认);面板拒绝在一个读不走的文件上覆盖,请人工修复或删除它:%v", runtimeconfig.MemoryTidyConfigRelPath, cause)) } // writeTuningWriteFailure 把落盘失败如实分类:**盘上那份读不走**(ErrInvalidMemoryTidy) // 是 409(拒绝覆盖,不是故障),其余(IO/权限/重读不一致)是 500。两种都带出处, // 绝不回一个「已保存」的假成功。 func (s *Server) writeTuningWriteFailure(w http.ResponseWriter, cause error) { if errors.Is(cause, runtimeconfig.ErrInvalidMemoryTidy) { s.writeTuningCorrupt(w, cause) return } logger.WithSource(s.log, memoryTuningLogSource).Error( "memory tidy tuning write failed", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause) writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_write_failed", "面板配置文件 %s 落盘失败(盘上内容未改动):%v", runtimeconfig.MemoryTidyConfigRelPath, cause)) }