feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。

- 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若
  $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」
  (十键:既有六键 + 频率四键),此后面板接管
- 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、
  一个字不写盘(不「修好它」)
- 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename)
- 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘
- 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行
  行为零变化,有强断言(归一化后配置逐字相等)
- 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
This commit is contained in:
AskaEth committed 2026-10-07 20:38:37 +08:00
1 parent d680390300
commit 23b6e3fe1e
8 files changed
+1156 -6

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
package memory
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
//
// 它回应主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端**自动创建**独立的面板配置文件之后就走面板
// 配置文件,这些键优先从面板配置上取」⇒ 播种的时机与「此后以面板为准」;
// - 「后端**自动创建**独立的面板配置文件**要做**」⇒ 这份快照就是「自动创建」的
// 内容,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
//
// 为什么放在 memory 而不是 runtimeconfig:runtimeconfig 只管**文件形状与读写**,
// 「缺位/坏值怎么回落、默认值是多少」属于 TidyConfig / ReviewConfig 的归一化语义
// (与 normalizeTidyConfig / normalizeReviewConfig 同一层)。
//
// 为什么快照取**归一化之后**的值:主人要的是「播种那一刻行为零变化」——把当时真正
// 生效的值写成文件,读回来叠上去之后(面板这一档等于生效值本身)结果逐字相同。
// 若快照取的是「未归一化的中间值」(比如四键还是 0),写下去的就是一份**比现状更空**
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
//
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
// 会与运行期悄悄分叉。
//
// 十键与它们的生效来源:
//
// 键 生效来源(无面板文件时) 快照值
// autoApply DisableAutoApply 的反向(代码默认开) true
// autoApplyPerRound defaultTidyAutoApplyPerRound 50
// ingestGate DisableIngestGate 的反向(默认开) true
// t1Judge DisableT1 的反向(默认开) true
// t1PerWindow defaultT1PerWindow 120
// t1WindowSec defaultT1Window 3600
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
// quietIntervalSec 缺省 = intervalSec 归一化值
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
//
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
// 文件形状 —— 它不读盘、不读 env、不认识面板文件:
//
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
// 与自动档两键(autoApply / autoApplyPerRound);
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
//
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.MemoryTidyTuning {
tidy = normalizeTidyConfig(tidy)
review = normalizeReviewConfig(review)
autoApply := !tidy.DisableAutoApply
ingestGate := !review.DisableIngestGate
t1Judge := !review.DisableT1
intervalSec := tidy.IntervalSec
quietIntervalSec := tidy.QuietIntervalSec
quietStartHour := tidy.QuietStartHour
quietEndHour := tidy.QuietEndHour
return runtimeconfig.MemoryTidyTuning{
AutoApply: &autoApply,
AutoApplyPerRound: tidy.AutoApplyPerRound,
IngestGate: &ingestGate,
T1Judge: &t1Judge,
T1PerWindow: review.T1PerWindow,
T1WindowSec: int(review.T1Window.Seconds()),
IntervalSec: &intervalSec,
QuietIntervalSec: &quietIntervalSec,
QuietStartHour: &quietStartHour,
QuietEndHour: &quietEndHour,
}
}
@@ -0,0 +1,196 @@
package memory
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**启动播种的值来源**(tidy_snapshot.go;主人 2026-10-07:「`.env` 只做
// 兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 快照十键齐全、值可用(否则 runtimeconfig.SeedMemoryTidy 会拒绝落盘);
// ② 值取「**当时解析出来的生效值**」—— `.env` 给了就用 `.env`,没给就用代码默认
// (默认值来自 normalizeTidyConfig / normalizeReviewConfig,不另写一份);
// ③ 最关键的一条:把快照当成面板值叠回去,**归一化结果与「没有面板文件」逐字相同**
// —— 这就是「播种那一刻行为零变化,之后改文件才生效」。
// snapBoolPtr / snapIntPtr 造「快照里显式写了这个键」的指针。
// (不复用 tidy_tuning_test.go 的 intPtr:那是给**面板**用例造的,两者语义不同,
// 分开命名便于读。)
func snapBoolPtr(v bool) *bool { return &v }
func snapIntPtr(v int) *int { return &v }
// TestTidyTuningSnapshot_CodeDefaults 钉住「什么都没配」时的十键 = 归一化的代码默认值。
// 期望值直接取自默认值常量(本包唯一那处定义),因此这条用例证明的是**接线**:
// 快照确实走了 normalizeTidyConfig / normalizeReviewConfig,而不是另写一份默认值。
func TestTidyTuningSnapshot_CodeDefaults(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
if snap.AutoApply == nil || !*snap.AutoApply {
t.Fatalf("autoApply = %v, want 显式的 true(代码默认开)", snap.AutoApply)
}
if snap.IngestGate == nil || !*snap.IngestGate {
t.Fatalf("ingestGate = %v, want 显式的 true(代码默认开)", snap.IngestGate)
}
if snap.T1Judge == nil || !*snap.T1Judge {
t.Fatalf("t1Judge = %v, want 显式的 true(代码默认开)", snap.T1Judge)
}
if snap.AutoApplyPerRound != defaultTidyAutoApplyPerRound {
t.Fatalf("autoApplyPerRound = %d, want %d", snap.AutoApplyPerRound, defaultTidyAutoApplyPerRound)
}
if snap.T1PerWindow != defaultT1PerWindow || snap.T1WindowSec != int(defaultT1Window.Seconds()) {
t.Fatalf("T1 限速 = %d/%d, want %d/%d", snap.T1PerWindow, snap.T1WindowSec,
defaultT1PerWindow, int(defaultT1Window.Seconds()))
}
if snap.IntervalSec == nil || *snap.IntervalSec != defaultTidyIntervalSec {
t.Fatalf("intervalSec = %v, want %d", snap.IntervalSec, defaultTidyIntervalSec)
}
// quietIntervalSec 缺省 = intervalSec(normalizeTidyConfig 的既有口径:不因时段换档)。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %v, want = intervalSec (%d)", snap.QuietIntervalSec, defaultTidyIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != defaultTidyQuietStartHour ||
snap.QuietEndHour == nil || *snap.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %v-%v, want %d-%d", snap.QuietStartHour, snap.QuietEndHour,
defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
}
// TestTidyTuningSnapshot_TakesEnvLayer 钉住「`.env` 给的就用 `.env`」:四键的 env 档
// (MEMORY_TIDY_INTERVAL_SEC / MEMORY_TIDY_QUIET_*_HOUR)必须原样进快照,否则播种写
// 下去的就是代码默认值,等于把主人配的 `.env` 静默抹平。
func TestTidyTuningSnapshot_TakesEnvLayer(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 22, QuietEndHour: 6}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.IntervalSec == nil || *snap.IntervalSec != 7200 {
t.Fatalf("intervalSec = %v, want 7200(取 `.env`)", snap.IntervalSec)
}
// env 只给了 intervalSec,quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %v, want 7200(缺省 = intervalSec)", snap.QuietIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != 22 ||
snap.QuietEndHour == nil || *snap.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", snap.QuietStartHour, snap.QuietEndHour)
}
}
// TestTidyTuningSnapshot_KeepsExplicitZeroHour 钉住钟点的零值口径:**0 是合法钟点**
// (0-7 的凌晨窗口)。若快照把「0 起」当成「没配」而写成 23,播种就篡改了主人的窗口。
//
// 注意「两端都是 0」是**另一回事**:那是 normalize 认的「都没配」(回落到 23-07),
// 这与显式写 0-5 的凌晨窗口必须可区分。
func TestTidyTuningSnapshot_KeepsExplicitZeroHour(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{QuietStartHour: 0, QuietEndHour: 5}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.QuietStartHour == nil || *snap.QuietStartHour != 0 || *snap.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 0-5(显式 0 是合法钟点)", snap.QuietStartHour, snap.QuietEndHour)
}
// 两端都是 0 = 「都没配」⇒ 归一成代码默认 23-07(既有口径,快照如实转述)。
both := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
if *both.QuietStartHour != defaultTidyQuietStartHour || *both.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("两端 0 = 都没配 ⇒ 应归一成 %d-%d,得到 %v-%v",
defaultTidyQuietStartHour, defaultTidyQuietEndHour, *both.QuietStartHour, *both.QuietEndHour)
}
}
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig 是**播种的全部意义**所在:
// 把快照当成面板值叠回去之后,整理与审查两侧归一化出来的配置必须与「面板文件不存在」
// 逐字相同 —— 于是播种那一刻行为零变化,之后改文件才生效。
//
// 两侧各按**运行期真实的叠加路径**走:
// - 整理侧 = 组合根那三步(TidyConfigFromConfig → ApplyTidyScheduleTuning →
// normalizeTidyConfig,即 resolveTidySchedule);
// - 审查侧 = wireMemoryReview 的写法(开关取 IngestGateOff/T1JudgeOff,数值取
// t1PerWindow/t1WindowSec,再交给 normalizeReviewConfig)。
func TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig(t *testing.T) {
t.Parallel()
envs := []struct {
name string
env config.MemoryTidyConfig
}{
{"`.env` 什么都没配", config.MemoryTidyConfig{}},
{"`.env` 配了频率与窗口", config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}},
{"`.env` 的窗口从 0 点起(凌晨档)", config.MemoryTidyConfig{IntervalSec: 10800, QuietStartHour: 0, QuietEndHour: 6}},
{"`.env` 把窗口关掉(两端相等且非 0)", config.MemoryTidyConfig{IntervalSec: 3600, QuietStartHour: 5, QuietEndHour: 5}},
{"`.env` 只配了周期", config.MemoryTidyConfig{IntervalSec: 1800}},
}
for _, tc := range envs {
log, _ := captureTidyLog(t)
// ① 没播种前:面板缺失 ⇒ 零值 tuning(= 当前生产里 data/config 下没这个文件的形态)。
before := resolveTidySchedule(tc.env, runtimeconfig.MemoryTidyTuning{}, log)
// ② 快照(这就是要写进面板文件的内容)。
snap := TidyTuningSnapshot(TidyConfigFromConfig(tc.env), ReviewConfig{})
// ③ 播种之后:面板文件就是那份快照,逐键叠回来。
after := resolveTidySchedule(tc.env, snap, log)
if before != after {
t.Fatalf("%s: 播种改变了生效配置:\n before=%+v\n after =%+v", tc.name, before, after)
}
}
// 审查侧同理(闸/T1 四键在 `.env` 里没有出处,只由面板与代码默认决定)。
reviewBefore := normalizeReviewConfig(ReviewConfig{})
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
reviewAfter := normalizeReviewConfig(ReviewConfig{
DisableIngestGate: snap.IngestGateOff(),
DisableT1: snap.T1JudgeOff(),
T1PerWindow: snap.T1PerWindow,
T1Window: time.Duration(snap.T1WindowSec) * time.Second,
})
if reviewBefore != reviewAfter {
t.Fatalf("播种改变了审查的生效配置:\n before=%+v\n after =%+v", reviewBefore, reviewAfter)
}
}
// TestTidyTuningSnapshot_IsCompleteAndPositive 钉住快照契约:十键非 nil、数值键为正、
// 钟点在 0..23。runtimeconfig.SeedMemoryTidy 会按这条契约拒收残缺种子,因此这里先
// 在**产生方**把它钉住,免得「播种写不出去」要到启动日志里才发现。
func TestTidyTuningSnapshot_IsCompleteAndPositive(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
pointers := map[string]*int{
"intervalSec": snap.IntervalSec,
"quietIntervalSec": snap.QuietIntervalSec,
"quietStartHour": snap.QuietStartHour,
"quietEndHour": snap.QuietEndHour,
}
for key, p := range pointers {
if p == nil {
t.Fatalf("%s 是 nil(缺键的种子会被拒收)", key)
}
}
if snap.AutoApply == nil || snap.IngestGate == nil || snap.T1Judge == nil {
t.Fatalf("开关指针不得为 nil:%+v", snap)
}
for key, v := range map[string]int{
"autoApplyPerRound": snap.AutoApplyPerRound,
"t1PerWindow": snap.T1PerWindow,
"t1WindowSec": snap.T1WindowSec,
"intervalSec": *snap.IntervalSec,
"quietIntervalSec": *snap.QuietIntervalSec,
} {
if v <= 0 {
t.Fatalf("%s = %d, want > 0(非正周期会被 runtimeconfig 拒收)", key, v)
}
}
for key, v := range map[string]int{"quietStartHour": *snap.QuietStartHour, "quietEndHour": *snap.QuietEndHour} {
if v < 0 || v > 23 {
t.Fatalf("%s = %d, want 0..23", key, v)
}
}
}