Files
AskaEth 34cef95e17 feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env)
- 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」
- 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事)
- 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了)
- 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过
- 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
2026-10-05 13:54:56 +08:00

108 lines
3.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"reflect"
"strconv"
"strings"
)
// 本文件从 `internal/config` 的 env 标签里读出各键的**声明默认值**,供只读配置
// 事实面(admin_config_facts.go)如实展示「默认 / 已改」。
//
// 为什么用标签而不是另一份默认值清单:`default:"…"` 标签本身就是配置层的唯一
// 真源(`config.lookupRaw` 在进程环境与 `.env` 都没给值时正是用它)。网关再抄
// 一份等于制造第二份事实,两边迟早对不上;而反射读标签既不需要 fopen `.env`
// (主人红线:键名单读 `.env.example`,**绝不读 `.env`**),也不需要新增配置包
// 导出面。
// envTagName 与 config 包的内部约定一致:env 标签可带逗号分隔的选项,
// 变量名是逗号前的部分。
func envTagName(tag string) string {
name, _, _ := strings.Cut(tag, ",")
return strings.TrimSpace(name)
}
// declaredEnvDefaults 遍历配置结构体,返回 `env:"KEY"` 字段的声明默认值
// (`default:"…"` 标签),按字段类型归一化成字符串,键与取值口径与
// `buildConfigFacts` 对齐:
//
// - bool → "true"/"false"(标签里的写法大小写、多余空白都被吸收);
// - int → 十进制;
// - float → 归一化小数(`0.80` ⇒ `0.8`);
// - string→ 原样(去首尾空白)。
//
// 没有 `default` 标签的字段不进表(其「未设置」语义由各自的 Set 标志单独表达,
// 不是本事实面的内容);无法归一化的值原样保留,宁可显示原始文本也不猜。
func declaredEnvDefaults(root any) map[string]string {
out := make(map[string]string)
if root == nil {
return out
}
collectEnvDefaults(reflect.ValueOf(root), out, 0)
return out
}
// maxConfigDepth 是递归下探嵌套配置组的深度上限(防御性:配置是静态结构,
// 但它必须是常量而不是魔法数字)。
const maxConfigDepth = 8
// collectEnvDefaults 递归收集默认值。非结构体、指针、不可寻址的字段一律跳过:
// 配置结构体是静态的平面嵌套,够不到的东西不猜。
func collectEnvDefaults(v reflect.Value, out map[string]string, depth int) {
if depth > maxConfigDepth {
return
}
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
fv := v.Field(i)
tag, hasEnv := field.Tag.Lookup("env")
if !hasEnv {
// 没有 env 标签的字段是嵌套配置组。
collectEnvDefaults(fv, out, depth+1)
continue
}
name := envTagName(tag)
if name == "" {
continue
}
def, hasDefault := field.Tag.Lookup("default")
if !hasDefault {
continue
}
out[name] = normalizeDefault(fv.Kind(), def)
}
}
// normalizeDefault 把标签里的默认值按字段类型归一化,形状与事实面的生效值一致。
func normalizeDefault(kind reflect.Kind, raw string) string {
text := strings.TrimSpace(raw)
switch kind {
case reflect.Bool:
if b, err := strconv.ParseBool(strings.ToLower(text)); err == nil {
return strconv.FormatBool(b)
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if n, err := strconv.ParseInt(text, 10, 64); err == nil {
return strconv.FormatInt(n, 10)
}
case reflect.Float32, reflect.Float64:
if f, err := strconv.ParseFloat(text, 64); err == nil {
return formatRatio(f)
}
}
return text
}