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
@@ -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