// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。 // // 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次 // 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板 // 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短 // 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径** // (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。 // // 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy // 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。 // // 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法): // // 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份 // 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样 // 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。 // 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent // 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的 // 手写排版不该因为一次空操作被重排。 // 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回 // `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体 // 回落 `.env`/代码默认,盖掉它只会掩盖问题)。 // 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、 // SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个 // JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。 // // 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成 // 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生 // 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。 package runtimeconfig import ( "bytes" "encoding/json" "fmt" "os" "path/filepath" "strings" ) const ( // memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。 memoryTidySaveFileMode = 0o600 // memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。 memoryTidySaveDirMode = 0o755 ) // PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进 // `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。 // // set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API // 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉 // 的键(= 回到下一档兜底)。两者都为空时**不落盘**。 // // 返回 (tuning, wrote, err): // - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」); // - wrote 报告这次是否真的写了字节(内容语义未变时为 false); // - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示 // 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。 func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) { doc, err := readMemoryTidyDocForPatch(path) if err != nil { return MemoryTidyTuning{}, false, err } before := canonicalMemoryTidyJSON(doc) merged := make(map[string]json.RawMessage, len(doc)+len(set)) for key, value := range doc { merged[key] = value } for key, value := range set { merged[key] = value } for _, key := range clear { delete(merged, key) } raw, err := json.MarshalIndent(merged, "", " ") if err != nil { return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err) } wrote := false if !bytes.Equal(raw, before) { if err := writeMemoryTidyTuningFile(path, raw); err != nil { return MemoryTidyTuning{}, false, err } wrote = true } tuning, err := LoadMemoryTidyTuning(path) if err != nil { // 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取 // 路径读走),如实报错,绝不回一份「成功」。 return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err) } return tuning, wrote, nil } // readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。 // // 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误); // 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy // (口径 3:拒绝覆盖)。 func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return map[string]json.RawMessage{}, nil } return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err) } if strings.TrimSpace(string(raw)) == "" { return map[string]json.RawMessage{}, nil } // 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事 // 就是自相矛盾(写入方会据此把一份坏配置盖掉)。 if _, err := decodeMemoryTidy(raw, path); err != nil { return nil, err } doc := map[string]json.RawMessage{} if err := json.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err) } return doc, nil } // canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径), // 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等 // (= 走正常落盘路径),不会误判成「没变」。 func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte { raw, err := json.MarshalIndent(doc, "", " ") if err != nil { return nil } return raw } // writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。 func writeMemoryTidyTuningFile(path string, raw []byte) error { if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil { return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err) } } tmp := path + ".tmp" if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil { return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err) } if err := os.Rename(tmp, path); err != nil { return fmt.Errorf("runtimeconfig: rename %s: %w", path, err) } return nil }