package runtimeconfig import ( "errors" "fmt" "log/slog" "os" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // 本文件落地「重置为 env 默认」这个**显式**动作(切片 R1)。它与 materialize.go 的 // 补齐方向相反,却共用**同一份** env 默认层投影(groupFillSpecs + envLayer): // // - Materialize(进程启动):面板缺该键 ⇒ 用 env 补上(面板是一等来源); // - ResetFromEnv(部署者显式要求):`.env` 声明过的键 ⇒ 覆盖回 env 值(env 是基线)。 // // 共用同一张键表,因此「本动作能改回的键集合」与「Materialize 会补齐的键集合」 // 结构性一致,不可能各说各话。组集合完全由面板决定:本动作不新增、不复活、不删除 // 任何组(顺序也不动),只改键值。 // ErrPanelUnreadable 表示面板配置文件读不到或解析不了。它专供调用方把 // 「配置不可读」与「写盘失败」分成两种处置(两种都不会改动文件)。 var ErrPanelUnreadable = errors.New("runtimeconfig: panel config unreadable") // 跳过原因:两种「这一组一个键都没改」的情形。它们是可直接给面板与审计看的 // 中文文案,因此以常量承载,避免代码、测试与文档各写一份。 const ( // SkipReasonNoDefaults 是该组的 provider 族在 env 默认层里没有任何**有信息量**的 // 键(见 isTautologicalEnvKey)——`.env` 没有为这一族声明默认值。 SkipReasonNoDefaults = "`.env` 未声明该组的默认键(面板值原样保留)" // SkipReasonAlreadyDefault 是 env 声明过该组的键,但面板值已与 env 值一致。 SkipReasonAlreadyDefault = "面板值已与 `.env` 默认一致,无需重置" ) // ResetGroup 是一次重置里**真的被改回**的分组:Keys 是 models.json 的 JSON 键名 // (与结构体 tag 逐字一致),**不含任何值**——凭据类键的值绝不回显、绝不进日志。 type ResetGroup struct { GroupID string Keys []string } // ResetSkip 是一个未被改动的分组与原因(顺序即面板里的组顺序)。 type ResetSkip struct { GroupID string Reason string } // ResetResult 是一次 ResetFromEnv 的可复核产物。 type ResetResult struct { // Reset 是被改回的分组,只含**真的变了**的键(面板值已与 env 一致的不算改回)。 Reset []ResetGroup // Skipped 是未被改动的分组与原因。 Skipped []ResetSkip // Written 为 true 表示本次真的改动了面板文件;无改动时一个字节都不动(幂等)。 Written bool } // ResetFromEnv 把面板组里「`.env` 默认层确实声明过」的键覆盖回 env 值。 // // 语义(与 Materialize 的补齐语义互为对照): // - 只动 `.env` 声明了非空值的键;env 未声明的键**一律保留面板值**; // - 组集合**不增减**(不复活被删的组、不删新增的组,顺序也不动); // - 面板值已与 env 值一致的键不算「改回」,不出现在 Reset 里; // - 没有任何键需要改时**不写盘**(幂等)。 // // 失败一律返回错误且**一个字节都不落盘**:面板文件不可读/不可解析(ErrPanelUnreadable) // 或写盘失败(SaveModels 自身的错误),调用方据此回显式错误,绝不假装成功。 // log 可为 nil(slog 对 nil logger 安全)。 func ResetFromEnv(path string, env config.LLMConfig, log *slog.Logger) (ResetResult, error) { log = logger.WithSource(log, "config/runtimeconfig") raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return ResetResult{}, fmt.Errorf("%w: 面板配置文件不存在(%s),没有可重置的面板值: %w", ErrPanelUnreadable, path, err) } return ResetResult{}, fmt.Errorf("%w: 读取 %s: %w", ErrPanelUnreadable, path, err) } panel, err := decodePanel(raw) if err != nil { return ResetResult{}, fmt.Errorf("%w: 解析 %s: %w", ErrPanelUnreadable, path, err) } layer := newEnvLayer(env) groups := make([]Group, len(panel.groups)) var res ResetResult for i, g := range panel.groups { groups[i] = g changed, declared := resetGroup(&groups[i], layer) if len(changed) == 0 { res.Skipped = append(res.Skipped, ResetSkip{GroupID: reportGroupID(g.ID), Reason: skipReason(declared)}) continue } res.Reset = append(res.Reset, ResetGroup{GroupID: reportGroupID(g.ID), Keys: changed}) } if len(res.Reset) == 0 { log.Debug("面板配置无需重置:没有与 `.env` 默认不同的键", "path", path, "source", envLayerSource) return res, nil } // SaveModels 先逐组校验再原子写(临时文件 + rename),因此校验或写盘失败时 // 原文件保持原样——「重置失败」绝不留下半写状态。 if err := SaveModels(path, groups); err != nil { return res, fmt.Errorf("runtimeconfig: 重置 %s: %w", path, err) } res.Written = true logReset(log, path, res) return res, nil } // resetGroup 把 env 为该组声明的键覆盖回 env 值,返回**真的被改回**的键与 // 「env 为该组声明了几个**有信息量**的键」(见 isTautologicalEnvKey)。键顺序即 // groupFillSpecs 的顺序(与被补的键同序)。 func resetGroup(g *Group, layer envLayer) (changed []string, declared int) { defaults := layer.defaultsFor(*g) for _, spec := range groupFillSpecs { value := defaults[spec.key] if value == "" { continue } if !isTautologicalEnvKey(spec.key) { declared++ } if spec.get(*g) == value { continue } spec.set(g, value) changed = append(changed, spec.key) } return changed, declared } // isTautologicalEnvKey 报告该键在 env 默认层里是不是**同义反复**:`provider` 的值由 // 「按组自己的 provider 选族」合成,因此恒等于组里已有的值,**不可能产生一次「改回」**。 // 它仍留在比较表内(与 materialize 的补齐集合逐字一致,`provider` 确实可能被补),但 // **不计入**「`.env` 到底为该组声明了什么」的判定——否则「`.env` 完全没声明这一族」会被 // 它伪装成「面板值已一致」,面板上那条跳过原因就会骗人。 func isTautologicalEnvKey(key string) bool { return key == "provider" } // skipReason 把「env 为这一组声明了几个键」翻成给面板看的原因。 func skipReason(declared int) string { if declared == 0 { return SkipReasonNoDefaults } return SkipReasonAlreadyDefault } // reportGroupID 是报告用的组标签:组 id 为空(手写配置,本就不合法)时用占位符, // 与 fill 日志的 tagKeys 同一约定,避免出现空字符串组名。 func reportGroupID(id string) string { if strings.TrimSpace(id) == "" { return "(no-id)" } return id } // logReset 汇报一次重置:只打组 id 与键名,**绝不打任何值**(apiKey 等凭据不进日志)。 func logReset(log *slog.Logger, path string, res ResetResult) { groups := make([]string, 0, len(res.Reset)) for _, g := range res.Reset { groups = append(groups, g.GroupID+"."+strings.Join(g.Keys, ",")) } log.Info("面板配置已按 .env 默认层重置(只改键值,组集合不变)", "path", path, "source", envLayerSource, "reset", groups, "resetCount", len(res.Reset), "skippedCount", len(res.Skipped)) }