主人 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/。
476 lines
18 KiB
Go
476 lines
18 KiB
Go
// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。
|
||
//
|
||
// 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里
|
||
// 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按
|
||
// design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在
|
||
// $DATA_DIR/config/coalesce.json,**零新 env 键**。
|
||
//
|
||
// 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** ——
|
||
// 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在
|
||
// internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定,
|
||
// **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。
|
||
package runtimeconfig
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
const (
|
||
// coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的
|
||
// config 子目录口径。
|
||
coalesceConfigSubdir = "config"
|
||
// coalesceFileName 是静默窗口合并旋钮的落盘文件名。
|
||
coalesceFileName = "coalesce.json"
|
||
// coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。
|
||
coalesceLogSource = "config/runtimeconfig/coalesce"
|
||
|
||
// clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、
|
||
// 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**,
|
||
// 绝不静默采信一个不可能的值,也绝不因此拦住入站。
|
||
CoalesceWindowMinMs = 300
|
||
CoalesceWindowMaxMs = 15000
|
||
CoalesceMaxWaitMinMs = CoalesceWindowMinMs
|
||
CoalesceMaxWaitMaxMs = 30000
|
||
CoalesceBatchMin = 1
|
||
CoalesceBatchMax = 50
|
||
coalesceDefaultWindow = 2000
|
||
coalesceDefaultWait = 6000
|
||
coalesceDefaultBatch = 24
|
||
)
|
||
|
||
// DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。
|
||
func DefaultCoalescePath(dataDir string) string {
|
||
return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName)
|
||
}
|
||
|
||
// CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源)
|
||
// 渲染成落盘/面板用的可读键:NUL 换成 `|`。
|
||
//
|
||
// 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定),
|
||
// 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本
|
||
// 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是
|
||
// 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。
|
||
func CoalesceConversationKey(laneKey string) string {
|
||
return strings.ReplaceAll(laneKey, "\x00", "|")
|
||
}
|
||
|
||
// CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。
|
||
//
|
||
// 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走
|
||
// DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。
|
||
type CoalescePolicy struct {
|
||
// Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。
|
||
Enabled bool `json:"enabled"`
|
||
// WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。
|
||
WindowMs int `json:"windowMs"`
|
||
// MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群
|
||
// 因此不会让计时永不归零(宁可晚回,不许不回)。
|
||
MaxWaitMs int `json:"maxWaitMs"`
|
||
// MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首
|
||
// 成为下一批(见 channelbridge/coalesce.go 的 split)。
|
||
MaxBatchMessages int `json:"maxBatchMessages"`
|
||
// ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。
|
||
ApplyToStrangers bool `json:"applyToStrangers"`
|
||
// StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留:
|
||
// 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。
|
||
StrangerWindowMs int `json:"strangerWindowMs"`
|
||
}
|
||
|
||
// DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。
|
||
func DefaultCoalescePolicy() CoalescePolicy {
|
||
return CoalescePolicy{
|
||
Enabled: true,
|
||
WindowMs: coalesceDefaultWindow,
|
||
MaxWaitMs: coalesceDefaultWait,
|
||
MaxBatchMessages: coalesceDefaultBatch,
|
||
ApplyToStrangers: true,
|
||
StrangerWindowMs: coalesceDefaultWindow,
|
||
}
|
||
}
|
||
|
||
// CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。
|
||
// `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。
|
||
type CoalescePolicyPatch struct {
|
||
Enabled *bool `json:"enabled,omitempty"`
|
||
WindowMs *int `json:"windowMs,omitempty"`
|
||
MaxWaitMs *int `json:"maxWaitMs,omitempty"`
|
||
MaxBatchMessages *int `json:"maxBatchMessages,omitempty"`
|
||
ApplyToStrangers *bool `json:"applyToStrangers,omitempty"`
|
||
StrangerWindowMs *int `json:"strangerWindowMs,omitempty"`
|
||
}
|
||
|
||
// apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。
|
||
func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy {
|
||
if patch.Enabled != nil {
|
||
p.Enabled = *patch.Enabled
|
||
}
|
||
if patch.WindowMs != nil {
|
||
p.WindowMs = *patch.WindowMs
|
||
}
|
||
if patch.MaxWaitMs != nil {
|
||
p.MaxWaitMs = *patch.MaxWaitMs
|
||
}
|
||
if patch.MaxBatchMessages != nil {
|
||
p.MaxBatchMessages = *patch.MaxBatchMessages
|
||
}
|
||
if patch.ApplyToStrangers != nil {
|
||
p.ApplyToStrangers = *patch.ApplyToStrangers
|
||
}
|
||
if patch.StrangerWindowMs != nil {
|
||
p.StrangerWindowMs = *patch.StrangerWindowMs
|
||
}
|
||
return p
|
||
}
|
||
|
||
// merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留
|
||
// 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。
|
||
func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch {
|
||
out := patch
|
||
if next.Enabled != nil {
|
||
out.Enabled = next.Enabled
|
||
}
|
||
if next.WindowMs != nil {
|
||
out.WindowMs = next.WindowMs
|
||
}
|
||
if next.MaxWaitMs != nil {
|
||
out.MaxWaitMs = next.MaxWaitMs
|
||
}
|
||
if next.MaxBatchMessages != nil {
|
||
out.MaxBatchMessages = next.MaxBatchMessages
|
||
}
|
||
if next.ApplyToStrangers != nil {
|
||
out.ApplyToStrangers = next.ApplyToStrangers
|
||
}
|
||
if next.StrangerWindowMs != nil {
|
||
out.StrangerWindowMs = next.StrangerWindowMs
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。
|
||
func (patch CoalescePolicyPatch) Empty() bool {
|
||
return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil &&
|
||
patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil &&
|
||
patch.StrangerWindowMs == nil
|
||
}
|
||
|
||
// CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表
|
||
// (键 = CoalesceConversationKey(车道键))。
|
||
type CoalesceFile struct {
|
||
Default CoalescePolicyPatch `json:"default"`
|
||
Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"`
|
||
}
|
||
|
||
// EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。
|
||
// 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开:
|
||
// 一个是运行期事实,一个是盘上原文。
|
||
func (f CoalesceFile) EffectiveDefault() CoalescePolicy {
|
||
p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy()))
|
||
return p
|
||
}
|
||
|
||
// EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。
|
||
func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) {
|
||
conv, ok := f.Conversations[key]
|
||
if !ok {
|
||
return CoalescePolicy{}, false
|
||
}
|
||
p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault()))
|
||
return p, true
|
||
}
|
||
|
||
// Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」
|
||
// 如实回显。值是该键在盘上的原值(未经 clamp)。
|
||
func (patch CoalescePolicyPatch) Values() map[string]any {
|
||
out := map[string]any{}
|
||
if patch.Enabled != nil {
|
||
out["enabled"] = *patch.Enabled
|
||
}
|
||
if patch.WindowMs != nil {
|
||
out["windowMs"] = *patch.WindowMs
|
||
}
|
||
if patch.MaxWaitMs != nil {
|
||
out["maxWaitMs"] = *patch.MaxWaitMs
|
||
}
|
||
if patch.MaxBatchMessages != nil {
|
||
out["maxBatchMessages"] = *patch.MaxBatchMessages
|
||
}
|
||
if patch.ApplyToStrangers != nil {
|
||
out["applyToStrangers"] = *patch.ApplyToStrangers
|
||
}
|
||
if patch.StrangerWindowMs != nil {
|
||
out["strangerWindowMs"] = *patch.StrangerWindowMs
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明
|
||
// (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成
|
||
// 一个没人知道的 300ms)。
|
||
func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) {
|
||
var notes []string
|
||
if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs {
|
||
notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs))
|
||
p.WindowMs = CoalesceWindowMinMs
|
||
}
|
||
if p.WindowMs > CoalesceWindowMaxMs {
|
||
notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs))
|
||
p.WindowMs = CoalesceWindowMaxMs
|
||
}
|
||
if p.WindowMs == 0 {
|
||
// 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。
|
||
notes = append(notes, "windowMs=0 视为关闭静默窗口")
|
||
p.Enabled = false
|
||
p.WindowMs = coalesceDefaultWindow
|
||
}
|
||
if p.StrangerWindowMs <= 0 {
|
||
p.StrangerWindowMs = p.WindowMs
|
||
}
|
||
if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs {
|
||
notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs))
|
||
p.StrangerWindowMs = p.WindowMs
|
||
}
|
||
if p.MaxWaitMs < p.WindowMs {
|
||
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs))
|
||
p.MaxWaitMs = p.WindowMs
|
||
}
|
||
if p.MaxWaitMs > CoalesceMaxWaitMaxMs {
|
||
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs))
|
||
p.MaxWaitMs = CoalesceMaxWaitMaxMs
|
||
}
|
||
if p.MaxBatchMessages < CoalesceBatchMin {
|
||
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin))
|
||
p.MaxBatchMessages = CoalesceBatchMin
|
||
}
|
||
if p.MaxBatchMessages > CoalesceBatchMax {
|
||
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax))
|
||
p.MaxBatchMessages = CoalesceBatchMax
|
||
}
|
||
return p, notes
|
||
}
|
||
|
||
// ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由
|
||
// JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。
|
||
func ValidateCoalescePatch(p CoalescePolicyPatch) error {
|
||
fields := []struct {
|
||
name string
|
||
v *int
|
||
}{
|
||
{"windowMs", p.WindowMs},
|
||
{"maxWaitMs", p.MaxWaitMs},
|
||
{"maxBatchMessages", p.MaxBatchMessages},
|
||
{"strangerWindowMs", p.StrangerWindowMs},
|
||
}
|
||
for _, f := range fields {
|
||
if f.v != nil && *f.v < 0 {
|
||
return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、
|
||
// 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒
|
||
// **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。
|
||
type CoalescePatch struct {
|
||
Default *CoalescePolicyPatch `json:"default,omitempty"`
|
||
Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"`
|
||
}
|
||
|
||
// Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。
|
||
func (p CoalescePatch) Empty() bool {
|
||
if p.Default != nil && !p.Default.Empty() {
|
||
return false
|
||
}
|
||
return len(p.Conversations) == 0
|
||
}
|
||
|
||
// CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。
|
||
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。
|
||
type CoalesceStore struct {
|
||
path string
|
||
log *slog.Logger
|
||
mu sync.RWMutex
|
||
cfg CoalesceFile
|
||
}
|
||
|
||
// NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
|
||
// 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。
|
||
func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
s := &CoalesceStore{
|
||
path: path,
|
||
log: logger.WithSource(log, coalesceLogSource),
|
||
cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}},
|
||
}
|
||
s.load()
|
||
return s
|
||
}
|
||
|
||
// Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。
|
||
func (s *CoalesceStore) Path() string { return s.path }
|
||
|
||
// Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
|
||
func (s *CoalesceStore) Present() bool {
|
||
if s.path == "" {
|
||
return false
|
||
}
|
||
_, err := os.Stat(s.path)
|
||
return err == nil
|
||
}
|
||
|
||
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
|
||
// 其它读/解析错误留 WARN 后仍走默认值。
|
||
func (s *CoalesceStore) load() {
|
||
if s.path == "" {
|
||
return
|
||
}
|
||
raw, err := os.ReadFile(s.path)
|
||
if err != nil {
|
||
if !os.IsNotExist(err) {
|
||
s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err)
|
||
}
|
||
return
|
||
}
|
||
var f CoalesceFile
|
||
if err := json.Unmarshal(raw, &f); err != nil {
|
||
s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err)
|
||
return
|
||
}
|
||
if f.Conversations == nil {
|
||
f.Conversations = map[string]CoalescePolicyPatch{}
|
||
}
|
||
s.mu.Lock()
|
||
s.cfg = f
|
||
s.mu.Unlock()
|
||
s.logClamp(f)
|
||
}
|
||
|
||
// logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。
|
||
func (s *CoalesceStore) logClamp(f CoalesceFile) {
|
||
if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 {
|
||
s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; "))
|
||
}
|
||
for key, patch := range f.Conversations {
|
||
if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 {
|
||
s.log.Warn("coalesce conversation override clamped",
|
||
"path", s.path, "conversation", key, "notes", strings.Join(notes, "; "))
|
||
}
|
||
}
|
||
}
|
||
|
||
// PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖,
|
||
// 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。
|
||
//
|
||
// 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效),
|
||
// 也与入站路径一样不会被一次落盘抖动卡住。
|
||
func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy {
|
||
s.mu.RLock()
|
||
def := s.cfg.Default
|
||
conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)]
|
||
s.mu.RUnlock()
|
||
p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy()))
|
||
if ok {
|
||
p, _ = ClampCoalescePolicy(conv.apply(p))
|
||
}
|
||
return p
|
||
}
|
||
|
||
// Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。
|
||
// 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」,
|
||
// 而不是「运行期把它夹成了什么」。
|
||
func (s *CoalesceStore) Snapshot() CoalesceFile {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))}
|
||
for k, v := range s.cfg.Conversations {
|
||
out.Conversations[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里
|
||
// 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内,
|
||
// 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。
|
||
//
|
||
// 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着
|
||
// 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。
|
||
func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
before, _ := json.Marshal(s.cfg)
|
||
if patch.Default != nil {
|
||
s.cfg.Default = s.cfg.Default.merge(*patch.Default)
|
||
}
|
||
if s.cfg.Conversations == nil {
|
||
s.cfg.Conversations = map[string]CoalescePolicyPatch{}
|
||
}
|
||
for key, conv := range patch.Conversations {
|
||
if conv == nil {
|
||
delete(s.cfg.Conversations, key)
|
||
continue
|
||
}
|
||
s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv)
|
||
}
|
||
after, _ := json.Marshal(s.cfg)
|
||
if string(before) == string(after) {
|
||
return false, nil
|
||
}
|
||
if err := s.saveLocked(); err != nil {
|
||
return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err)
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。
|
||
// 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。
|
||
func (s *CoalesceStore) saveLocked() error {
|
||
if s.path == "" {
|
||
return nil
|
||
}
|
||
dir := filepath.Dir(s.path)
|
||
if dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return fmt.Errorf("mkdir: %w", err)
|
||
}
|
||
}
|
||
raw, err := json.MarshalIndent(s.cfg, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("marshal: %w", err)
|
||
}
|
||
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
|
||
if err != nil {
|
||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||
}
|
||
tmpName := tmp.Name()
|
||
if err := tmp.Chmod(0o600); err != nil {
|
||
_ = tmp.Close()
|
||
_ = os.Remove(tmpName)
|
||
return fmt.Errorf("chmod %s: %w", tmpName, err)
|
||
}
|
||
if _, err := tmp.Write(raw); err != nil {
|
||
_ = tmp.Close()
|
||
_ = os.Remove(tmpName)
|
||
return fmt.Errorf("write %s: %w", tmpName, err)
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
_ = os.Remove(tmpName)
|
||
return fmt.Errorf("close %s: %w", tmpName, err)
|
||
}
|
||
if err := os.Rename(tmpName, s.path); err != nil {
|
||
_ = os.Remove(tmpName)
|
||
return fmt.Errorf("rename: %w", err)
|
||
}
|
||
return nil
|
||
}
|