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,168 @@
package gateway
import (
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件是 P4「后台思考行紧急度 / 穿透安静时段」的测试:钉住
// 「穿透标记 ⇔ 档位下限」的对应关系,以及「不可证时不下发」的诚实边界。
// quietCfg 构造安静时段 = 23:00-07:00(跨午夜)的生效配置。
func quietCfg() *config.Config {
cfg := cfgDefault()
cfg.Background.QuietStartHour = 23
cfg.Background.QuietEndHour = 7
return cfg
}
// localAt 造一个**本地时区**的墙钟时刻(安静时段是本地钟点窗口)。
func localAt(hour int) time.Time {
return time.Date(2026, 10, 6, hour, 30, 0, 0, time.Local)
}
// TestAdminBackthink_QuietHoursBypassMatchesUrgency 是判据 ④:管理行上的穿透标记
// 必须与 `urgency` 档位下限一一对应 ——
// - 安静时段内 + 判定要推 → urgency=warning(≥ high)+ quietHoursBypassed=true;
// - 安静时段内 + 判定不推 → urgency=routine + 无穿透键;
// - 安静时段外 → 两个键都不下发(存档不可证,绝不猜)。
func TestAdminBackthink_QuietHoursBypassMatchesUrgency(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{
ID: 3, Trigger: "iot.device.smoke", At: localAt(2), Conclusion: "烟雾告警,得叫她",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "delivered to 1 active client(s), event_bypass"},
},
{
ID: 2, Trigger: "heartbeat", At: localAt(3), Conclusion: "深夜只是想一想",
ShouldPush: false,
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "quiet hours; not interrupting"},
},
{
ID: 1, Trigger: "heartbeat", At: localAt(12), Conclusion: "白天的一轮",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteIMBridge, Detail: "delivered via IM bridge"},
},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
if len(items) != 3 {
t.Fatalf("items = %d, want 3", len(items))
}
body := string(raw)
byID := map[string]AdminBackthinkItem{}
for _, it := range items {
byID[it.ID] = it
}
// ① 安静时段内被放行 ⇒ 穿透 + 档位下限 warning(= 至少 high)。
bypass := byID["3"]
if !bypass.QuietHoursBypassed || bypass.Urgency != urgency.LevelWarning.String() {
t.Fatalf("穿透行 = %+v, want urgency=%s + quietHoursBypassed=true", bypass, urgency.LevelWarning)
}
// ② 安静时段内被扣下 ⇒ routine,且不得标成穿透。
held := byID["2"]
if held.QuietHoursBypassed || held.Urgency != urgency.LevelRoutine.String() {
t.Fatalf("静默行 = %+v, want urgency=routine 且不穿透", held)
}
// ③ 安静时段之外 ⇒ 两个键都不下发(存档不可证)。
day := byID["1"]
if day.QuietHoursBypassed || day.Urgency != "" {
t.Fatalf("白天行 = %+v, want 两个键都缺席", day)
}
// 线上形状:真穿透的行必须带这两个键;不可证的行必须**不带**它们(不是 false)。
if !strings.Contains(body, `"urgency":"warning"`) || !strings.Contains(body, `"quietHoursBypassed":true`) {
t.Fatalf("响应缺少穿透/档位键:%s", body)
}
if !strings.Contains(body, `"urgency":"routine"`) {
t.Fatalf("响应缺少被扣下那轮的档位:%s", body)
}
if strings.Contains(body, `"quietHoursBypassed":false`) {
t.Fatalf("「没穿透」不得写成 false(键必须缺席,才不把「未知」画成事实):%s", body)
}
}
// TestAdminBackthink_OmitsUrgencyForZeroTimestamp 钉住兜底:时间戳坏掉的行
// (backthink 存储层解析失败会给零值时间)**不下发**任何档位/穿透 ——
// 宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
func TestAdminBackthink_OmitsUrgencyForZeroTimestamp(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{ID: 1, Trigger: "broken", ShouldPush: true},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
got := items[0]
if got.Urgency != "" || got.QuietHoursBypassed {
t.Fatalf("零值时间戳不得产出档位/穿透:%+v", got)
}
if strings.Contains(string(raw), `"urgency"`) || strings.Contains(string(raw), `"quietHoursBypassed"`) {
t.Fatalf("零值时间戳必须省略两个键:%s", raw)
}
}
// TestQuietHoursActiveAt_WindowSemantics 钉住窗口口径(与 backthink.decide 的
// inQuietHours 逐字一致):半开区间、可跨午夜、两端相等即关闭。
func TestQuietHoursActiveAt_WindowSemantics(t *testing.T) {
t.Parallel()
at := func(hour, minute int) time.Time {
return time.Date(2026, 10, 6, hour, minute, 0, 0, time.Local)
}
cases := []struct {
name string
start, end int
when time.Time
want bool
}{
{"跨午夜:起点", 23, 7, at(23, 0), true},
{"跨午夜:午前", 23, 7, at(2, 30), true},
{"跨午夜:终点前", 23, 7, at(6, 59), true},
{"跨午夜:终点(半开区间不含)", 23, 7, at(7, 0), false},
{"跨午夜:起点前", 23, 7, at(22, 59), false},
{"跨午夜:白天", 23, 7, at(12, 0), false},
{"日间窗口", 9, 17, at(10, 0), true},
{"日间窗口:窗口外", 9, 17, at(22, 0), false},
{"两端相等 ⇒ 关闭", 23, 23, at(23, 30), false},
{"零值 ⇒ 关闭", 0, 0, at(1, 0), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := quietHoursActiveAt(tc.start, tc.end, tc.when); got != tc.want {
t.Fatalf("quietHoursActiveAt(%d,%d,%v) = %v, want %v", tc.start, tc.end, tc.when, got, tc.want)
}
})
}
}
// TestBackthinkQuietOutcomeOf_ReadsLocalHour 钉住时区口径:库里存 UTC,但安静
// 时段是**本地钟点**窗口 ⇒ 推导必须先换回本地时区再取钟点(否则北京时间凌晨 2 点
// 会被当成 UTC 18 点,穿透标记整片错位)。
func TestBackthinkQuietOutcomeOf_ReadsLocalHour(t *testing.T) {
t.Parallel()
if _, offset := time.Now().In(time.Local).Zone(); offset == 0 {
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
}
local2am := localAt(2)
utc := local2am.UTC()
if utc.Hour() == local2am.Hour() {
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
}
if got := backthinkQuietOutcomeOf(backthink.Record{At: utc, ShouldPush: true}, 23, 7); !got.bypassed {
t.Fatalf("UTC 存储的本地凌晨 2 点必须判为穿透:%+v", got)
}
}