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:
AskaEth committed 2026-10-24 21:11:22 +08:00
1 parent 679c8abf1d
commit ca8577f05b
21 files changed
+3616 -244

No files matched your search

+249
View File
@@ -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")
}
}
+7
View 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)))