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 下限限定)
This commit is contained in:
AskaEth committed 2026-10-05 13:54:56 +08:00
1 parent a057a0f61f
commit 34cef95e17
25 files changed
+1741 -14

No files matched your search

@@ -0,0 +1,107 @@
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
}