feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」 此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答 (批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。 四条不可走样: - 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器 工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定, 配置无权翻转; - 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点 (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前; - 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复; - 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom), 被静默的消息进不了批。 实现落点: - lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记; - coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数; - coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车; - coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch; - runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键); - gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 + writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批); - 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。 M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条 按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。 回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build + 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
This commit is contained in:
1 parent
679c8abf1d
commit
ca8577f05b
21 files changed
+3616
-244
No files matched your search
@@ -0,0 +1,249 @@
|
||||
package gateway
|
||||
|
||||
// 本文件承载**渠道静默窗口合并**(M23)的管理读写端点:
|
||||
//
|
||||
// GET /api/v1/admin/channels/coalesce 读:盘上的窗口参数 + 取值规则 + 只读事实
|
||||
// PUT /api/v1/admin/channels/coalesce 写:三态补丁 + 原子落盘 + 热生效 + 审计
|
||||
//
|
||||
// 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
|
||||
// 不该让她回三次 —— 但「等多久 / 最多攒多少」这类参数只该在面板上调,
|
||||
// 不该为改一个 2.0s → 3.0s 去 SSH 改文件(与 memory-tidy 调优同一取向)。
|
||||
//
|
||||
// 四条口径(每条都在 admin_coalesce_test.go 里钉住):
|
||||
//
|
||||
// 1. **三态**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload`
|
||||
// 且**盘上一个字节都不动**(校验全部发生在落盘之前,见
|
||||
// runtimeconfig.ValidateCoalescePatch)。
|
||||
// 2. **写端点带审计**(动作 `channels.coalesce`),`writeAuth`(owner|admin)——
|
||||
// 与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面,
|
||||
// 因此不是 owner-only;也不给 viewer(它是渠道调优,不是监控投影)。
|
||||
// 3. **热生效**:桥每次发车前从**同一个** store 实例读生效值,因此保存后下一次发车
|
||||
// 就用新值 —— 响应里的 `hot:true` 是事实,不是承诺。
|
||||
// 4. **主人私聊是只读事实**:车道类型由 actor 元数据决定(`IsOwner() && !IsGroupChat()`
|
||||
// ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**),本端点**没有任何键**能翻转它。
|
||||
// `ownerPrivate:"never"` 就是这条事实的下发形状,面板据此只显示一行说明、不给开关。
|
||||
//
|
||||
// 零新 `.env` 键(design/13 §2.1):配置落在 `$DATA_DIR/config/coalesce.json`。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// coalesceLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
|
||||
coalesceLogSource = "gateway/channels-coalesce"
|
||||
// coalesceAuditAction 是写端点落审计的动作名(与 channels.* 同族命名)。
|
||||
coalesceAuditAction = "channels.coalesce"
|
||||
// coalesceMaxBodyBytes 是写端点请求体的上限:这是一份给人改的小配置
|
||||
// (6 个标量键 + 少量会话覆盖),正常请求体不到 1 KiB。
|
||||
coalesceMaxBodyBytes = 32 << 10
|
||||
// coalesceOwnerPrivateFact 是「主人私聊不受此设置影响」的**机器可读**形式。
|
||||
// 它不是开关值:没有任何请求体字段能把它改成别的(改车道类型要改 actor 判定)。
|
||||
coalesceOwnerPrivateFact = "never"
|
||||
)
|
||||
|
||||
// CoalesceConfigStore 是「渠道静默窗口配置」的可选能力:生产装配的 `s.channels`
|
||||
// 就是 cmd/cyrene 的 `*runtimeChannels`,它背后持有**与 bridge 同一个**
|
||||
// `runtimeconfig.CoalesceStore`(因此写进内存即热生效)。
|
||||
//
|
||||
// 用可选接口而不是往 Deps/Server 加字段:渠道调优本来就在这个依赖面上,
|
||||
// 加字段会让「谁持有什么」多一条没人复得到的路径(与 gateway 其它可选能力同一手法)。
|
||||
type CoalesceConfigStore interface {
|
||||
// CoalescePath 返回配置文件落点(面板如实回显改的是哪个文件)。
|
||||
CoalescePath() string
|
||||
// CoalescePresent 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
|
||||
CoalescePresent() bool
|
||||
// CoalesceSnapshot 返回盘上那份原值(未 clamp,供面板回显「你写进去的是什么」)。
|
||||
CoalesceSnapshot() runtimeconfig.CoalesceFile
|
||||
// ApplyCoalesce 应用三态补丁并原子落盘。返回是否真的改动了盘上的字节。
|
||||
ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error)
|
||||
}
|
||||
|
||||
// AdminCoalescePolicy 是一组窗口参数(生效值)。
|
||||
type AdminCoalescePolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
WindowMs int `json:"windowMs"`
|
||||
MaxWaitMs int `json:"maxWaitMs"`
|
||||
MaxBatchMessages int `json:"maxBatchMessages"`
|
||||
ApplyToStrangers bool `json:"applyToStrangers"`
|
||||
StrangerWindowMs int `json:"strangerWindowMs"`
|
||||
}
|
||||
|
||||
// AdminCoalesceRules 是取值规则(随读下发 ⇒ 面板表单与后端校验不可能两套规则)。
|
||||
type AdminCoalesceRules struct {
|
||||
// WindowMs 是允许区间(毫秒);越界会被后端 clamp 并留 WARN,而不是拒绝。
|
||||
WindowMs [2]int `json:"windowMs"`
|
||||
// MaxWaitMs 的下界**跟随窗口**(因此 [0] 是窗口下界、[1] 是绝对上限)。
|
||||
MaxWaitMs [2]int `json:"maxWaitMs"`
|
||||
// MaxBatchMessages 是单批条数区间(超出不是丢弃,是拆成下一批)。
|
||||
MaxBatchMessages [2]int `json:"maxBatchMessages"`
|
||||
// Clamp 说明越界值的处置("clamp"),使面板不必猜。
|
||||
Clamp string `json:"clamp"`
|
||||
// FlushReasons 是发车原因的闭集(面板/文档据此解释日志里的 flush_reason)。
|
||||
FlushReasons []string `json:"flushReasons"`
|
||||
}
|
||||
|
||||
// AdminCoalesceView 是 GET / PUT `/admin/channels/coalesce` 的 data。
|
||||
type AdminCoalesceView struct {
|
||||
// Path 是面板配置文件的落点(与 channels.json 同一棵配置树)。
|
||||
Path string `json:"path"`
|
||||
// Present 报告那份文件是否已存在(false = 还没写过,运行期正走代码默认)。
|
||||
Present bool `json:"present"`
|
||||
// Hot 报告这份配置是否**热生效**(桥每次发车前重读同一 store ⇒ true)。
|
||||
Hot bool `json:"hot"`
|
||||
// Default 是**按代码默认 + 盘上 default 段**归一后的生效值(面板的「当前值」)。
|
||||
Default AdminCoalescePolicy `json:"default"`
|
||||
// Patch 是盘上 default 段的**原值**:键缺位 ⇒ 不出现(= 跟随代码默认)。
|
||||
// 有了它,面板的「留空 ⇒ 跟随默认 2000ms」才是照实回显而不是猜。
|
||||
Patch map[string]any `json:"patch"`
|
||||
// Conversations 是每会话覆盖的**生效值**(键 = `platform|channel`,可读形态)。
|
||||
Conversations map[string]AdminCoalescePolicy `json:"conversations"`
|
||||
// Rules 是取值规则;OwnerPrivate 是那条只读事实(永远 "never")。
|
||||
Rules AdminCoalesceRules `json:"rules"`
|
||||
OwnerPrivate string `json:"ownerPrivate"`
|
||||
}
|
||||
|
||||
// adminCoalesceRead 是 GET 的处理函数:只读、不审计(与 /admin/config-facts 同档)。
|
||||
func (s *Server) adminCoalesceRead(w http.ResponseWriter, _ *http.Request) {
|
||||
store, ok := s.coalesceStore()
|
||||
if !ok {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_unavailable",
|
||||
"渠道静默窗口配置面未接线:面板配置文件落点无法确定"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildCoalesceView(store))
|
||||
}
|
||||
|
||||
// adminCoalesceWrite 是 PUT 的处理函数:解析 → 校验 → 落盘 → 回显真值 → 审计。
|
||||
//
|
||||
// 顺序是刻意的(口径 1):**任何**校验失败都在落盘之前返回 400,
|
||||
// 因此「非法值 ⇒ 盘上一个字节都不动」是结构性的,不是靠回滚。
|
||||
func (s *Server) adminCoalesceWrite(w http.ResponseWriter, r *http.Request) {
|
||||
store, ok := s.coalesceStore()
|
||||
if !ok {
|
||||
s.auditClaims(r, coalesceAuditAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_unavailable",
|
||||
"渠道静默窗口配置面未接线:面板配置文件落点无法确定"))
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, coalesceMaxBodyBytes))
|
||||
if err != nil {
|
||||
s.auditClaims(r, coalesceAuditAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
|
||||
return
|
||||
}
|
||||
patch, err := parseCoalescePatch(body)
|
||||
if err != nil {
|
||||
s.auditClaims(r, coalesceAuditAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
|
||||
return
|
||||
}
|
||||
wrote := false
|
||||
if !patch.Empty() {
|
||||
wrote, err = store.ApplyCoalesce(patch)
|
||||
if err != nil {
|
||||
s.auditClaims(r, coalesceAuditAction, "error")
|
||||
logger.WithSource(s.log, coalesceLogSource).Error(
|
||||
"coalesce config write failed", "path", store.CoalescePath(), "error", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_write_failed",
|
||||
"面板配置文件落盘失败(盘上内容未改动):%v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
log := logger.WithSource(s.log, coalesceLogSource)
|
||||
if wrote {
|
||||
log.Info("coalesce config saved (hot: the bridge rereads it before every departure)",
|
||||
"path", store.CoalescePath(), "action", coalesceAuditAction)
|
||||
} else {
|
||||
log.Info("coalesce config unchanged (no bytes written)", "path", store.CoalescePath())
|
||||
}
|
||||
s.auditClaims(r, coalesceAuditAction, "ok")
|
||||
writeJSON(w, http.StatusOK, buildCoalesceView(store))
|
||||
}
|
||||
|
||||
// coalesceStore 解析「渠道静默窗口配置」的可选能力面。
|
||||
func (s *Server) coalesceStore() (CoalesceConfigStore, bool) {
|
||||
if s.channels == nil {
|
||||
return nil, false
|
||||
}
|
||||
store, ok := s.channels.(CoalesceConfigStore)
|
||||
if !ok || store.CoalescePath() == "" {
|
||||
return nil, false
|
||||
}
|
||||
return store, true
|
||||
}
|
||||
|
||||
// parseCoalescePatch 解析 PUT 请求体:严格解码(未知键即错),并做「敲错级」校验
|
||||
// (负数)。越界的合法数字不在这里拒绝 —— 它们由运行期 clamp 并留 WARN。
|
||||
func parseCoalescePatch(body []byte) (runtimeconfig.CoalescePatch, error) {
|
||||
var patch runtimeconfig.CoalescePatch
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&patch); err != nil {
|
||||
return runtimeconfig.CoalescePatch{}, fmt.Errorf("请求体不是合法的窗口参数补丁:%w", err)
|
||||
}
|
||||
if dec.More() {
|
||||
return runtimeconfig.CoalescePatch{}, fmt.Errorf("请求体只允许一个 JSON 对象")
|
||||
}
|
||||
if patch.Default != nil {
|
||||
if err := runtimeconfig.ValidateCoalescePatch(*patch.Default); err != nil {
|
||||
return runtimeconfig.CoalescePatch{}, err
|
||||
}
|
||||
}
|
||||
for key, conv := range patch.Conversations {
|
||||
if conv == nil {
|
||||
continue
|
||||
}
|
||||
if err := runtimeconfig.ValidateCoalescePatch(*conv); err != nil {
|
||||
return runtimeconfig.CoalescePatch{}, fmt.Errorf("%s:%w", key, err)
|
||||
}
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
// buildCoalesceView 组装面板视图:盘上原值 + 生效值 + 取值规则 + 只读事实。
|
||||
func buildCoalesceView(store CoalesceConfigStore) AdminCoalesceView {
|
||||
file := store.CoalesceSnapshot()
|
||||
view := AdminCoalesceView{
|
||||
Path: store.CoalescePath(),
|
||||
Present: store.CoalescePresent(),
|
||||
Hot: true,
|
||||
Default: adminCoalescePolicy(file.EffectiveDefault()),
|
||||
Patch: file.Default.Values(),
|
||||
Conversations: map[string]AdminCoalescePolicy{},
|
||||
Rules: AdminCoalesceRules{
|
||||
WindowMs: [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs},
|
||||
MaxWaitMs: [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceMaxWaitMaxMs},
|
||||
MaxBatchMessages: [2]int{runtimeconfig.CoalesceBatchMin, runtimeconfig.CoalesceBatchMax},
|
||||
Clamp: "clamp",
|
||||
FlushReasons: []string{"quiet", "max_wait", "all_stale", "off", "stop"},
|
||||
},
|
||||
OwnerPrivate: coalesceOwnerPrivateFact,
|
||||
}
|
||||
for key := range file.Conversations {
|
||||
if policy, ok := file.EffectiveConversation(key); ok {
|
||||
view.Conversations[key] = adminCoalescePolicy(policy)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// adminCoalescePolicy 把 runtimeconfig 的策略投影成面板形状(字段一一对应)。
|
||||
func adminCoalescePolicy(p runtimeconfig.CoalescePolicy) AdminCoalescePolicy {
|
||||
return AdminCoalescePolicy{
|
||||
Enabled: p.Enabled,
|
||||
WindowMs: p.WindowMs,
|
||||
MaxWaitMs: p.MaxWaitMs,
|
||||
MaxBatchMessages: p.MaxBatchMessages,
|
||||
ApplyToStrangers: p.ApplyToStrangers,
|
||||
StrangerWindowMs: p.StrangerWindowMs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package gateway
|
||||
|
||||
// 本文件锁定渠道静默窗口合并(M23)的读写端点
|
||||
// (`GET|PUT /api/v1/admin/channels/coalesce`):
|
||||
//
|
||||
// ① 读侧如实回显:生效值(代码默认 + 盘上 default 段)+ 盘上原值 patch(缺位键不出现)
|
||||
// + 取值规则 + 「主人私聊永远不受影响」这条只读事实 + hot:true;
|
||||
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动;合法值 ⇒ 采用且保留同文件其它键与其它会话;
|
||||
// 非法值(负数/未知键/类型错) ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
|
||||
// ③ 热生效是事实(同一 store 实例被桥每次发车前重读),响应 `hot:true`;
|
||||
// ④ RBAC 走 writeAuth(owner|admin),写落审计 `channels.coalesce`;
|
||||
// ⑤ 请求体里**没有任何**键能翻转「主人私聊不走窗口」——那由 actor 元数据决定。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// coalesceEnv 是一个把 coalesce 配置文件放进临时数据目录的网关测试环境。
|
||||
type coalesceEnv struct {
|
||||
url string
|
||||
path string
|
||||
jwt *auth.JWTService
|
||||
store *runtimeconfig.CoalesceStore
|
||||
}
|
||||
|
||||
// coalesceStubChannels 是 ChannelConfigStore + CoalesceConfigStore 的生产形状替身:
|
||||
// 它把 coalesce 的读写直接委托给一个真的 runtimeconfig.CoalesceStore(因此落盘、
|
||||
// 三态、热生效都是真实路径,而不是再写一套假的)。
|
||||
type coalesceStubChannels struct {
|
||||
store *runtimeconfig.CoalesceStore
|
||||
}
|
||||
|
||||
func (c *coalesceStubChannels) Path() string { return "" }
|
||||
func (c *coalesceStubChannels) Load() map[string][]config.ChannelAccount { return nil }
|
||||
func (c *coalesceStubChannels) Reload(string) error { return nil }
|
||||
func (c *coalesceStubChannels) CoalescePath() string { return c.store.Path() }
|
||||
func (c *coalesceStubChannels) CoalescePresent() bool { return c.store.Present() }
|
||||
func (c *coalesceStubChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
|
||||
return c.store.Snapshot()
|
||||
}
|
||||
func (c *coalesceStubChannels) ApplyCoalesce(p runtimeconfig.CoalescePatch) (bool, error) {
|
||||
return c.store.Apply(p)
|
||||
}
|
||||
|
||||
// newCoalesceEnv 构造环境:coalesce.json 落在 t.TempDir()/config/coalesce.json。
|
||||
func newCoalesceEnv(t *testing.T) *coalesceEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config", "coalesce.json")
|
||||
store := runtimeconfig.NewCoalesceStore(path, nil)
|
||||
deps := Deps{DataDir: dir, Channels: &coalesceStubChannels{store: store}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
return &coalesceEnv{
|
||||
url: ts.URL + "/api/v1/admin/channels/coalesce",
|
||||
path: path,
|
||||
jwt: jwt,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// disk 读回盘上的原始字节(「非法值不落盘」的证据就是它与之前相等 / 文件仍不存在)。
|
||||
func (e *coalesceEnv) disk(t *testing.T) ([]byte, bool) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false
|
||||
}
|
||||
t.Fatalf("read coalesce file: %v", err)
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// view 解一次成功响应的 data。
|
||||
func (e *coalesceEnv) view(t *testing.T, body []byte) AdminCoalesceView {
|
||||
t.Helper()
|
||||
var view AdminCoalesceView
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
|
||||
t.Fatalf("decode view: %v (body=%s)", err, body)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func TestAdminCoalesceReadShowsDefaultsAndRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newCoalesceEnv(t)
|
||||
token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodGet, e.url, nil, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := e.view(t, body)
|
||||
if !view.Hot || view.Present {
|
||||
t.Fatalf("view = %+v, want hot:true and present:false for a fresh deployment", view)
|
||||
}
|
||||
if view.OwnerPrivate != "never" {
|
||||
t.Fatalf("ownerPrivate = %q, want the read-only fact 'never'", view.OwnerPrivate)
|
||||
}
|
||||
// 缺位 ⇒ 生效值是代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24)。
|
||||
if view.Default.WindowMs != 2000 || view.Default.MaxWaitMs != 6000 || view.Default.MaxBatchMessages != 24 {
|
||||
t.Fatalf("default policy = %+v, want 2000/6000/24", view.Default)
|
||||
}
|
||||
if !view.Default.Enabled || !view.Default.ApplyToStrangers {
|
||||
t.Fatalf("default policy = %+v, want both switches on by default", view.Default)
|
||||
}
|
||||
if len(view.Patch) != 0 {
|
||||
t.Fatalf("patch = %v, want empty (nothing written on disk yet)", view.Patch)
|
||||
}
|
||||
if view.Rules.WindowMs != [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs} {
|
||||
t.Fatalf("rules.windowMs = %v, want [300 15000]", view.Rules.WindowMs)
|
||||
}
|
||||
if len(view.Rules.FlushReasons) == 0 {
|
||||
t.Fatalf("rules.flushReasons is empty, want the closed set of departure reasons")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCoalesceWriteIsThreeState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newCoalesceEnv(t)
|
||||
token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner)
|
||||
|
||||
// ① 合法值 ⇒ 采用并落盘。
|
||||
resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if view := e.view(t, body); view.Default.WindowMs != 3000 || !view.Default.Enabled || !view.Present {
|
||||
t.Fatalf("after PUT default = %+v, want windowMs 3000, the rest at defaults and present:true", view.Default)
|
||||
}
|
||||
before, ok := e.disk(t)
|
||||
if !ok {
|
||||
t.Fatal("PUT reported success but no file was written")
|
||||
}
|
||||
|
||||
// ② 缺位键 ⇒ 不动(同样的字节 ⇒ 连文件都不重写)。
|
||||
resp, body = doRaw(t, http.MethodPut, e.url, `{}`, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("empty PUT status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if after, _ := e.disk(t); string(after) != string(before) {
|
||||
t.Fatalf("empty patch rewrote the file:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
|
||||
// ③ 非法值(负数)⇒ 400 且盘上零变更。
|
||||
resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":-5}}`, token)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("negative windowMs status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertErrorCode(t, body, "invalid_payload")
|
||||
if after, _ := e.disk(t); string(after) != string(before) {
|
||||
t.Fatalf("invalid patch touched the file:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
|
||||
// ④ 未知键 ⇒ 400 且盘上零变更。
|
||||
resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMsTypo":1}}`, token)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unknown key status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if after, _ := e.disk(t); string(after) != string(before) {
|
||||
t.Fatalf("unknown key touched the file:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCoalesceConversationOverridesAndDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newCoalesceEnv(t)
|
||||
token := signAny(t, e.jwt, "acct-admin", auth.RoleAdmin)
|
||||
|
||||
// 每会话覆盖:只有给出的键被采用,其余跟随全局生效值。
|
||||
resp, body := doRaw(t, http.MethodPut, e.url,
|
||||
`{"default":{"windowMs":2500},"conversations":{"telegram|group:-20":{"enabled":false}}}`, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := e.view(t, body)
|
||||
conv, ok := view.Conversations["telegram|group:-20"]
|
||||
if !ok {
|
||||
t.Fatalf("conversation override missing from the view: %+v", view.Conversations)
|
||||
}
|
||||
if conv.Enabled || conv.WindowMs != 2500 {
|
||||
t.Fatalf("conversation policy = %+v, want enabled:false and windowMs inherited (2500)", conv)
|
||||
}
|
||||
|
||||
// null ⇒ 删掉该会话覆盖(回到全局)。
|
||||
resp, body = doRaw(t, http.MethodPut, e.url, `{"conversations":{"telegram|group:-20":null}}`, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("delete override status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if view := e.view(t, body); len(view.Conversations) != 0 {
|
||||
t.Fatalf("conversations = %+v, want none after the null delete", view.Conversations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCoalesceWriteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newCoalesceEnv(t)
|
||||
viewer := signAny(t, e.jwt, "acct-viewer", auth.RoleViewer)
|
||||
resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, viewer)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer PUT status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if _, ok := e.disk(t); ok {
|
||||
t.Fatal("a forbidden write still created the config file")
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,13 @@ func (s *Server) registerAdminRoutes(
|
||||
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
|
||||
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
|
||||
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
|
||||
// 渠道静默窗口合并(M23;见 admin_coalesce.go):读回 `$DATA_DIR/config/coalesce.json`
|
||||
// 的窗口参数 + 取值规则 + 「主人私聊不受影响」这条只读事实;写是同一条路径上的
|
||||
// **三态补丁**(键缺位 = 不动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
|
||||
// + 审计(channels.coalesce)。两条都走 writeAuth(owner|admin)—— 与上面那条
|
||||
// memory-tidy 调优同档:写的是面板配置文件,不是身份/凭据面。
|
||||
mux.Handle("GET /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceRead)))
|
||||
mux.Handle("PUT /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceWrite)))
|
||||
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||
|
||||
Reference in new issue
Block a user