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:
1 parent
215eedf8e9
commit
4213b268fe
13 files changed
+1289
-43
No files matched your search
@@ -34,6 +34,13 @@ const backthinkFileName = "backthink.json"
|
||||
// 但一旦某个家族换位置,共用常量会让另一处**静默**跟着搬家。
|
||||
const backthinkConfigSubdir = "config"
|
||||
|
||||
// BackthinkConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/backthink.json`)。
|
||||
//
|
||||
// 单独导出它,是为了让面板能**如实**告诉主人「改哪个文件」,而不必把部署相关的
|
||||
// 绝对路径塞进接口形状(绝对路径随部署而变,由占用它的部署自己拼 `$DATA_DIR`)。
|
||||
// 它与 `DefaultBackthinkPath` 取自同一对常量(目录段 + 文件名),因此不会漂移。
|
||||
const BackthinkConfigRelPath = backthinkConfigSubdir + "/" + backthinkFileName
|
||||
|
||||
// ErrInvalidBackthink 是 backthink.json 结构不合法时的分类错误(errors.Is 可判类)。
|
||||
var ErrInvalidBackthink = errors.New("runtimeconfig: invalid backthink config")
|
||||
|
||||
@@ -42,6 +49,13 @@ var ErrInvalidBackthink = errors.New("runtimeconfig: invalid backthink config")
|
||||
// 字段名与 design/12 §N C.5 的规格逐字一致(camelCase 的 JSON 键,文件是给人看、
|
||||
// 给人改的)。**没有版本字段**:这个家族只有这两个数字,等它真的长形状时再加版本,
|
||||
// 现在加一个永远为 0 的字段只是噪音。
|
||||
//
|
||||
// ⚠️ **写路径未实现(切片 C2 如实登记)**:本文件至今只有读口。C.5 把「面板保存 +
|
||||
// `SaveBackgroundTuning` + admin 端点」列为 C2,而面板写路径要经 design/09 §5 的
|
||||
// 面板声明机制(`Plugins.Declare` + 动作分发),那需要组合根接线(`cmd/cyrene`)
|
||||
// 与一个 `backthink` 面板声明 —— 都不在 C2 的文件面内。因此 C2 只交付**读侧**:
|
||||
// 运行期生效的旋钮经 `GET /api/v1/admin/backthink/self-wake` 可见(含代码默认值),
|
||||
// 改动仍要**人手改这个文件 + 重启**(与 `QUIET_*` 同口径:启动期读一次)。
|
||||
type BackgroundTuning struct {
|
||||
// SelfWakePerWindow 是每窗口允许的自排轮数。零/负 = 未声明或笔误 ⇒
|
||||
// 由服务侧 normalize 回落代码默认(**没有「关闭上限」语义**)。
|
||||
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
@@ -285,11 +284,11 @@ func (b *Backthink) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
return b.store.List(ctx, limit)
|
||||
}
|
||||
|
||||
// triggers 返回本服务为事件驱动思考所监听的总线主题。
|
||||
// 它是一个方法(而非包级变量),因此不会出现包级全局
|
||||
// 切片,符合编码约束。
|
||||
// triggers 返回本服务为事件驱动思考所监听的总线主题。它是
|
||||
// `EventSubjects`(trigger_domain.go 的唯一真源)的转述 —— 订阅表与 admin
|
||||
// 的 `?trigger=` 筛选白名单共用同一份,新增主题时两边同时生效。
|
||||
func (b *Backthink) triggers() []string {
|
||||
return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent}
|
||||
return EventSubjects()
|
||||
}
|
||||
|
||||
// subscribe 为每个触发主题注册一个处理器。每个订阅都
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package backthink
|
||||
|
||||
import "time"
|
||||
|
||||
// 本文件是自排闸的**只读快照**出口(design/12 §N C.5/C.2 的读侧,切片 C2)。
|
||||
//
|
||||
// 为什么需要它:面板此前**看不到**「本窗口还能自排几次」—— 那个数字只出现在
|
||||
// 被拒绝那一轮的审计文案里(`backthink.self_wake.capped` 的 result),也就是
|
||||
// 「已经被拒一次」之后才第一次可见。C.5 的闸本来就维护了一份**只为上报**的镜像
|
||||
// (selfWakeGate.granted),本文件把它接出来,判定仍然只由 limiter 做
|
||||
// (§C.5:「判定始终由 limiter 做;两者用同一个时间源与同一个窗口」)。
|
||||
//
|
||||
// 🔴 本文件**不做**任何写入、不碰滑窗件(`session.RateLimiter` 至今没有用量读口,
|
||||
// 属别人的文件面):镜像在 selfWakeGate 里已经有了,读它即可。
|
||||
|
||||
// SelfWakeStatus 是自排闸的只读快照:本窗口已用多少、上限多少、窗口多长,
|
||||
// 以及容量 1 的待跑槽是否被占用。
|
||||
//
|
||||
// 它**不参与任何判定**(判定始终由滑窗与待跑槽做),因此读它永远不改变闸的状态。
|
||||
type SelfWakeStatus struct {
|
||||
// Running 报告自排是否真的能排:未 Start / 已 Stop ⇒ false(此刻 WakeNow
|
||||
// 会返回 ErrSelfWakeUnavailable)。面板据此显示「未运行」而不是「0 次」。
|
||||
Running bool
|
||||
// Pending 报告容量 1 的待跑槽是否已占用(有一轮自排排着队还没跑)。
|
||||
Pending bool
|
||||
// Used 是本窗口已用额度,与收据 `SelfWakeReceipt.Used` **同一口径**
|
||||
// (同一次取数:过期的记录先被丢弃,再计数)。
|
||||
Used int
|
||||
// Limit 与 WindowSec 是**进程生效**的上限与窗口(已 normalize;`≤0` 的那份
|
||||
// 配置不会走到这里,见 cfg.go 的 normalize 注释)。
|
||||
Limit int
|
||||
WindowSec int
|
||||
// DefaultLimit / DefaultWindowSec 是代码默认值;LimitIsDefault / WindowIsDefault
|
||||
// 报告生效值**恰好等于**默认值。
|
||||
//
|
||||
// ⚠️ 它**不是**「配置文件里没写这个键」的断言(与 config-facts 的 isDefault
|
||||
// 同一条诚实口径:网关不读配置文件,也无从区分「配了 4」与「没配」)。
|
||||
DefaultLimit int
|
||||
DefaultWindowSec int
|
||||
LimitIsDefault bool
|
||||
WindowIsDefault bool
|
||||
}
|
||||
|
||||
// SelfWakeStatus 取一张自排闸快照。第二个返回值为 false 表示**闸不在**
|
||||
// (只有零值 / 未经 `New` 构造的 Backthink 会这样)—— 调用方**不得**把零值
|
||||
// 当成事实(不伪造「0 / 0」)。
|
||||
func (b *Backthink) SelfWakeStatus() (SelfWakeStatus, bool) {
|
||||
gate, running := b.wakeGate()
|
||||
if gate == nil {
|
||||
return SelfWakeStatus{}, false
|
||||
}
|
||||
gate.mu.Lock()
|
||||
// usedLocked 顺手丢弃过期记录,因此这里读到的与判定用的是同一个口径。
|
||||
used := gate.usedLocked()
|
||||
pending := gate.pending != nil
|
||||
limit, window := gate.limit, gate.window
|
||||
gate.mu.Unlock()
|
||||
|
||||
return SelfWakeStatus{
|
||||
Running: running,
|
||||
Pending: pending,
|
||||
Used: used,
|
||||
Limit: limit,
|
||||
WindowSec: int(window / time.Second),
|
||||
DefaultLimit: defaultSelfWakeLimit,
|
||||
DefaultWindowSec: int(defaultSelfWakeWindow / time.Second),
|
||||
LimitIsDefault: limit == defaultSelfWakeLimit,
|
||||
WindowIsDefault: window == defaultSelfWakeWindow,
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件守切片 C2 的**用量可见面**(design/12 §N C.5 的读侧):自排闸的只读快照
|
||||
// 必须与工具收据(SelfWakeReceipt)**同一口径**(同一个时间源、同一个窗口、
|
||||
// 同一个「消耗之后取值」),并且**读它不改变闸的状态**。
|
||||
|
||||
// TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot 是主判据:
|
||||
// 排一轮后 `Used` 必须等于收据里的 Used,待跑槽随之变占用;额度用尽后
|
||||
// `Used` 停在 `Limit`(不会因为一次拒绝而虚增)。
|
||||
//
|
||||
// 变异:把 SelfWakeStatus 的 Used 硬编码成 0(或改成 `len(granted)` 而不丢弃
|
||||
// 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。
|
||||
func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
// worker 卡住 ⇒ 排进去的那一轮不会把槽取走,`pending` 因此可确定地断言。
|
||||
w := newWakeThinker()
|
||||
w.holdSelfWake = make(chan struct{})
|
||||
b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w)
|
||||
|
||||
// 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。
|
||||
before := mustStatus(t, b)
|
||||
if !before.Running {
|
||||
t.Fatal("已 Start 的服务其快照必须报告 Running=true")
|
||||
}
|
||||
if before.Used != 0 || before.Pending {
|
||||
t.Fatalf("刚启动的快照 = %+v, want Used=0 / Pending=false", before)
|
||||
}
|
||||
if before.Limit != 2 || before.WindowSec != 3600 {
|
||||
t.Fatalf("快照口径 = %+v, want Limit=2 / WindowSec=3600", before)
|
||||
}
|
||||
if before.LimitIsDefault {
|
||||
t.Fatalf("上限 2 不该被算成默认值:%+v", before)
|
||||
}
|
||||
// 窗口仍取了代码默认(1 小时)⇒ WindowIsDefault 就是 true(这是「生效值恰好等于
|
||||
// 默认值」如实上报,而不是「配置文件没写」的断言)。
|
||||
if !before.WindowIsDefault {
|
||||
t.Fatalf("窗口取了默认值却没标记:%+v", before)
|
||||
}
|
||||
if before.DefaultLimit != defaultSelfWakeLimit || before.DefaultWindowSec != int(defaultSelfWakeWindow/time.Second) {
|
||||
t.Fatalf("默认值口径 = %+v, want %d / %d", before,
|
||||
defaultSelfWakeLimit, int(defaultSelfWakeWindow/time.Second))
|
||||
}
|
||||
|
||||
receipt, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "排一轮"})
|
||||
if err != nil {
|
||||
t.Fatalf("WakeNow: %v", err)
|
||||
}
|
||||
if !receipt.Accepted {
|
||||
t.Fatalf("收据 = %+v, want Accepted", receipt)
|
||||
}
|
||||
after := mustStatus(t, b)
|
||||
if after.Used != receipt.Used || after.Used != 1 {
|
||||
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 1(同一口径)", after.Used, receipt.Used)
|
||||
}
|
||||
if !after.Pending {
|
||||
t.Fatalf("排入后待跑槽应报告占用:%+v", after)
|
||||
}
|
||||
|
||||
// 第二次撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
|
||||
if _, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "再排一轮"}); err != nil {
|
||||
t.Fatalf("WakeNow 合并: %v", err)
|
||||
}
|
||||
merged := mustStatus(t, b)
|
||||
if merged.Used != 1 {
|
||||
t.Fatalf("合并后 Used = %d, want 1(合并不额外占额度)", merged.Used)
|
||||
}
|
||||
if merged.Limit != 2 {
|
||||
t.Fatalf("合并后 Limit = %d, want 2", merged.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfWakeStatus_StoppedServiceIsNotRunning 钉住「未运行」这一态:
|
||||
// 服务在跑之前(以及 Stop 之后)快照必须如实报 Running=false,好让面板显示
|
||||
// 「此刻排不出新的一轮」而不是「已用 0 次」。
|
||||
//
|
||||
// 变异:把 SelfWakeStatus 的 Running 直接写 true ⇒ 本用例变红。
|
||||
func TestSelfWakeStatus_StoppedServiceIsNotRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, _ := newBackthink(t, Config{}, Deps{Thinker: newWakeThinker(), State: &mockState{}})
|
||||
|
||||
st := mustStatus(t, b)
|
||||
if st.Running {
|
||||
t.Fatalf("未 Start 的快照 = %+v, want Running=false", st)
|
||||
}
|
||||
// 未运行时额度必然是 0,且上限已 normalize(面板仍能看到「上限 4/小时」)。
|
||||
if st.Used != 0 {
|
||||
t.Fatalf("未运行时 Used = %d, want 0", st.Used)
|
||||
}
|
||||
if st.Limit != defaultSelfWakeLimit || !st.LimitIsDefault || !st.WindowIsDefault {
|
||||
t.Fatalf("零配置的快照 = %+v, want 默认口径 %d/%v", st,
|
||||
defaultSelfWakeLimit, defaultSelfWakeWindow)
|
||||
}
|
||||
|
||||
b.Start(context.Background())
|
||||
if !mustStatus(t, b).Running {
|
||||
t.Fatal("Start 之后快照必须报告 Running=true")
|
||||
}
|
||||
if err := b.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if mustStatus(t, b).Running {
|
||||
t.Fatal("Stop 之后快照必须报告 Running=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfWakeStatus_ReadDoesNotConsumeBudget 钉住「读是纯读」:反复读快照
|
||||
// 不能让额度减少,也不能让待跑槽变化。
|
||||
//
|
||||
// 变异:把快照实现改成调用 limiter.Allow()(成功即消耗)⇒ 本用例在 Used 处变红。
|
||||
func TestSelfWakeStatus_ReadDoesNotConsumeBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, _ := newBackthink(t, Config{SelfWakeLimit: 1, SelfWakeWindow: time.Hour},
|
||||
Deps{Thinker: newWakeThinker(), State: &mockState{}})
|
||||
b.Start(context.Background())
|
||||
t.Cleanup(func() { _ = b.Stop(context.Background()) })
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if got := mustStatus(t, b).Used; got != 0 {
|
||||
t.Fatalf("第 %d 次读快照后 Used = %d, want 0(读不改闸)", i+1, got)
|
||||
}
|
||||
}
|
||||
// 读完之后额度仍然可用:唯一那一格必须能真排上一轮。
|
||||
receipt, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "读完之后再排"})
|
||||
if err != nil {
|
||||
t.Fatalf("WakeNow: %v", err)
|
||||
}
|
||||
if !receipt.Accepted || receipt.Used != 1 {
|
||||
t.Fatalf("收据 = %+v, want Accepted 且 Used=1(读快照没有偷走额度)", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfWakeStatus_UnreadableOnlyWhenGateMissing 钉住第二个返回值的诚实语义:
|
||||
// 正常构造的服务一定有闸(New 里建),只有零值 Backthink 才报告「看不到」。
|
||||
//
|
||||
// 变异:把 `return SelfWakeStatus{}, false` 改成 `true` ⇒ 本用例变红
|
||||
// (调用方会把 0/0 当成真实用量)。
|
||||
func TestSelfWakeStatus_UnreadableOnlyWhenGateMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := (&Backthink{}).SelfWakeStatus(); ok {
|
||||
t.Fatal("零值 Backthink 没有闸,必须报告 ok=false(不得伪造 0/0)")
|
||||
}
|
||||
b, _ := newBackthink(t, Config{}, Deps{Thinker: newWakeThinker(), State: &mockState{}})
|
||||
if _, ok := b.SelfWakeStatus(); !ok {
|
||||
t.Fatal("经 New 构造的服务一定有闸,必须报告 ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
// mustStatus 取快照并在「闸不在」时直接失败(正常构造的服务不该走到那一步)。
|
||||
func mustStatus(t *testing.T, b *Backthink) SelfWakeStatus {
|
||||
t.Helper()
|
||||
st, ok := b.SelfWakeStatus()
|
||||
if !ok {
|
||||
t.Fatal("SelfWakeStatus 报告闸不在:正常构造的服务不该这样")
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -45,6 +45,14 @@ ALTER TABLE backthink_records ADD COLUMN urgency TEXT NOT NULL DEFAULT ''`
|
||||
const createRecordsIndexSQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_backthink_records_at ON backthink_records(at)`
|
||||
|
||||
// createRecordsTriggerIndexSQL 支撑按触发源筛选的那条查询
|
||||
// (`WHERE trigger = ? ORDER BY id DESC`)。索引条目是 (trigger, rowid),而主键
|
||||
// `id` 就是 rowid ⇒ SQLite 能直接用一次索引范围扫描(反向)同时满足筛选与排序。
|
||||
// 它是**本片新增读路径**的配套:没有它,筛选就是全表扫描 + 排序(表按心跳每 15 分钟
|
||||
// 增长一行,一年就是三万余行)。
|
||||
const createRecordsTriggerIndexSQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_backthink_records_trigger ON backthink_records(trigger)`
|
||||
|
||||
const insertRecordSQL = `
|
||||
INSERT INTO backthink_records
|
||||
(trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error, urgency)
|
||||
@@ -56,6 +64,19 @@ FROM backthink_records
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`
|
||||
|
||||
// selectRecordsByTriggerSQL 与 selectRecordsSQL 逐字同形,只多一个按
|
||||
// `Record.Trigger` 的**服务端**筛选(参数化,不拼字符串)。
|
||||
//
|
||||
// 为什么下推到 SQL 而不是「取一页再在内存里筛」:那样 `limit` 就变成「这一页里
|
||||
// 恰好匹配的条数」,面板会看到「自我唤醒 0 条」而库里其实有 —— 一个静默的假空值
|
||||
// (design/12 §N C.6 的服务端筛选要的正是「筛不到 ≠ 没发生」)。
|
||||
const selectRecordsByTriggerSQL = `
|
||||
SELECT id, trigger, at, conclusion, should_push, push_pushed, push_route, push_detail, push_error, urgency
|
||||
FROM backthink_records
|
||||
WHERE trigger = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`
|
||||
|
||||
// recordsTableName 是本模块自建表的物理名(DDL 与判列共用一处)。
|
||||
const recordsTableName = "backthink_records"
|
||||
|
||||
@@ -115,6 +136,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if _, err := db.ExecContext(ctx, createRecordsIndexSQL); err != nil {
|
||||
return nil, fmt.Errorf("backthink: ensure backthink_records index: %w", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, createRecordsTriggerIndexSQL); err != nil {
|
||||
return nil, fmt.Errorf("backthink: ensure backthink_records trigger index: %w", err)
|
||||
}
|
||||
return &Store{db: db, log: log}, nil
|
||||
}
|
||||
|
||||
@@ -207,9 +231,7 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
|
||||
// defaultListLimit。urgency 列的空串如实读成「未知」(见
|
||||
// Record.Urgency),不做任何补齐或推断。
|
||||
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultListLimit
|
||||
}
|
||||
limit = normListLimit(limit)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -218,8 +240,40 @@ func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
return nil, fmt.Errorf("backthink: list records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRecords(rows, limit)
|
||||
}
|
||||
|
||||
records := make([]Record, 0, limit)
|
||||
// ListByTrigger 返回**某一触发源**的最近记录,最新的在前:语义与 List 逐字相同,
|
||||
// 只多一个参数化的 `trigger = ?`(design/12 §N C.6 的服务端筛选)。
|
||||
//
|
||||
// ⚠️ 它按**原始落库值**精确匹配(不模糊、不前缀):`Record.Trigger` 对事件轮是事件
|
||||
// 主题字符串,对内置轮是 `heartbeat` / `startup` / `self_wake` —— 合法取值由
|
||||
// `IsTriggerValue`(trigger_domain.go 的唯一真源)判定,本层不做二次裁剪。
|
||||
func (s *Store) ListByTrigger(ctx context.Context, trigger string, limit int) ([]Record, error) {
|
||||
limit = normListLimit(limit)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, selectRecordsByTriggerSQL, trigger, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("backthink: list records by trigger: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRecords(rows, limit)
|
||||
}
|
||||
|
||||
// normListLimit 把非正的 limit 归一为 defaultListLimit(两条读路径同一口径)。
|
||||
func normListLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return defaultListLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// scanRecords 是两条读路径共用的行解码(抽出来是为了让「列序 / 空串语义」只有
|
||||
// 一处真源:再加第三条查询时不会悄悄错位)。
|
||||
func scanRecords(rows *sql.Rows, capacity int) ([]Record, error) {
|
||||
records := make([]Record, 0, capacity)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r Record
|
||||
|
||||
@@ -127,7 +127,7 @@ type Trigger struct {
|
||||
// heartbeatTrigger 是周期性、低重要度的触发。低重要度意味着
|
||||
// 该轮很少打扰用户,遵守 "默认不发送 chat 类型消息"。
|
||||
func heartbeatTrigger() Trigger {
|
||||
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
|
||||
return Trigger{Source: HeartbeatSource, Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
|
||||
}
|
||||
|
||||
// StartupSummary 是启动轮思考(裁决 07.4.4,design/12 §1)的触发摘要。它同时是
|
||||
@@ -139,7 +139,7 @@ const StartupSummary = "process startup"
|
||||
// Kind 仍为 TriggerHeartbeat:启动轮的推送与心跳走完全相同的门与限频
|
||||
// (PushGate / 打扰级联 / 活动门),进程反复重启也不会刷屏主人。
|
||||
func startupTrigger() Trigger {
|
||||
return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
|
||||
return Trigger{Source: StartupSource, Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
|
||||
}
|
||||
|
||||
// triggerForEvent 把总线事件映射为 Trigger:主题给出可读摘要与主题级默认重要度,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
)
|
||||
|
||||
// 本文件是「触发源取值域」的**唯一真源**:`Record.Trigger`(= `Trigger.Source` 字符串)
|
||||
// 可能出现的取值,以及本服务订阅哪些事件主题。
|
||||
//
|
||||
// 为什么要独立出来:admin 的 `GET /api/v1/admin/backthink?trigger=` **服务端筛选**
|
||||
// (design/12 §N C.6,切片 C2)必须**如实拒绝未知取值**(400),而「未知」只能相对
|
||||
// 一个闭集判断。若在网关里另抄一份主题清单,新增订阅主题时就会出现「数据里有这个取值、
|
||||
// 筛选却报错」的静默漂移 —— 因此订阅表与筛选白名单从同一处取。
|
||||
//
|
||||
// ⚠️ 与 design/12 §C.6「`trigger` 的取值域不封闭」**不矛盾**:那条约束的是**显示**口径
|
||||
// (客户端按字面值显示、不做闭集校验、不隐藏不认识的取值);这里定义的是**筛选白名单**
|
||||
// —— 面向的是「筛选参数打错了要报错,而不是静默返回一个空列表」(静默的空列表会被
|
||||
// 读成「这段时间没有这一类的思考」,那正是本片要避免的假事实)。
|
||||
|
||||
// 内建触发标签。它们是 `Trigger.Source` 的字面值,也是 `Record.Trigger` 的落库值,
|
||||
// 生产构造点分别是 heartbeatTrigger / startupTrigger(trigger.go)。
|
||||
const (
|
||||
// HeartbeatSource 是周期性自主反思(含启动轮之外的每次心跳)的标签。
|
||||
HeartbeatSource = "heartbeat"
|
||||
// StartupSource 是进程启动后第一轮思考的标签(裁决 07.4.4)。
|
||||
StartupSource = "startup"
|
||||
)
|
||||
|
||||
// EventSubjects 返回本服务订阅的事件主题 —— **唯一真源**:`subscribe` 用它注册订阅,
|
||||
// `TriggerFilterValues` 用它拼筛选白名单,因此新增一个订阅主题时两处同时生效
|
||||
// (不会出现「数据库里有新主题的记录、筛选却 400」的漂移)。
|
||||
func EventSubjects() []string {
|
||||
return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent}
|
||||
}
|
||||
|
||||
// TriggerFilterValues 返回 `Record.Trigger` 可能出现的**全部取值**(闭集):
|
||||
// 三个内建标签(心跳 / 启动 / 自排)+ 订阅的事件主题。
|
||||
//
|
||||
// 它是 admin 筛选参数的白名单,也是「面板可选项」的真源;顺序稳定,便于报错文案
|
||||
// 与客户端下拉复用。
|
||||
func TriggerFilterValues() []string {
|
||||
return append([]string{HeartbeatSource, StartupSource, SelfWakeSource}, EventSubjects()...)
|
||||
}
|
||||
|
||||
// IsTriggerValue 报告 v 是否是 `Record.Trigger` 可能出现的取值(白名单判定)。
|
||||
func IsTriggerValue(v string) bool {
|
||||
for _, x := range TriggerFilterValues() {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ListByTrigger 按触发源读取记录(服务端筛选的读口,design/12 §N C.6 / 切片 C2)。
|
||||
// 它把筛选**下推**到存储层:`limit` 因此是「**匹配**的记录条数」,而不是
|
||||
// 「先取一页再筛掉不匹配的」(后者会给面板一个静默的假空值)。
|
||||
//
|
||||
// 委托给 `*Store`(自己不认识 SQL);`List` 的语义、排序、空串口径一字不改。
|
||||
func (b *Backthink) ListByTrigger(ctx context.Context, trigger string, limit int) ([]Record, error) {
|
||||
return b.store.ListByTrigger(ctx, trigger, limit)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
// 本文件是 `GET /api/v1/admin/backthink` 的**服务端筛选**一侧(design/12 §N C.6,
|
||||
// 切片 C2)。它从 admin_query.go 的 `adminListBackthink` 里把读侧抽出来,只做两件
|
||||
// 新事:① 解析并**校验** `?trigger=`(未知值如实 400);② 把筛选下推到存储层。
|
||||
//
|
||||
// 三条口径(都在 C.6 与本片派工里点名,实现不得自行放宽):
|
||||
// 1. **未知取值 ⇒ 400**,绝不静默忽略 —— 静默忽略会让面板在「显示的是全部」的同时
|
||||
// 声称「正在筛选自我唤醒」,那是一个假的筛选结果。
|
||||
// 2. **取值域 = 既有 + `self_wake`**:真源是 `backthink.TriggerFilterValues()`
|
||||
// (内建标签 + 本服务订阅的事件主题),不在网关里另抄一份清单。
|
||||
// 3. **筛选下推**(不是「取一页再筛」):`limit` 是匹配条数,否则面板会看到
|
||||
// 「0 条」而库里其实有(同上,假空值)。
|
||||
|
||||
// backthinkTriggerLister 是按触发源下推筛选的**可选**读口:生产实现
|
||||
// `*backthink.Backthink` 满足它(下面有编译期断言钉住这一点)。
|
||||
//
|
||||
// 为什么用可选接口而不是给 `BackthinkStore`(contracts.go,共享契约面)加一个方法:
|
||||
// 加方法会让网关包内既有的测试替身(notifications_test.go 的 stubBackthinkFeed)
|
||||
// 编译不过 —— 那属于别人的文件面。不满足本接口时退回「取一页再筛」并**如实记 WARN**
|
||||
// (见 listBackthinkRecords),语义退化不静默发生。
|
||||
type backthinkTriggerLister interface {
|
||||
ListByTrigger(ctx context.Context, trigger string, limit int) ([]backthink.Record, error)
|
||||
}
|
||||
|
||||
// 编译期钉住生产实现满足本可选口:形状一旦漂移,**编译期**就红,而不是等到运行期
|
||||
// 悄悄退化成「取一页再筛」(那条路径只有一条 WARN,肉眼很难发现)。
|
||||
var _ backthinkTriggerLister = (*backthink.Backthink)(nil)
|
||||
|
||||
// parseBackthinkTrigger 解析并校验 `?trigger=`。返回空串 = 不过滤(参数缺席)。
|
||||
//
|
||||
// 三个分支都刻意不静默:
|
||||
// - 缺席 ⇒ 不过滤(既有行为,老客户端零影响);
|
||||
// - **在场但为空** ⇒ 400:那多半是客户端把想筛选的值弄丢了,此时当作「不过滤」
|
||||
// 会让它以为自己在筛选(与「未知值静默忽略」是同一类假事实);
|
||||
// - 不在白名单 ⇒ 400,并在文案里列出可选取值(客户端可直接显示)。
|
||||
func parseBackthinkTrigger(q url.Values) (string, error) {
|
||||
if !q.Has("trigger") {
|
||||
return "", nil
|
||||
}
|
||||
v := strings.TrimSpace(q.Get("trigger"))
|
||||
if v == "" {
|
||||
return "", apperr.Wrap(apperr.ErrBadRequest, "invalid_trigger",
|
||||
"trigger 参数为空:要么省略该参数(返回全部),要么给一个合法取值:%s",
|
||||
strings.Join(backthink.TriggerFilterValues(), " / "))
|
||||
}
|
||||
if !backthink.IsTriggerValue(v) {
|
||||
return "", apperr.Wrap(apperr.ErrBadRequest, "invalid_trigger",
|
||||
"未知的触发源筛选 %q(可选值:%s)",
|
||||
v, strings.Join(backthink.TriggerFilterValues(), " / "))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// listBackthinkRecords 取记录:给了 trigger 时优先**下推**筛选;实现不提供下推口时
|
||||
// 退回「取一页再筛」并记一条 WARN(那条 WARN 就是「本次口径退化了」的留痕)。
|
||||
func (s *Server) listBackthinkRecords(ctx context.Context, trigger string, limit int) ([]backthink.Record, error) {
|
||||
if trigger == "" {
|
||||
return s.backthink.List(ctx, limit)
|
||||
}
|
||||
if lister, ok := s.backthink.(backthinkTriggerLister); ok {
|
||||
return lister.ListByTrigger(ctx, trigger, limit)
|
||||
}
|
||||
s.log.Warn("backthink trigger filter degraded: reader does not support pushdown",
|
||||
"trigger", trigger, "limit", limit)
|
||||
records, err := s.backthink.List(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterRecordsByTrigger(records, trigger), nil
|
||||
}
|
||||
|
||||
// filterRecordsByTrigger 是退化路径上的一页内筛选(下推不可用时才走这里)。
|
||||
func filterRecordsByTrigger(records []backthink.Record, trigger string) []backthink.Record {
|
||||
out := make([]backthink.Record, 0, len(records))
|
||||
for _, r := range records {
|
||||
if r.Trigger == trigger {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBackthinkList 是列表读侧的本体:取记录 → 补齐安静时段口径 → 下发。
|
||||
// 它由 `adminListBackthink`(admin_query.go 里的路由落点)在校验过筛选参数后调用。
|
||||
func (s *Server) writeBackthinkList(w http.ResponseWriter, r *http.Request, trigger string) {
|
||||
out := make([]AdminBackthinkItem, 0)
|
||||
if s.backthink == nil {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
records, err := s.listBackthinkRecords(r.Context(), trigger, backthinkListLimit)
|
||||
if err != nil {
|
||||
s.log.Error("list backthink", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "backthink_failed", "failed to list backthink records"))
|
||||
return
|
||||
}
|
||||
// 安静时段窗口取**运行时真正生效**的那一个(0/0 是「未填」而不是「没有安静时段」,
|
||||
// 见 admin_config_facts.go 的 effectiveQuietHours —— 与 backthink 的 normalize 同口径),
|
||||
// 否则 0/0 部署下会漏报真正的穿透。
|
||||
quietStart, quietEnd := s.effectiveQuietHours()
|
||||
for _, rec := range records {
|
||||
quiet := backthinkQuietOutcomeOf(rec, quietStart, quietEnd)
|
||||
out = append(out, AdminBackthinkItem{
|
||||
ID: strconv.FormatInt(rec.ID, 10),
|
||||
Trigger: rec.Trigger,
|
||||
Conclusion: rec.Conclusion,
|
||||
ShouldNotify: rec.ShouldPush,
|
||||
Pushed: rec.PushResult.Pushed,
|
||||
Route: string(rec.PushResult.Route),
|
||||
Detail: rec.PushResult.Detail,
|
||||
Bypassed: rec.PushResult.Bypassed,
|
||||
At: rec.At.UnixMilli(),
|
||||
// 紧急度(优先精确档位,旧行带下限标记)/ 是否穿透安静时段:只报存档可证的部分。
|
||||
Urgency: quiet.urgency,
|
||||
UrgencyLowerBound: quiet.urgencyLowerBound,
|
||||
QuietHoursBypassed: quiet.bypassed,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
)
|
||||
|
||||
// 本文件守切片 C2 的后端读侧(design/12 §N C.6 的服务端筛选 + C.5 的用量可见面):
|
||||
//
|
||||
// 1. `GET /api/v1/admin/backthink?trigger=` —— 白名单外的取值**如实 400**
|
||||
// (绝不静默忽略:静默会让面板一边显示全部、一边声称在筛选);
|
||||
// 2. 合法取值 ⇒ **下推**筛选(`limit` 是匹配条数);
|
||||
// 3. `GET /api/v1/admin/backthink/self-wake` —— 只读快照的三态(未接线 /
|
||||
// 未运行 / 运行中)必须可区分,且**不得**把「看不到」画成 0/0。
|
||||
|
||||
// triggerAwareFeed 是本片用的读侧替身:它实现可选口 ListByTrigger(下推筛选),
|
||||
// 用来钉住「网关真的走了下推路径」;`pushdownCalls` 记录下推被调用的次数。
|
||||
type triggerAwareFeed struct {
|
||||
records []backthink.Record
|
||||
pushdownCalls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *triggerAwareFeed) List(_ context.Context, limit int) ([]backthink.Record, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if limit > 0 && limit < len(f.records) {
|
||||
return append([]backthink.Record(nil), f.records[:limit]...), nil
|
||||
}
|
||||
return append([]backthink.Record(nil), f.records...), nil
|
||||
}
|
||||
|
||||
func (f *triggerAwareFeed) ListByTrigger(_ context.Context, trigger string, limit int) ([]backthink.Record, error) {
|
||||
f.pushdownCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([]backthink.Record, 0, len(f.records))
|
||||
for _, r := range f.records {
|
||||
if r.Trigger == trigger {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
if limit > 0 && limit < len(out) {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getBackthink 以 owner 身份请求列表端点(query 是带前导 `?` 的查询串)。
|
||||
func getBackthink(t *testing.T, tsURL, query, tok string) (int, []byte, []string) {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodGet, tsURL+"/api/v1/admin/backthink"+query, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, raw, nil
|
||||
}
|
||||
var list struct {
|
||||
Data []AdminBackthinkItem `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
out := make([]string, 0, len(list.Data))
|
||||
for _, it := range list.Data {
|
||||
out = append(out, it.Trigger)
|
||||
}
|
||||
return resp.StatusCode, raw, out
|
||||
}
|
||||
|
||||
// sampleFeed 返回一份混合触发源的记录(最新在最前,与后端口径一致)。
|
||||
func sampleFeed() *triggerAwareFeed {
|
||||
return &triggerAwareFeed{records: []backthink.Record{
|
||||
{ID: 5, Trigger: backthink.SelfWakeSource, At: time.UnixMilli(5000)},
|
||||
{ID: 4, Trigger: backthink.HeartbeatSource, At: time.UnixMilli(4000)},
|
||||
{ID: 3, Trigger: backthink.SelfWakeSource, At: time.UnixMilli(3000)},
|
||||
{ID: 2, Trigger: "iot.device_update", At: time.UnixMilli(2000)},
|
||||
{ID: 1, Trigger: backthink.HeartbeatSource, At: time.UnixMilli(1000)},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_UnknownTriggerIs400 是 C.6 最要紧的一条:白名单外的 `trigger`
|
||||
// 必须**如实 400**,绝不静默返回全部(那会让「正在筛选自我唤醒」变成假话)。
|
||||
//
|
||||
// 变异:把 parseBackthinkTrigger 的未知值分支改成 `return "", nil`(静默忽略)
|
||||
// ⇒ 本用例在「期望 400」处变红。
|
||||
func TestAdminBackthink_UnknownTriggerIs400(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := sampleFeed()
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
for _, bad := range []string{"?trigger=event", "?trigger=self-wake", "?trigger=HEARTBEAT", "?trigger=all"} {
|
||||
status, raw, _ := getBackthink(t, ts.URL, bad, tok)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s: status = %d, want 400(body=%s)", bad, status, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"invalid_trigger"`) {
|
||||
t.Fatalf("%s: 400 必须带稳定的错误码 invalid_trigger:%s", bad, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), backthink.SelfWakeSource) {
|
||||
t.Fatalf("%s: 文案应列出可选取值(含 self_wake):%s", bad, raw)
|
||||
}
|
||||
}
|
||||
if feed.pushdownCalls != 0 {
|
||||
t.Fatalf("非法取值不该触达存储:pushdownCalls = %d", feed.pushdownCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_EmptyTriggerIs400 钉住「在场但为空」的诚实口径:`?trigger=`
|
||||
// 多半是客户端把想筛选的值弄丢了,此时当作「不过滤」会让它以为自己筛过了。
|
||||
//
|
||||
// 变异:把空值分支合并进「缺席 ⇒ 不过滤」⇒ 本用例变红。
|
||||
func TestAdminBackthink_EmptyTriggerIs400(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: sampleFeed()})
|
||||
status, raw, _ := getBackthink(t, ts.URL, "?trigger=", signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400(body=%s)", status, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"invalid_trigger"`) {
|
||||
t.Fatalf("400 必须带 invalid_trigger:%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_TriggerFilterPushesDown 钉住合法取值走**下推**:只回匹配行,
|
||||
// 且值域里的事件主题同样可筛。
|
||||
//
|
||||
// 变异:把 listBackthinkRecords 的下推分支删掉(一律走 List + 内存筛)⇒
|
||||
// pushdownCalls 断言变红(这也正是「口径退化」必须留痕的那条路径)。
|
||||
func TestAdminBackthink_TriggerFilterPushesDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := sampleFeed()
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
status, raw, got := getBackthink(t, ts.URL, "?trigger="+backthink.SelfWakeSource, tok)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(got) != 2 || got[0] != backthink.SelfWakeSource || got[1] != backthink.SelfWakeSource {
|
||||
t.Fatalf("筛选结果 = %v, want 两条自排", got)
|
||||
}
|
||||
if feed.pushdownCalls != 1 {
|
||||
t.Fatalf("pushdownCalls = %d, want 1(筛选必须下推)", feed.pushdownCalls)
|
||||
}
|
||||
|
||||
// 事件主题也在白名单里(它是既有取值,不是新增的)。
|
||||
status, raw, got = getBackthink(t, ts.URL, "?trigger=iot.device_update", tok)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "iot.device_update" {
|
||||
t.Fatalf("筛选结果 = %v, want 一条 iot.device_update", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_NoTriggerKeepsExistingShape 钉住老行为零改动:不给参数时
|
||||
// 返回全部(顺序与后端一致),且**不**调用下推口。
|
||||
//
|
||||
// 变异:让缺参时也走 ListByTrigger ⇒ pushdownCalls 变红。
|
||||
func TestAdminBackthink_NoTriggerKeepsExistingShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := sampleFeed()
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
status, raw, got := getBackthink(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("未筛选时应有 5 条,得到 %v", got)
|
||||
}
|
||||
if feed.pushdownCalls != 0 {
|
||||
t.Fatalf("未筛选不该走下推口:pushdownCalls = %d", feed.pushdownCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_DegradedReaderStillFiltersOnePage 钉住**退化路径**:读侧替身
|
||||
// 不实现下推口时,仍要给出筛选结果(一页内筛),并如实记 WARN(WARN 无法在响应里
|
||||
// 断言,这里是「行为不静默失败」的探针)。
|
||||
//
|
||||
// 变异:删掉退化分支 ⇒ 该路径会 panic(类型断言后直接调用)或返回全部 ⇒ 变红。
|
||||
func TestAdminBackthink_DegradedReaderStillFiltersOnePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
// stubBackthinkFeed(notifications_test.go)只实现 List ⇒ 走退化路径。
|
||||
feed := &stubBackthinkFeed{records: sampleFeed().records}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
status, raw, got := getBackthink(t, ts.URL, "?trigger="+backthink.SelfWakeSource,
|
||||
signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("退化路径也应筛出两条自排,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// statusReaderFeed 是自排状态读口的替身(网关只认这个可选口)。
|
||||
type statusReaderFeed struct {
|
||||
triggerAwareFeed
|
||||
st backthink.SelfWakeStatus
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (f *statusReaderFeed) SelfWakeStatus() (backthink.SelfWakeStatus, bool) { return f.st, f.ok }
|
||||
|
||||
// getSelfWakeStatus 请求用量端点并解码。
|
||||
func getSelfWakeStatus(t *testing.T, tsURL, tok string) (int, []byte, AdminSelfWakeStatus) {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodGet, tsURL+"/api/v1/admin/backthink/self-wake", nil, tok)
|
||||
var env struct {
|
||||
Data AdminSelfWakeStatus `json:"data"`
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, env.Data
|
||||
}
|
||||
|
||||
// TestAdminSelfWakeStatus_RunningSnapshot 钉住正常态:生效口径 + 本窗口用量 +
|
||||
// 待跑槽 + 默认值标记一起下发。
|
||||
//
|
||||
// 变异:把 Gate 里的 Used 换成 0(或漏配 Gate)⇒ 本用例变红。
|
||||
func TestAdminSelfWakeStatus_RunningSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &statusReaderFeed{
|
||||
st: backthink.SelfWakeStatus{
|
||||
Running: true, Pending: true, Used: 3,
|
||||
Limit: 4, WindowSec: 3600,
|
||||
DefaultLimit: 4, DefaultWindowSec: 3600,
|
||||
LimitIsDefault: true, WindowIsDefault: true,
|
||||
},
|
||||
ok: true,
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
status, raw, got := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if !got.Available || !got.Running {
|
||||
t.Fatalf("status = %+v, want available+running", got)
|
||||
}
|
||||
if got.Reason != "" {
|
||||
t.Fatalf("运行中不该带说明文案:%+v", got)
|
||||
}
|
||||
if got.Gate == nil {
|
||||
t.Fatalf("运行中必须带 gate:%s", raw)
|
||||
}
|
||||
if got.Gate.Used != 3 || got.Gate.Limit != 4 || !got.Gate.Pending || got.Gate.WindowSec != 3600 {
|
||||
t.Fatalf("gate = %+v, want used=3 limit=4 pending=true window=3600", got.Gate)
|
||||
}
|
||||
if !got.Gate.LimitIsDefault || !got.Gate.WindowIsDefault {
|
||||
t.Fatalf("gate 默认值标记 = %+v, want 双 true", got.Gate)
|
||||
}
|
||||
if got.ConfigFile != "config/backthink.json" {
|
||||
t.Fatalf("configFile = %q, want config/backthink.json", got.ConfigFile)
|
||||
}
|
||||
// 原始 JSON 也要真的带这些键(形状给客户端看,不只是 Go 侧结构)。
|
||||
for _, want := range []string{`"available":true`, `"running":true`, `"used":3`, `"pending":true`} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("响应缺 %s:%s", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSelfWakeStatus_HonestThreeStates 钉住「看不到 ≠ 0 次」这两态:
|
||||
// 未接线 / 实现不提供读口 / 闸不在 ⇒ `available:false` 且 `gate` 缺席;
|
||||
// 已接线但未运行 ⇒ `available:true, running:false` + 说明文案 + 配置面数字仍在。
|
||||
//
|
||||
// 变异:把未接线一态改成返回 `gate:{used:0,limit:0}`(伪造 0/0)⇒ 变红。
|
||||
func TestAdminSelfWakeStatus_HonestThreeStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("未接线", func(t *testing.T) {
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{})
|
||||
status, raw, got := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if got.Available || got.Gate != nil {
|
||||
t.Fatalf("未接线时不得给 gate:%s", raw)
|
||||
}
|
||||
if got.Reason == "" {
|
||||
t.Fatalf("未接线必须说明原因:%s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"gate"`) {
|
||||
t.Fatalf("未接线时 gate 必须缺席:%s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("实现不提供读口", func(t *testing.T) {
|
||||
// stubBackthinkFeed 不实现 SelfWakeStatus ⇒ 这一态。
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: &stubBackthinkFeed{}})
|
||||
_, raw, got := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if got.Available || got.Gate != nil || got.Reason == "" {
|
||||
t.Fatalf("status = %+v, body = %s", got, raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("闸不在", func(t *testing.T) {
|
||||
feed := &statusReaderFeed{ok: false}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
_, raw, got := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if got.Available || got.Gate != nil || got.Reason == "" {
|
||||
t.Fatalf("status = %+v, body = %s", got, raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("已接线未运行", func(t *testing.T) {
|
||||
feed := &statusReaderFeed{
|
||||
st: backthink.SelfWakeStatus{Limit: 4, WindowSec: 3600, DefaultLimit: 4, DefaultWindowSec: 3600},
|
||||
ok: true,
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
_, raw, got := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if !got.Available || got.Running {
|
||||
t.Fatalf("status = %+v, want available && !running", got)
|
||||
}
|
||||
if got.Gate == nil || got.Gate.Limit != 4 {
|
||||
t.Fatalf("未运行时配置面数字仍应可见:%s", raw)
|
||||
}
|
||||
if got.Reason == "" {
|
||||
t.Fatalf("未运行必须说明「此刻排不出新的一轮」:%s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAdminSelfWakeStatus_RequiresOwnerOrAdmin 钉住档位:与列表端点同门同档,
|
||||
// viewer 读不到(含她自排的成本口径)。匿名仍旧 401(fail-closed)。
|
||||
//
|
||||
// 变异:把路由从 writeAuth 改成 viewerAuth ⇒ 本用例的 viewer 断言变红。
|
||||
func TestAdminSelfWakeStatus_RequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &statusReaderFeed{st: backthink.SelfWakeStatus{Running: true, Limit: 4, WindowSec: 3600}, ok: true}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
|
||||
if status, _, _ := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "v", auth.RoleViewer)); status != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d, want 403", status)
|
||||
}
|
||||
if status, _, _ := getSelfWakeStatus(t, ts.URL, ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("匿名 status = %d, want 401", status)
|
||||
}
|
||||
if status, _, _ := getSelfWakeStatus(t, ts.URL, signAny(t, jwt, "a", auth.RoleAdmin)); status != http.StatusOK {
|
||||
t.Fatalf("admin status = %d, want 200", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBackthinkTrigger_Direct 直接钉住解析函数的两条边界(不经过 HTTP):
|
||||
// 缺席 = 不过滤;带空白 = 先 trim 再判定(` self_wake ` 是合法取值)。
|
||||
//
|
||||
// 变异:把 TrimSpace 去掉 ⇒ 第二组断言变红。
|
||||
func TestParseBackthinkTrigger_Direct(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseBackthinkTrigger(url.Values{})
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("缺席时 got=%q err=%v, want 空串 + nil", got, err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("trigger", " "+backthink.SelfWakeSource+" ")
|
||||
got, err = parseBackthinkTrigger(q)
|
||||
if err != nil {
|
||||
t.Fatalf("trim 后合法:%v", err)
|
||||
}
|
||||
if got != backthink.SelfWakeSource {
|
||||
t.Fatalf("got = %q, want %q(应先 trim)", got, backthink.SelfWakeSource)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeThinker 是给「真服务」用的最小 Thinker:只要它被调用就返回一句结论。
|
||||
// 它的存在让下面那条端到端用例不必依赖任何解释器或模型。
|
||||
type fakeThinker struct{}
|
||||
|
||||
func (fakeThinker) Think(context.Context, string, string) (string, error) {
|
||||
return "(测试结论)", nil
|
||||
}
|
||||
|
||||
// TestAdminBackthink_RealServiceSatisfiesBothReadPorts 是本片的**跨层闭环**:
|
||||
// 用**真的** `*backthink.Backthink`(真存储 + 真滑窗 + 真待跑槽,内存 SQLite)当读侧,
|
||||
// 经**真的** HTTP 路由读回来。它钉住三件只有真跑才成立的事:
|
||||
//
|
||||
// 1. 生产实现确实满足网关的两个**可选**读口(`backthinkTriggerLister` /
|
||||
// `selfWakeStatusReader`)—— 两个 `var _ = (*backthink.Backthink)(nil)` 是编译期
|
||||
// 的第一道,这里是运行期的第二道(少了它,「可选口」会静默退化成「不提供读口」);
|
||||
// 2. 服务端筛选对真存储真的按 `trigger = ?` 取(下推,不是拿一页再筛):
|
||||
// 启动轮落的是 `startup`,自排轮落的是 `self_wake`,两者互不串门;
|
||||
// 3. 用量快照与真闸同步:`WakeNow` 消耗一格后,端点报的 `used` 必须跟着变,
|
||||
// 且**不是** 0/0 的假快照。
|
||||
func TestAdminBackthink_RealServiceSatisfiesBothReadPorts(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
handle, err := db.Open(ctx, ":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = handle.Close() })
|
||||
|
||||
bt, err := backthink.New(backthink.Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, backthink.Deps{
|
||||
Thinker: fakeThinker{},
|
||||
DB: handle,
|
||||
Log: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("backthink.New: %v", err)
|
||||
}
|
||||
bt.Start(ctx)
|
||||
t.Cleanup(func() { _ = bt.Stop(context.Background()) })
|
||||
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: bt})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// 等启动轮落盘(异步),再用服务端筛选把它捞出来:`startup` 有、`self_wake` 还没有。
|
||||
waitFor(t, func() bool {
|
||||
_, _, got := getBackthink(t, ts.URL, "?trigger="+backthink.StartupSource, tok)
|
||||
return len(got) >= 1
|
||||
}, "启动轮落盘(?trigger=startup 应有一条)")
|
||||
|
||||
status, raw, got := getBackthink(t, ts.URL, "?trigger="+backthink.SelfWakeSource, tok)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("自排还没发生,服务端筛选不该给出记录:%v", got)
|
||||
}
|
||||
|
||||
// 用量快照:真闸(limit=2),且此刻已用 0、在运行。
|
||||
_, raw, before := getSelfWakeStatus(t, ts.URL, tok)
|
||||
if !before.Available || !before.Running || before.Gate == nil {
|
||||
t.Fatalf("真服务的用量快照 = %+v, body = %s", before, raw)
|
||||
}
|
||||
if before.Gate.Used != 0 || before.Gate.Limit != 2 {
|
||||
t.Fatalf("gate = %+v, want used=0 limit=2", before.Gate)
|
||||
}
|
||||
|
||||
// 真排一轮:主人环境 + 真服务 ⇒ 额度消耗一格。
|
||||
receipt, err := bt.WakeNow(loop.WithSessionEnv(ctx, loop.OwnerEnv()),
|
||||
backthink.SelfWakeRequest{Reason: "端到端自排一轮"})
|
||||
if err != nil {
|
||||
t.Fatalf("WakeNow: %v", err)
|
||||
}
|
||||
if !receipt.Accepted {
|
||||
t.Fatalf("receipt = %+v, want Accepted", receipt)
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
_, _, rows := getBackthink(t, ts.URL, "?trigger="+backthink.SelfWakeSource, tok)
|
||||
return len(rows) >= 1
|
||||
}, "自排轮落盘(?trigger=self_wake 应有一条)")
|
||||
|
||||
_, raw, after := getSelfWakeStatus(t, ts.URL, tok)
|
||||
if after.Gate == nil {
|
||||
t.Fatalf("用量快照缺 gate:%s", raw)
|
||||
}
|
||||
if after.Gate.Used != 1 {
|
||||
t.Fatalf("gate.Used = %d, want 1(真消耗一格;0 就是假快照)", after.Gate.Used)
|
||||
}
|
||||
if after.Gate.Limit != receipt.Limit {
|
||||
t.Fatalf("端点上限 %d 与收据上限 %d 不一致", after.Gate.Limit, receipt.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor 轮询 cond 直到为真或超时(异步落盘的同步点)。
|
||||
func waitFor(t *testing.T, cond func() bool, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("等待超时:%s", what)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
)
|
||||
|
||||
// 本文件是 `GET /api/v1/admin/backthink/self-wake` 的**只读用量可见面**
|
||||
// (design/12 §N C.5 的读侧,切片 C2)。
|
||||
//
|
||||
// 为什么要有它:C.5 的「本窗口已用 N / 上限 M」此前**只**出现在被拒那一轮的审计文案里
|
||||
// (`backthink.self_wake.capped` 的 result)—— 也就是「已经被拒过一次」才第一次看得见。
|
||||
// 面板要能提前告诉主人「还能自排几次」,就得有一个只读快照出口。
|
||||
//
|
||||
// 它是**纯读**:不写库、不落审计、不碰闸(读快照永远不改变闸的状态),与列表端点同门
|
||||
// 同档(writeAuth = owner|admin;含她自排的成本口径与运行状态,不是给 viewer 的监控投影)。
|
||||
//
|
||||
// ⚠️ 它**不是**写口:调这两个旋钮要改 `$DATA_DIR/config/backthink.json` + 重启
|
||||
// (启动期读一次,与 `QUIET_*` 同口径)。面板写路径未做,原因见 runtimeconfig
|
||||
// `BackgroundTuning` 的注释(要经 design/09 §5 的面板声明机制,需组合根接线)。
|
||||
|
||||
// selfWakeStatusReader 是自排闸只读快照的**可选**读口:生产实现
|
||||
// `*backthink.Backthink` 满足它。用可选接口的理由同 backthinkTriggerLister
|
||||
// (不改共享契约、不弄坏既有测试替身);不满足时如实回「本实现不提供读口」。
|
||||
type selfWakeStatusReader interface {
|
||||
SelfWakeStatus() (backthink.SelfWakeStatus, bool)
|
||||
}
|
||||
|
||||
// 编译期钉住生产实现满足本可选口(形状漂移在编译期立刻显形,而不是等到运行期
|
||||
// 悄悄变成「本实现不提供自排状态读口」)。
|
||||
var _ selfWakeStatusReader = (*backthink.Backthink)(nil)
|
||||
|
||||
// AdminSelfWakeGate 是自排闸的线上投影:**进程生效**的上限 / 窗口 + 本窗口用量 +
|
||||
// 待跑槽占用,外带代码默认值(供面板区分「默认 / 已改」)。
|
||||
type AdminSelfWakeGate struct {
|
||||
// Pending 报告容量 1 的待跑槽是否占用(有一轮自排排着队还没跑)。
|
||||
Pending bool `json:"pending"`
|
||||
// Used 是本窗口已用额度(与工具收据 `SelfWakeReceipt.Used` 同一口径)。
|
||||
Used int `json:"used"`
|
||||
// Limit / WindowSec 是**进程生效**的上限与窗口(已按「≤ 0 回落默认」normalize)。
|
||||
Limit int `json:"limit"`
|
||||
WindowSec int `json:"windowSec"`
|
||||
// DefaultLimit / DefaultWindowSec 是代码默认值;LimitIsDefault / WindowIsDefault
|
||||
// 报告生效值**恰好等于**默认值。
|
||||
//
|
||||
// ⚠️ 它**不是**「配置文件里没写这个键」的断言:网关**不读** `backthink.json`
|
||||
// (与 config-facts 的 isDefault 同一条诚实口径 —— 无从不区分「配了 4」与「没配」)。
|
||||
DefaultLimit int `json:"defaultLimit"`
|
||||
DefaultWindowSec int `json:"defaultWindowSec"`
|
||||
LimitIsDefault bool `json:"limitIsDefault"`
|
||||
WindowIsDefault bool `json:"windowIsDefault"`
|
||||
}
|
||||
|
||||
// AdminSelfWakeStatus 是 `GET /api/v1/admin/backthink/self-wake` 的响应。
|
||||
//
|
||||
// 三态**必须可区分**(面板不得把「看不到」画成「0 次」):
|
||||
// - `available:false` ⇒ 本进程给不出快照(服务未接线 / 实现不提供读口 / 闸不在),
|
||||
// 此时 `gate` **缺席**、`reason` 说明原因;
|
||||
// - `available:true, running:false` ⇒ 已接线但不在运行(未启动或已停止):`gate`
|
||||
// 仍带配置面数字(上限/窗口/默认值),但此刻她排不出新的一轮 —— `reason` 说明;
|
||||
// - `available:true, running:true` ⇒ 正常态,`reason` 缺席。
|
||||
type AdminSelfWakeStatus struct {
|
||||
Available bool `json:"available"`
|
||||
Running bool `json:"running"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// ConfigFile 是「要改哪个文件」的相对落点(`config/backthink.json`,相对 `$DATA_DIR`):
|
||||
// 绝对路径随部署而变,接口只给相对落点,由面板拼上下文说明。
|
||||
ConfigFile string `json:"configFile,omitempty"`
|
||||
Gate *AdminSelfWakeGate `json:"gate,omitempty"`
|
||||
}
|
||||
|
||||
// adminSelfWakeStatus 下发自排闸的只读快照(见文件头注释)。
|
||||
func (s *Server) adminSelfWakeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// 未接线:如实现状作答(与 adminListBackthink 返回空列表同口径:200 + 诚实说明,
|
||||
// 不假装有一个 0/0 的闸)。
|
||||
if s.backthink == nil {
|
||||
writeJSON(w, http.StatusOK, AdminSelfWakeStatus{
|
||||
Reason: "后台思考服务未接线:本进程不产生自排轮",
|
||||
})
|
||||
return
|
||||
}
|
||||
reader, ok := s.backthink.(selfWakeStatusReader)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, AdminSelfWakeStatus{
|
||||
Reason: "当前后端的后台思考实现不提供自排状态读口",
|
||||
})
|
||||
return
|
||||
}
|
||||
st, ok := reader.SelfWakeStatus()
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, AdminSelfWakeStatus{
|
||||
Reason: "自排闸不在(后台思考服务未构造):没有可报的用量",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
out := AdminSelfWakeStatus{
|
||||
Available: true,
|
||||
Running: st.Running,
|
||||
ConfigFile: runtimeconfig.BackthinkConfigRelPath,
|
||||
Gate: &AdminSelfWakeGate{
|
||||
Pending: st.Pending,
|
||||
Used: st.Used,
|
||||
Limit: st.Limit,
|
||||
WindowSec: st.WindowSec,
|
||||
DefaultLimit: st.DefaultLimit,
|
||||
DefaultWindowSec: st.DefaultWindowSec,
|
||||
LimitIsDefault: st.LimitIsDefault,
|
||||
WindowIsDefault: st.WindowIsDefault,
|
||||
},
|
||||
}
|
||||
if !st.Running {
|
||||
out.Reason = "后台思考未在运行(未启动或已停止):此刻她排不出新的一轮"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package gateway
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -363,41 +362,17 @@ func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// adminListBackthink 列出最近的后台思考记录(管理读)。
|
||||
//
|
||||
// 它现在只做**筛选参数校验**并把读侧交给 writeBackthinkList
|
||||
// (admin_backthink_filter.go,切片 C2):`?trigger=` 未知取值如实 400
|
||||
// (design/12 §N C.6 的服务端筛选),合法取值则下推到存储层取匹配条数。
|
||||
func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]AdminBackthinkItem, 0)
|
||||
if s.backthink == nil {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
records, err := s.backthink.List(r.Context(), backthinkListLimit)
|
||||
trigger, err := parseBackthinkTrigger(r.URL.Query())
|
||||
if err != nil {
|
||||
s.log.Error("list backthink", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "backthink_failed", "failed to list backthink records"))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
// 安静时段窗口取**运行时真正生效**的那一个(0/0 是「未填」而不是「没有安静时段」,
|
||||
// 见 admin_config_facts.go 的 effectiveQuietHours —— 与 backthink 的 normalize 同口径),
|
||||
// 否则 0/0 部署下会漏报真正的穿透。
|
||||
quietStart, quietEnd := s.effectiveQuietHours()
|
||||
for _, rec := range records {
|
||||
quiet := backthinkQuietOutcomeOf(rec, quietStart, quietEnd)
|
||||
out = append(out, AdminBackthinkItem{
|
||||
ID: strconv.FormatInt(rec.ID, 10),
|
||||
Trigger: rec.Trigger,
|
||||
Conclusion: rec.Conclusion,
|
||||
ShouldNotify: rec.ShouldPush,
|
||||
Pushed: rec.PushResult.Pushed,
|
||||
Route: string(rec.PushResult.Route),
|
||||
Detail: rec.PushResult.Detail,
|
||||
Bypassed: rec.PushResult.Bypassed,
|
||||
At: rec.At.UnixMilli(),
|
||||
// 紧急度(优先精确档位,旧行带下限标记)/ 是否穿透安静时段:只报存档可证的部分。
|
||||
Urgency: quiet.urgency,
|
||||
UrgencyLowerBound: quiet.urgencyLowerBound,
|
||||
QuietHoursBypassed: quiet.bypassed,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
s.writeBackthinkList(w, r, trigger)
|
||||
}
|
||||
|
||||
// adminListRag 列出知识库文档,并在带 ?query= 时
|
||||
|
||||
@@ -199,6 +199,13 @@ func (s *Server) registerAdminRoutes(
|
||||
mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog)))
|
||||
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
|
||||
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
|
||||
// 自排闸只读快照(切片 C2,**v0.82**;design/12 §N C.5 的读侧):把
|
||||
// `$DATA_DIR/config/backthink.json` 的**进程生效值**(上限/窗口 + 代码默认值)
|
||||
// 与「本窗口已用 N / 上限 M」(selfWakeGate 的只读镜像)下发给面板。
|
||||
// 只读、不审计、不写库;与上面的列表端点同门同档(writeAuth = owner|admin)
|
||||
// —— 它是她自排的成本口径与运行状态,不是给 viewer 的监控投影。
|
||||
// ⚠️ 它**不是**写口:调这两个旋钮要改配置文件 + 重启(启动期读一次)。
|
||||
mux.Handle("GET /api/v1/admin/backthink/self-wake", writeAuth(http.HandlerFunc(s.adminSelfWakeStatus)))
|
||||
// 最近一轮链路图快照(**v0.71**):`graph_update` 只走实时帧、从不重放,
|
||||
// 前端图桶也不持久化 ⇒ 「轮次结束后才打开的页面」此前只能看到空态。
|
||||
// 只读、不审计;与同一张图的 WS 下行帧同档(只发 owner|admin,
|
||||
|
||||
Reference in new issue
Block a user