feat(backthink): 自排的成本可见面(C2 读侧,Y3)

- GET /api/v1/admin/backthink 加可选 ?trigger=(闭集=内建触发标签 + 本服务订阅的事件主题;
  未知/空 ⇒ 400 invalid_trigger,不静默忽略;合法值下推到 SQL ⇒ limit 是匹配条数)
- 新端点 GET /api/v1/admin/backthink/self-wake(writeAuth):闸的只读镜像
  {available,running,reason?,configFile?,gate{pending,used,limit,windowSec,default*,*IsDefault}}
  三态分开(未接线 / 有配置未运行 / 运行中),读它**不消耗额度**
- 零新 env 键;旋钮源 = $DATA_DIR/config/backthink.json(**写面未做**,前置=组合根接线,已登记)
- 键名沿用设计与现有代码的 selfWakePerWindow(派工书写错成 selfWakeLimit,未改键名以免废掉主人已写的配置)
- 5 个变异(未知值静默忽略/关掉下推/恒报运行中/白名单摘键/前端把看不到画成 0/0)
⚠️ 本片改了 4 个文件面外文件(admin_query.go/routes.go/api.ts/adminSystem.ts)——已逐条说明理由
This commit is contained in:
AskaEth committed 2026-10-06 21:48:49 +08:00
1 parent 215eedf8e9
commit 4213b268fe
13 files changed
+1289 -43

No files matched your search

@@ -0,0 +1,166 @@
package backthink
import (
"context"
"testing"
"time"
)
// 本文件守切片 C2 的两条读侧能力(design/12 §N C.5/C.6):
// 1. **触发源取值域**(trigger_domain.go 的唯一真源)—— 内建标签 + 订阅的事件主题,
// 它是 admin `?trigger=` 筛选白名单的依据;
// 2. **按触发源读取**(ListByTrigger 下推到 SQL)—— `limit` 必须是**匹配**条数,
// 而不是「先取一页再筛掉不匹配的」(后者会给面板一个静默的假空值)。
// TestTriggerFilterValues_CoversBuiltinsAndSubscribedSubjects 钉住白名单的**构成**:
// 三个内建标签(心跳 / 启动 / 自排)+ 本服务订阅的全部事件主题。
//
// 变异:把 SelfWakeSource 从列表里摘掉 ⇒ 本用例变红(面板将筛不到自排轮,
// 而它恰恰是这一档最想看的);少列一个订阅主题 ⇒ 同红。
func TestTriggerFilterValues_CoversBuiltinsAndSubscribedSubjects(t *testing.T) {
t.Parallel()
values := TriggerFilterValues()
set := map[string]bool{}
for _, v := range values {
if set[v] {
t.Fatalf("白名单里有重复取值 %q:%v", v, values)
}
set[v] = true
}
for _, want := range []string{HeartbeatSource, StartupSource, SelfWakeSource} {
if !set[want] {
t.Fatalf("白名单缺内建标签 %q:%v", want, values)
}
}
subjects := EventSubjects()
if len(subjects) == 0 {
t.Fatal("订阅主题表为空:白名单会漏掉全部事件轮")
}
for _, s := range subjects {
if !set[s] {
t.Fatalf("白名单缺订阅主题 %q:%v", s, values)
}
}
// 反向:白名单里的每个取值都必须真的可能出现在 Record.Trigger 上
// (= 内建标签 或 订阅主题)—— 多列一个「不可能出现」的值会让筛选永远返回空。
for _, v := range values {
if v == HeartbeatSource || v == StartupSource || v == SelfWakeSource {
continue
}
if !isEventSubject(v) {
t.Fatalf("白名单含一个不可能是触发源的取值 %q:%v", v, values)
}
}
}
// isEventSubject 只在本测试里做反向核对(生产侧没有这个判定:白名单由
// TriggerFilterValues 拼出来,不需要反查)。
func isEventSubject(v string) bool {
for _, s := range EventSubjects() {
if s == v {
return true
}
}
return false
}
// TestIsTriggerValue_RejectsUnknown 钉住白名单判定:已知取值通行,未知/空串一律拒。
//
// 变异:把 IsTriggerValue 改成恒 true ⇒ 服务端筛选会「接受一切但筛不中」,
// 网关那条 400 的用例随之变红。
func TestIsTriggerValue_RejectsUnknown(t *testing.T) {
t.Parallel()
for _, ok := range []string{HeartbeatSource, StartupSource, SelfWakeSource, "iot.device_update"} {
if !IsTriggerValue(ok) {
t.Fatalf("IsTriggerValue(%q) = false, want true", ok)
}
}
for _, bad := range []string{"", "event", "self-wake", "HEARTBEAT", "all", "iot"} {
if IsTriggerValue(bad) {
t.Fatalf("IsTriggerValue(%q) = true, want false", bad)
}
}
}
// TestStore_ListByTrigger_IsServerSideFilter 钉住**下推**语义:筛选后 `limit` 数的是
// 匹配条数(而不是「本页里恰好匹配的条数」)。
//
// 变异:把 ListByTrigger 改回「List 之后内存筛」的退化实现(拿 limit 条再筛)⇒
// 下面的 `limit=1` 断言会拿到 0 条(最新一条是心跳,被筛掉后没有别的自排)⇒ 变红。
func TestStore_ListByTrigger_IsServerSideFilter(t *testing.T) {
t.Parallel()
ctx := context.Background()
s, err := NewStore(ctx, newTestDB(t), discardLog())
if err != nil {
t.Fatalf("NewStore: %v", err)
}
base := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
// 交替写入:心跳(最新)/ 自排 / 心跳 / 自排 / 心跳(最旧)。
triggers := []string{HeartbeatSource, SelfWakeSource, HeartbeatSource, SelfWakeSource, HeartbeatSource}
for i, tr := range triggers {
if _, err := s.Append(ctx, Record{Trigger: tr, At: base.Add(time.Duration(i) * time.Minute)}); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
got, err := s.ListByTrigger(ctx, SelfWakeSource, 10)
if err != nil {
t.Fatalf("ListByTrigger: %v", err)
}
if len(got) != 2 {
t.Fatalf("自排记录 = %d 条, want 2(%+v)", len(got), got)
}
for _, r := range got {
if r.Trigger != SelfWakeSource {
t.Fatalf("筛选结果含非自排行:%+v", r)
}
}
// 最新的在前(与 List 同口径)。
if got[0].ID != 4 || got[1].ID != 2 {
t.Fatalf("顺序 = %d,%d, want 4,2(最新在前)", got[0].ID, got[1].ID)
}
// 🔴 下推的关键判据:limit 数的是**匹配**条数。
one, err := s.ListByTrigger(ctx, SelfWakeSource, 1)
if err != nil {
t.Fatalf("ListByTrigger limit=1: %v", err)
}
if len(one) != 1 || one[0].ID != 4 {
t.Fatalf("limit=1 结果 = %+v, want 只有 id=4(匹配条数,不是先取一页再筛)", one)
}
// 未出现过的合法取值 ⇒ 空列表(不是错误)。
none, err := s.ListByTrigger(ctx, "reminder.trigger", 10)
if err != nil {
t.Fatalf("ListByTrigger(reminder.trigger): %v", err)
}
if len(none) != 0 {
t.Fatalf("无匹配时应返回空列表,得到 %+v", none)
}
}
// TestBackthink_ListByTrigger_DelegatesToStore 钉住服务层的读口真的接到存储
// (网关的可选接口 `backthinkTriggerLister` 靠的就是这个方法签名)。
//
// 变异:把 `Backthink.ListByTrigger` 改成忽略 trigger 直接 List ⇒ 本用例变红。
func TestBackthink_ListByTrigger_DelegatesToStore(t *testing.T) {
t.Parallel()
b, _ := newBackthink(t, Config{}, Deps{Thinker: newWakeThinker(), State: &mockState{}})
ctx := context.Background()
for i := 0; i < 3; i++ {
if _, err := b.store.Append(ctx, Record{Trigger: HeartbeatSource, At: time.UnixMilli(int64(i))}); err != nil {
t.Fatalf("Append: %v", err)
}
}
if _, err := b.store.Append(ctx, Record{Trigger: SelfWakeSource, At: time.UnixMilli(99)}); err != nil {
t.Fatalf("Append: %v", err)
}
got, err := b.ListByTrigger(ctx, SelfWakeSource, 50)
if err != nil {
t.Fatalf("ListByTrigger: %v", err)
}
if len(got) != 1 || got[0].Trigger != SelfWakeSource {
t.Fatalf("got = %+v, want 只有那条自排记录", got)
}
}