Files
AskaEth 4213b268fe 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)——已逐条说明理由
2026-10-06 21:48:49 +08:00

167 lines
6.1 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 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)
}
}