Files
Cyrene_v2/internal/services/gateway/admin_coalesce_test.go
T
AskaEth ca8577f05b 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/。
2026-10-24 21:11:22 +08:00

218 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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")
}
}