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

@@ -0,0 +1,140 @@
// 群聊静默窗口卡(M23)的**真实渲染**测试。
//
// 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里):
// ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关);
// ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**;
// ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。
//
// 跑法(与 MemoryTidyTuningCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \
// && node /tmp/cyrene-coalesce-card.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
COALESCE_FIELDS,
COALESCE_HOT_LABEL,
OWNER_PRIVATE_NOTE,
CoalesceCardBody,
draftFromView,
parseCoalescePatch,
type CoalesceView,
} from "./CoalesceCard";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(代码默认 + 一条会话覆盖)。 */
function fixtureView(overrides: Partial<CoalesceView> = {}): CoalesceView {
return {
path: "/data/config/coalesce.json",
present: true,
hot: true,
default: {
enabled: true,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
patch: {},
conversations: {
"telegram|group:-20": {
enabled: false,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
},
rules: {
windowMs: [300, 15000],
maxWaitMs: [300, 30000],
maxBatchMessages: [1, 50],
clamp: "clamp",
flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"],
},
ownerPrivate: "never",
...overrides,
};
}
function render(view: CoalesceView, draftOverride?: Partial<ReturnType<typeof draftFromView>>): string {
const draft = { ...draftFromView(view), ...(draftOverride ?? {}) };
const check = parseCoalescePatch(view, draft);
return renderToStaticMarkup(
createElement(CoalesceCardBody, {
view,
draft,
errors: check.errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => undefined,
onSave: () => undefined,
})
);
}
function main(): void {
const view = fixtureView();
const html = render(view);
// ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。
for (const field of COALESCE_FIELDS) {
ok(has(html, field.label), `field label missing: ${field.label}`);
ok(has(html, field.hint), `field hint missing for ${field.key}`);
ok(has(html, field.key), `field key not shown for ${field.key}`);
}
ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing");
ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it");
ok(has(html, COALESCE_HOT_LABEL), "hot label missing");
ok(has(html, "/data/config/coalesce.json"), "config path not shown");
ok(has(html, "telegram|group:-20"), "per-conversation override not listed");
ok(has(html, "300–15000ms"), "window rules not shown");
ok(has(html, "quiet"), "flush reasons not shown");
// ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。
const unchanged = parseCoalescePatch(view, draftFromView(view));
ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch");
ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys");
const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" });
ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch");
ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`);
// ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。
const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" });
ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported");
ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported");
ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent");
ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent");
// 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。
const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" });
ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected");
const badHtml = render(view, { windowMs: "10" });
ok(has(badHtml, "取值范围"), "invalid draft must render an inline error");
ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button");
process.stdout.write(`coalesce card render: ${checks} checks passed\n`);
}
main();