主人 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/。
250 lines
11 KiB
Go
250 lines
11 KiB
Go
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,
|
||
}
|
||
}
|