主人 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/。
218 lines
8.5 KiB
Go
218 lines
8.5 KiB
Go
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")
|
||
}
|
||
}
|