架构深入

本文档是 imcore 的综合功能与技术文档,面向新加入的开发者、方案架构师与技术评审。 全文分四部分:① 项目概览 → ② 功能文档 → ③ 核心技术实现 → ④ 高复杂度专题独立深讲。 各专题的完整设计推演另见 CLAUDE.md 与 docs/superpowers/specs/,本文不重复其全部论证, 而是给出可独立阅读的结构化讲解。


目录


第一部分 项目概览

1.1 项目定位

imcore 是一套 Go 语言实现的商业化 WebSocket IM 系统(github.com/balala/imcore,Go 1.27), 覆盖三类实时通讯场景(单聊 DM / 群聊 / 直播房间),并在此之上构建了完整的 AI 智能客服、人工客服工作台、管理后台、数据治理 与 多端 SDK 能力, 以 SKU(软件版本)+ 签名授权(License) 模式商业交付。

1.2 技术栈总览

层 技术
语言/运行时 Go 1.27(CGO_ENABLED=0 纯静态,交叉编译 amd64 / arm64 / loong64 信创)
Web/WS Gin + Gorilla WebSocket
消息总线 RabbitMQ(topic/direct exchange)/ NATS JetStream(可内嵌单进程)/ Redis Streams,三选一配置切换
存储 MySQL 8 / PostgreSQL(含 pgvector),双方言运行时兼容
缓存/协调 Redis(single / sentinel / cluster 三模式)
对象存储 S3 兼容(MinIO/Ceph/COS/OBS/七牛/AWS)、阿里云 OSS、本地目录
RPC gRPC(实例间直连与在线状态查询)
序列化 JSON / Protobuf(总线消息可切换,解码端自动嗅探双格式)
前端 React 18 + Vite(chat-studio 联调台 / admin-console 管理台)、Astro(官网)
SDK TypeScript(含 Electron / React Native / 微信小程序入口)、Dart/Flutter、Swift、Kotlin
加密 Ed25519(license 验签)、AES-256-GCM / SM4-GCM(TOTP 密钥静态加密)、HMAC-SHA256(审计链)、可选国密 JWT(HMSM3)
可观测 Prometheus /metrics、/healthz /livez /readyz /stat、结构化 JSON 日志、pprof(PGO 采集)

1.3 进程/二进制矩阵

二进制 路径 职责
websocket cmd/websocket 主服务:HTTP API + WebSocket(约 130 个命令)+ gRPC + 内嵌前端 +(Ultimate)管理后台
persist_consumer cmd/persist_consumer RabbitMQ 模式房间历史持久化消费者(可多副本竞争消费)
jetstream_consumer cmd/jetstream_consumer NATS 模式持久化消费者(pull consumer,多副本竞争)
ai_worker cmd/ai_worker AI 回复 worker:消费 ai.request 等队列,调用 LLM/转写/embedding,流式回推(可多副本)
sync_worker cmd/sync_worker 外部业务系统周期同步(用户/好友/历史消息),支持 Redis 租约选主 HA
migrate cmd/migrate 数据库迁移 runner(-status/-baseline/-until)
imctl cmd/imctl 运维 TUI + systemd 单元生成器
governance cmd/governance 数据治理 CLI(审计链校验/保留策略/归档恢复/擦除重放)
license_tool cmd/license_tool 授权密钥对生成 / 签发 / 验证
loadtest / satreport cmd/* 压测器与饱和度报告生成器
packagegen / protocolgen / config_template cmd/* 代码生成器(SKU 目录/协议四端 SDK/配置模板)

1.4 双维度商业模型:构建 SKU × 可售 Edition

项目将「编译期能力裁剪」与「运行时授权解锁」分为两条正交轴:

构建 SKU(Go build tags,6 个)

构建 SKU Tags 内容
basic (无) 仅 IM 核心
pro (无) 与 basic 同二进制,pro 能力由 license 运行时解锁
ai aiagent basic + AI agent(内部构建)
ultimate imultimate community basic + 管理后台 + sync_worker + 社区(内部构建)
customer-service aiagent imultimate 售为 customer_service edition
full aiagent imultimate community 全量(内部构建)

可售 Edition(lib/license 运行时门控,4 个):basic(免费)/ pro(¥9,800/年)/ ultimate(¥29,800/年)/ customer_service(¥19,800/年)。 版本价格与功能对照的单一事实来源是 lib/license(能力与额度)+ packaging/editions.yaml (展示元数据),cmd/packagegen 由此生成官网 SKU 数据、功能对照文档与 packaging/sku.mk。

发布版二进制不信任明文配置:强制验签(Ed25519),公钥在编译期经 ldflags 烧入, 改 yaml 无法升级版本;授权过期后服务不宕机,降级为 basic 继续运行(核心 IM 存活、付费能力实时关闭)。

1.5 总体架构图

                         ┌────────────────────────────────────────────────┐
                         │                客户端(多端)                    │
                         │  Web SDK / Electron / RN / 小程序 / Flutter /    │
                         │  Swift / Kotlin / chat-studio / 客服 Widget      │
                         └───────────────┬────────────────────────────────┘
                          WebSocket(JSON) │ HTTP API / OpenAPI / Webhook
             ┌───────────────────────────┼──────────────────────────────┐
             │                     websocket 主服务(多实例)              │
             │  ┌────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
             │  │ 连接层      │ │ 命令路由      │ │  业务控制器          │ │
             │  │ sharded    │ │ ~130 WS cmd  │ │  DM/群/房间/好友/RTC │ │
             │  │ registry   │ │ + HTTP/ADMIN │ │  审核/推送/会话状态   │ │
             │  └─────┬──────┘ └──────┬───────┘ └─────────┬───────────┘ │
             │        │        ┌──────┴────────┐   ┌──────┴──────────┐  │
             │  ┌─────┴──────┐ │ bus.go 粘合层  │   │ gRPC server     │  │
             │  │Admin Console│ │ (bind/unbind  │   │ (presence/直投)  │  │
             │  │ :7080 独立  │ │  引用计数)     │   └──────┬──────────┘  │
             │  └────────────┘ └──────┬────────┘          │              │
             └───────────────────────┼──────────────────┼──────────────┘
                                     │ Publish/Subscribe│
        ┌────────────────────────────┼──────────────────┼───────────────────┐
        │                     消息总线(可插拔,三选一)  │                    │
        │  RabbitMQ(topic/direct) / NATS JetStream(3 streams) / Redis Streams│
        └───────┬─────────────────────┬───────────────────┬───────────────┘
                │                     │                   │
     ┌──────────┴─────────┐ ┌─────────┴────────┐ ┌────────┴─────────────┐
     │ persist_consumer / │ │ ai_worker(多副本) │ │ sync_worker / 各 worker│
     │ jetstream_consumer │ │ LLM/RAG/工具/转写 │ │ 业务同步/治理定时任务  │
     └──────────┬─────────┘ └─────────┬────────┘ └────────┬─────────────┘
                │                     │                    │
     ┌──────────┴─────────────────────┴────────────────────┴─────────────┐
     │  MySQL / PostgreSQL(双方言,读写分离可选)      Redis(presence/   │
     │  消息/会话/客服/AI/治理 ~50 张表 + pgvector     缓存/锁/限流/流)   │
     │  对象存储(S3/OSS/MinIO:媒体、治理归档)                          │
     └──────────────────────────────────────────────────────────────────┘

关键数据流(简述,详见第三部分):

  1. 上行:客户端 WS 帧 → 鉴权/限流/去重 → 审核(敏感词/回调)→ 同步幂等落库 → 本地投递 + broker 跨实例投递 → 离线推送任务 → 会话摘要 → ack。
  2. 下行:broker 订阅(room.>/user.>/group.> 等)→ handleBroadcast 身份复核 → 本地房间/群/用户索引 fan-out → 每连接双队列(可靠/尽力而为)写泵。
  3. AI:DM 命中 bot → ai.request(workqueue,恰好一个 worker 消费)→ 流式 delta/done/error(fanout,每个 WS 实例投递本地会话)。

第二部分 功能文档

2.1 IM 核心通讯

2.1.1 三种会话场景

能力 DM 单聊 Group 群聊 Room 直播/游戏房间
消息发送/历史/搜索 ✅(chat_dm_*) ✅(chat_group_*) ✅(chat_room_*,聊天与房间事件分表)
成员模型 无成员表,按 dm:{userA,userB} 会话 id DB 持久成员 + 角色(owner/admin/member)+ 用户组 临时房间:join/exit + 本地成员索引
定向/可见性控制 好友白名单/消息政策 定向消息(group_targeted + 受众表)、@all/@成员/@用户组/@部门(fanout 上限) 低优先级消息可丢弃(弹幕型)
管理操作 — 创建/邀请/审批/踢人/禁言/转让/解散全生命周期 禁言/踢人/广播/合并广播缓存
存储表 dm_messages(幂等键 conversation+sender+client_dedup_id) chat_group_messages + chat_group_message_audiences room_chat_messages + room_messages(异步持久化)

2.1.2 消息操作与体验

  • 消息操作:编辑、撤回(recall,带 MutationVersion 版本链)、表情回应(reaction)、 引用回复、已读回执与送达回执(chat_message_receipts 表)、正在输入(typing)。
  • 消息体结构:分段(segments)联合体——text / emoji / 图片 / 文件 / 语音 / 位置 / 名片 / nudge / quick_reply / card(富交互,见 2.3.6);消息 ID 为 UUIDv7(时间有序, 服务端强制校验);请求带 tempID 实现客户端去重与 ack 关联。
  • 会话状态:未读数(Redis Lua 原子递增 + DB 持久化 chat_conversation_states)、 置顶/免打扰/标签/草稿、最近会话列表(ZSet top-100)、多端同步(chat_device_sync 事件流 + 全量快照兜底)。
  • RTC 音视频:1v1 通话 + 多人会议(rtc_* / rtc_multi_*),TURN HMAC 临时凭证 (RFC 5766-bis),通话记录落库并可触发 AI 通话分析。
  • 关系链:好友申请/同意/删除、黑名单、DM 白名单政策、企业组织架构(部门同步 + @部门)。
  • 媒体:上传走服务端网关(S3/OSS/MinIO/本地),私有桶 + 时间限 presigned GET, DM 媒体默认不可公开访问。

2.1.3 内容安全

  • 敏感词审核(moderation):内置 ~2k 中文词库(go:embed)+ 管理端词库 CRUD, 命中策略 block/replace/pass 可配;异步审计落 chat_moderation_logs;词库加载失败 fail-open(不因审核库抖动拦截正常消息)。
  • 发送前回调(message_callback):同步 HTTP 回调可审核/改写/拒绝消息, HMAC-SHA256 签名头防伪造,fail_open 可配(详见专题 14)。
  • 投递后回调(message_route):异步事件外发到业务系统(融云风格签名兼容)。

2.2 AI 智能客服(aiagent 构建)

按 Phase 演进,当前能力(P0–P6+):

能力 说明 关键命令/主题
Bot 对话 DM 发给 bot:<id> 即触发;回复经 chat.stream.delta/done/error 逐 token 流式下发 ai.request(workqueue)
多后端 OpenAI 兼容(DeepSeek/vLLM/OpenRouter…)/ Dify / Coze / 自建 webhook 工作流,四类 provider 适配 ai_bots.provider
RAG 知识库 文档上传(text/md/pdf/docx/url)→ 异步切片(800 字/150 重叠,rune 级)→ embedding(DashScope 1024 维)→ pgvector 检索 top-k → 注入第二 system 消息;低置信三策略(answer/refuse/handover);引文 append 模式 kb.ingest(workqueue)
人工接管 双向:坐席 chat_dm_handover 转给 bot;bot 调 handover_to_human builtin / KB 低置信 / 规则钩子转回人工;双方实时收 chat_dm_routing_changed chat_conversation_routing
交接简报 转人工时自动生成四段简报(诉求/已尝试/情绪/待确认),LLM 失败回落模板;仅坐席可见,支持离线补取 ai.handover.brief(.ready)
函数调用 每bot 可配 HTTP 转发工具(SSRF 防护、4KB 响应上限)+ builtin;最多 3 轮 tool loop,末轮强制纯文本收敛;人工确认挂起续跑 ai_tools / ai_tool_call_audit
语音 DM 音频段先经转写 webhook(STT)再进对话管线;错误码细分(不支持/不可用/失败) lib/speech
规则钩子 beforeChat 声明式规则(keyword/turns 条件 → webhook/handover/stop_reply 动作),原生与外部工作流 bot 均生效 ai_hooks
长期记忆 三层:结构化客户档案(operator 值永不丢)→ 滚动摘要 → pgvector 长尾事实召回;写入水位按会话隔离;人机会话空闲后回写(坐席结论也进记忆) ai_user_memory_*
会话小结 客服会话关闭时一次 LLM 小结,独立于长期记忆(详见 2.3) cs_sessions.summary
质检闭环 用户可见来源附录、置信策略、不可变 Prompt/KB 快照、纠错与版本绑定评测集(20260827_ai_quality_control_loop.sql) ai_bots 扩展列
通话分析 RTC 通话记录异步 STT + 结构化分析落 call_analysis call.analyze(workqueue)

2.3 人工客服工作台

2.3.1 坐席与组织

  • 坐席名单(cs_agents):坐席身份唯一真源,bootstrap 空表播种 + admin CRUD; 角色分 agent / supervisor;归属团队(cs_teams)。
  • 可接待状态:在线(跨实例 presence 判定)≠ 可接待;自动路由要求 启用 + 在线 + ready 状态 + 班次内 + 技能/语言匹配 + 精确活动数未满 max_concurrent。
  • 团队路由:转接可指定团队(teamID),按 least-loaded 自动挑人;无候选进入持久排队。

2.3.2 服务会话(cs_sessions)

把「一次服务」做成一等概念:

  • 会话边界:任一参与者是 bot 或会话有 routing 行即判为客服会话;首条消息锚定开启, 活动水位单调推进;空闲 5 分钟自动关闭并触发小结(独立于记忆回写的 30 分钟阈值)。
  • 转接/改派:坐席间转接(chat_dm_transfer,core 命令)、主管强制改派 (chat_dm_force_reassign,fail-closed + 预写审计行)、群内认领/释放/改派 (chat_group_cs_claim/release/reassign,复用 routing 表,groupcs: 会话 id 形状)。
  • 排队系统:cs_routing_queue 持久队列(优先级 + 入队时间重试,DB 短租约协调多 worker), 营业时间外 offline_policy=queue 同样持久排队;ai_worker.routing_queue.interval_sec(默认 5s)扫描。
  • 满意度评价:会话关闭后向客户发评分卡(富交互消息),1/3/5 分 + 补评硬过期窗口 + 冷却期 + 最少消息数三道闸;运营侧有明细复盘(review_note/tags)、催评(至多一次)与 daily_stats 满意度趋势。
  • 话术库(cs_reply_templates):坐席快捷回复,{{var}} 占位符客户端渲染, 封顶 500 条返回真实 total。
  • 内部备注(cs_customer_notes):append-only 坐席自由文本,永不进 AI prompt (防“这客户难缠”类内容被 LLM 转述),id 游标分页。
  • 客户档案卡(ai_user_profile_values):坐席可查看/编辑客户结构化档案, source='operator' 的值模型永不覆盖;授权 fail-closed。
  • 坐席工作台(/home/agent-workbench)与 客服 Demo(/home/cs-demo, 访客气泡 → 转人工建专属客服群 → 队列认领演练)。

2.4 管理后台(Ultimate,独立 Gin engine :7080)

  • 安全:JWT(access/refresh/2FA 三类 token 靠 audience 隔离,jti 双通道撤销)、 RBAC(35+ 权限码 module:action,四级通配,内置 superadmin/ops/moderator/readonly + DB 自定义角色缓存)、TOTP 双因子(RFC 6238,密钥静态加密,10 个恢复码)、登录限速、 全操作审计。
  • 管理面:仪表盘、运营趋势(daily_stats:DAU/新用户/发送者/活跃群房间/在线峰值/ 消息量/满意度)、管理员与角色、用户/群/房间/消息管理(含消息日志与广播)、 敏感词、通知模板、公告、推送任务、黑名单、系统配置(运行时热更新白名单键 + 历史/回滚)、 license 状态、AI 机器人管理(bot CRUD/测试/用量、KB 文档、记忆、工具、钩子、质检)、 客服中心(坐席/团队/客服群/话术/满意度/队列实时页)、通话记录、数据治理。
  • 审计:全后台操作落 admin_audit_logs,HMAC 哈希链防篡改(专题 11)。

2.5 数据治理(Ultimate)

  • 数据清单:14 类数据资产(表级 inventory:类别/用途/敏感度/主体键)。
  • 主体访问/删除(DSR):请求状态机(pending→approved→processing→completed), 导出按客户匹配;擦除 = 单事务内 DM 会话 id 重写 + 消息假名化 + 44 条删除 spec + 5 条假名化 spec(删我的数据不毁他人的会话)+ 对象存储擦除标记重放补偿。
  • 消息归档:分级保留策略(DM/群/房间/社区 × session/group/version scope), 归档包(含关联表)+ SHA-256 校验和 + manifest 落对象存储,支持 checksum + 白名单 校验后幂等恢复;发送者被法律保留的消息不归档。
  • 法律保留:legal hold 阻断擦除;保留策略执行与调度器独立 fail-closed。
  • 执行安全:governance.execution_enabled 默认 false;破坏性操作 admin 页 / CLI 双入口均需显式 -execute。

2.6 开放能力

  • Inbound Push OpenAPI(/openapi/v1):客户业务后端单向推送用户资料/好友关系/ 通话记录;数据库 scoped 凭据(SHA-256 存储,token 只显示一次)+ 限速(本地桶 + 可选 Redis 跨实例分钟配额);禁用时 404 不暴露端点存在性。
  • 外部同步 sync_worker:周期拉取业务 API(用户/好友/DM/房间/房间聊天历史), 版本号防回退(chat_sync_entity_versions),游标水位持久化,失败不推进; 多实例 Redis 租约选主(active-passive)。
  • Webhook 外发契约:docs/WEBHOOK_CONTRACTS.md + JSON Schema + 示例, 全部由 cmd/protocolgen 从 clients/protocol.yaml 生成,CI 防漂移。
  • 多端 SDK:TypeScript(浏览器/Node/Electron/RN/小程序)、Dart/Flutter(含 UIKit)、 Swift(SPM)、Kotlin(Gradle);协议 additive 前向兼容(服务端 ≥ v1.2.0); SDK 公共 API 面快照(sdk-api/snapshots/*.api)+ 默认行为基线 + 迁移报告制度。
  • 客服 Widget:iframe 嵌入 + 宿主页 loader 脚本(IMCoreWidget API),品牌可配。

2.7 运维与交付

  • 部署形态:Docker Compose(一键演示栈:pgvector+Redis+RabbitMQ+MinIO+migrate Job)、 Kubernetes Helm(主 deployment 滚动策略 + PDB + HPA(可接自定义指标 imcore_current_connections)
    • worker deployment + ServiceMonitor)、systemd + imctl TUI、信创三架构 tarball。
  • 可观测:/healthz /livez /readyz(依赖就绪 fail-closed,drain 期间 not-ready)、 /stat(连接/吞吐/错误/goroutine/集群实例)、/metrics(50+ 自定义序列:消息计数/ 队列深度/worker 流 lag 与 backlog/DB 连接与长事务/webhook 成功率/错误码分布), 16 条 Prometheus 告警规则;worker 二进制独立 consumer.obs_addr 观测面 + 有界 drain。
  • 容量与性能(4C8G 单实例,multi-room 200 msg/s): 10,000 并发连接 p99 = 152.8ms,fan-out 38,506 msg/s,0 错误 0 丢消息 (sat-20260724 官网口径);5000 连接建议低延迟容量(p99 143.9ms); 39KiB 内存/连接;Protobuf 总线解码比 JSON 快 5.6×。
  • 混沌测试:8 个故障注入脚本(DB/Redis/broker 中断、慢消费者、队列灌满、重连风暴、 网络矩阵、上传凭证过期),以数据库计数为零丢失判据(发送数 vs room_messages 去重计数一次比对证明零丢失零重复)。
  • 质量流水线:make ci = fmt/vet/lint + 4-SKU 测试矩阵 + 迁移一致性 + SKU 符号隔离 + 协议/打包生成物防漂移;PGO(提交 default.pgo 自动生效);gosec + govulncheck + npm audit; secret-check 防凭证入库。

第三部分 核心技术实现

3.1 连接层(servers/websocket)

连接建立(init_acc.go):HTTP → WS 升级,鉴权链顺序执行——IP 握手限流 → JWT(query/header 三种携带方式;校验 issuer/audience,提取 user_id/scopes/jti/device_id)→ DB 撤销检查 → 用户级限流 → ban 检查 → Origin 白名单(规范化 scheme+authority)→ 实例连接数上限(超限 503 让 LB 重平衡)。升级后每连接两个 goroutine(read/write)。

Client 结构(client.go):双发送队列——Send(可靠,默认 256)与 BestEffort(尽力而为,默认 32);outboundFrame 为值类型,广播 fan-out 时同一帧 (含 *websocket.PreparedMessage)被所有接收者共享,零额外分配。

心跳:读超时 75s(pong 刷新);服务端 ping 用分片调度器 + 有界 worker 池 (ping_scheduler.go)摊平海量 timer;应用层 180s 无活动清理(同时清过期 JWT 连接)。

注册表(client_registry.go):shardedRegistry 按会话 ID FNV 哈希分 32 片 (连接集)+ 按用户 ID 分片(map[userID]map[sessionID]*Client 天然支持多端), 无全局锁;跨分片快照非全局原子(调用方均可容忍)。ClientManager.start() 是 单 goroutine 事件循环,select 六个容量 1000 的 channel(Register/Login/Unregister/ JoinRoom/LeaveRoom/Broadcast)。

3.2 消息上行链路

ProcessData(acc_process.go):JSON 信封 {seq, cmd, data} → 256KiB 上限 → 全局限流 → 非公开命令必须已登录 → 受保护命令每次派发前重查撤销(堵 revoke 与 socket 关闭间的竞态窗口)→ handler map 分发 → 统一回 {seq, cmd, code, data} ack。

三类发送控制器共用骨架(去重 → 权限 → 审核 → 频控 → UUIDv7 → 回调门 → 持久化 → 投递 → 离线推送 → 摘要 → ack),差异在投递路由与持久化方式:

DM Group Room
持久化 同步幂等 insert(InsertDMMessageIdempotent) 同步幂等 insert 同步 upsert(聊天)+ 异步队列(房间事件)
broker subject user.{targetID}(direct exchange) group.{id}(topic) room.{id}(topic)
特色 送达/已读回执、CS 会话边界 hook、bot 分发 定向受众、mention fanout(上限 500) 优先级消息、低优先级丢弃

3.3 消息下行与跨实例广播(bus.go)

订阅拓扑:每实例声明 5 个广播队列(gowebsocket.broadcast.{global|user|room|group|community}.{instanceID}) 订阅通配 subject;persist 用固定队列名 gowebsocket.persist.room 形成全 fleet 竞争消费(每条消息只持久化一次)。

handleBroadcast 粘合核心:按 RoomType 分发到本地原语(全局/用户/房间/群/定向群), 并做三件事安全加固——payload 内嵌身份复核(sender 字段与 BusMessage.UserID 比对)、 群定向消息消费端重校验成员(fail-closed,防伪造 payload)、room 消息权限复核 (生命周期命令放行、其余查成员状态、admin 前缀比对已持久化的召回状态与版本)。

同实例直送优化(user 定向):先本地直投,再按 Redis presence 判断是否有远端会话, 仅有远端时才 publish 并戳 OriginInstance;回弹到本实例时跳过(origin 去重), 避免双投。presence 误判由 P0 catch-up 同步 + 离线推送兜底。

动态 Bind/Unbind:按用户/房间/群/社区维护引用计数(本地在线成员数), 0→1 时 Bind、1→0 时 Unbind——RabbitMQ 下是真正的服务端绑定;NATS/Redis 下 Bind 退化为 首次建通配订阅(本地过滤)。重连后快照-锁外执行-锁内对账(详见专题 1)。

3.4 消息总线抽象(lib/broker)

统一接口 Publish / Subscribe / Bind / Unbind / OnReconnect / Close + 扩展 SubscribeWork(竞争消费原语);subject 统一 NATS 风格,RabbitMQ 适配器在边界把 > 翻译成 #。

RabbitMQ NATS JetStream Redis Streams
拓扑 4 exchange(global/room/group 为 topic,user 为 direct) 3 个 stream:核心 gowebsocket(MaxAge 5min)+ _ai_work(WorkQueuePolicy)+ _ai_push(LimitsPolicy) family stream(XADD MAXLEN ~10000)
广播语义 每实例队列 + Bind/Unbind 真绑定 每实例 durable push consumer,Bind 首次归并通配后 no-op 每实例 consumer group,从 $ 起读
竞争消费 固定队列名(workqueue 天然) 共享 durable pull consumer(DeliverAll) 共享 group,从 0 起读 + XAUTOCLAIM 回收
可靠性 显式 Ack/Nack requeue AckExplicit + AckWait(30s/300s) + MaxDeliver XAck + XAutoClaim(minIdle 阈值)
特有语义 无 ack 期限,无限重投 ack 超时重投、MaxDeliver 用尽即弃(等 MaxAge) NOGROUP 自愈重建;生产须 AOF

Codec(lib/buscodec):编码按配置(默认 Protobuf,实测快 5.6×);解码按首字节 嗅探({ = JSON,否则 Protobuf),保证滚动升级期间新旧实例互译。

3.5 持久化层(lib/dblib)

双通道设计:

  • 通道 A(同步):发送路径 controller 内联幂等 insert——ack 前消息已入库(可靠性第一支柱);
  • 通道 B(异步):历史回填类写入走 4 条 buffered channel(room/roomChat/groupChat/dm, 默认 cap 10000)+ 单 goroutine persistLoop 批量 flush(batch 2005000 / 100ms60s 可热更),满则丢弃 DB 写但 Redis 历史仍写(分层降级),flush 失败 3 次指数退避后记日志丢弃。

MySQL/PostgreSQL 双方言:sql_dialect.go 运行时重写器把 MySQL 方言 SQL 翻译为 Postgres(详见专题 4);连接池默认 maxIdle=maxOpen;PG DSN 自动注入 idle_in_transaction_session_timeout;可选读写分离(深分页历史与搜索路由只读副本, 最新页/事务/读后写永远走主库)。

迁移:migrations/{mysql,postgres}/ 双方言目录 + schema_migrations 记账 + CI 集合一致性校验(详见专题 15)。

3.6 Redis 层(lib/cache / lib/redislib)

  • 在线状态:三 key 结构(session Hash TTL 24h + user session Set + 旧单记录兼容), 是跨实例投递路由与 gRPC fan-out 的依据;
  • 实例目录:balala:hash:servers Hash + readiness 生命周期上报;
  • 历史热缓存:每场景 list RPush+LTrim(1000),miss 回填 DB;
  • 会话摘要:ZSet 会话列表 + Lua 原子未读递增 + DB 持久化;
  • 请求去重状态机 / 频控窗口 / mutation 互斥 / 群与关系缓存;
  • Redis cluster 兼容:会话状态 key 带 {userID} hash tag 保 Lua 单槽。

消息标识无集中序列号——UUIDv7 消息 ID + DB 自增 id(历史分页游标)。

已修复——会话写锁使同会话 id 序 == 提交序(回归门禁:lib/dblib/conversation_write_lock_test.go)。 自增 id 在 INSERT 时分配、行在 COMMIT 时才可见(read-committed)。串行化缺失时,同一会话的并发 发送者交错提交会让读者看到 id 7 而看不见在途的 id 6,把游标推进到 7(prevCursor = 页内最大 id), 此后 6 被 id > cursor 补拉通道永久跳过(客户端视角的跳过,不是数据丢失)。修复:所有聊天插入 DAO(DM/群/房幂等插入、legacy upsert、synced/AI 回复插入,见 lib/dblib/conversation_write_lock.go)在会话写锁内完成“分配 id → 提交”——会话级咨询锁(PG pg_try_advisory_lock(hashtextextended(key,0)) / MySQL GET_LOCK),等待者用“尝试-退避”循环、 不占连接;持锁者钉住一条连接并在其上执行插入,并发持锁者数不会与连接池互相死锁。按 Materialize《linearizable log on read-committed》的思路:用锁换顺序、不升级隔离级别。已知绕锁 残余(记录在案):演示种子与 room_messages 事件异步 persister——均不进聊天历史游标路径。 CancelPendingPushTasksUpToCursor(UUIDv7 <= readCursor 取消离线推送)的前提随之愈合:读游标 不可能再越过锁路径上未提交的较小 id。

3.7 AI 管线(lib/aiagent + cmd/ai_worker)

DM 落库 → dispatchAIBotDM(bot: 前缀 / routing 表判定)
  → RegisterQueuedStream(Redis 注册可取消元数据)→ publish ai.request
  → [ai_worker] SubscribeWork(恰好一个副本消费;总是 ack,错误走 stream.error)
    → 限流(Redis Lua 滑窗,故障降级进程内兜底)
    → 语音转写(可选,SSRF 防护 webhook)
    → beforeChat 钩子(规则可 stop/handover)
    → fork:原生管线 / 外部工作流(Dify/Coze/webhook 拥有记忆与 RAG)
       原生管线:
         记忆组装(档案→摘要→向量事实,预算降级)
       + KB 检索(pgvector top-k,低置信策略)
       + 历史窗口(角色归因:bot/接管坐席=assistant)
       → provider.Stream(SSE → chunk channel)
       → 逐 delta publish chat.stream.delta(Seq 单调)
       → tool loop(≤3 轮,claim 幂等,双审计)
       → 持久化 dm_messages → publish chat.stream.done
  → [websocket 实例] ai_push fanout → 本地会话投帧(不在本实例即 no-op)

消费语义:work 主题(ai.request/kb.ingest/call.analyze/ai.handover.brief/ai.survey.ready) 恰好一个副本处理;push 主题(chat.stream.* 等)全实例 fanout。主题→stream 映射真源 lib/broker/aisubjects,drift 测试守卫。后台 sweeper(单 Redis 锁单飞): 会话关闭(5min)→ 记忆回写(30min)→ 记忆 TTL 清理 → 排队路由扫描(5s), 共享时钟分相位预算(详见专题 8)。

3.8 HTTP / gRPC 面

  • HTTP(routers/web_routers.go):健康探针(公开)、用户组(在线/限额/配置/上传/ 转写)、管理组(adminOps 网关 = loopback + CIDR + token:消息下发/公告/系统/统计/ 指标/pprof/chat-studio 调试台)、OpenAPI(Bearer)。
  • gRPC(servers/grpcserver):QueryUsersOnline / SendMsg / SendMsgAll / GetUserList ——bus 禁用时的跨实例投递 fallback 与 presence 查询通道;实例失联自动从 Redis 目录摘除。

3.9 安全体系(横切)

机制 实现
客户端鉴权 JWT(query/header),撤销实时重查;游客 token(demo 门控+限流);房间邀请 JWT
Admin 鉴权 JWT 三类 token + TOTP + RBAC + 登录限速 + jti 撤销(本地+Redis 双通道,Redis 故障 fail-open 有意取舍)
授权验签 Ed25519 编译期公钥烧入(专题 10)
SSRF netguard:解析后校验 + pin-IP 拨号关闭 rebinding 窗口(专题 13)
回调安全 HMAC 签名 + 能力提升防护(专题 14)
静态加密 TOTP 密钥 AES-256-GCM / 国密 SM4-GCM(tagged 前向兼容)
国密 SM3/SM4 纯 Go(无 cgo),JWT 双算法白名单平滑迁移(新签 HMSM3、验签兼容 HS256)
审计 后台操作哈希链(专题 11)、AI/工具/OpenAPI 共用 append-only 生命周期证据(参数只存摘要)

第四部分 高复杂度专题独立深讲

以下 19 个专题是本项目中技术实现难度与复杂度最高的部分。每个专题按 「问题背景 → 难点在哪 → 实现方案 → 关键代码 → 设计取舍」结构独立讲解, 可单独阅读。

专题 1:跨实例广播的动态绑定与重连对账

位置:servers/websocket/bus.go(约 900 行核心)。

问题背景

多实例部署下,用户 A 连在实例 1、用户 B 连在实例 2 时,A 发给 B 的消息必须跨实例投递。 投递靠消息总线,但不能让每个实例订阅所有 subject——几万房间 × 几十万用户的通配订阅 会让无关消息淹没有效带宽。因此绑定必须动态:本地有某房间/群/用户的连接才订阅对应 subject,连接离开就退订。

难点在哪

绑定状态是分布式一致性 + 高并发生命周期的复合问题:

  1. 连接的 join/leave/login/logout 并发地发生在多个 goroutine(事件循环、controller 同步段、per-user worker 池)中,计数必须精确——多算产生 stale binding 泄漏, 少算直接静默丢跨实例消息(RabbitMQ topic 未命中绑定即丢弃,无缓存无报错);
  2. broker 重连后必须全量重建绑定,重建期间计数还在被并发修改——快照与执行的间隙里 某个 key 的计数可能已归零,重建出来的绑定就是错的;
  3. Bind 是网络往返,不能持有任何锁执行,否则一次网络抖动拖死全部登录。

实现方案

  • 四套引用计数:userBindCounts / roomBindCounts / groupBindCounts(+ groupLocalUsers map[groupID]map[userID]int 双层索引),0→1 Bind、1→0 Unbind; 群按「用户 × 群 × session 数」计数;群解散时快照清理。
  • 重连对账(rebind)三段式(rebindCounts,泛型函数):
    1. 锁内快照当前全部非零计数;
    2. 锁外逐个 Bind(网络操作不持锁);
    3. 结束后锁内对账:计数中途归零的条目补 Unbind。 这样无论重建期间并发发生什么,最终状态与计数表一致。
  • ack 时序保证:join/leave controller 在持 roomStateMu 期间同步完成 broker bind 与 client.RoomID 赋值,副作用才派发进事件循环——保证 ack 回到客户端时状态已 落地,ack 后立刻发的消息不会被未绑定的路由键丢掉。
  • 网络 I/O 剥离:login/leave 的 broker bind、presence 广播交给 per-user 分片 worker 池(16 分片 × cap 2048,FIFO 保同用户不乱序);分片队列满则内联降级执行 而非丢弃(丢 bind = 静默断投递)。
  • RabbitMQ publishPool:绑定操作原本挤在单共享 channel 的内部锁上;pool 自建 独立连接 + N 条独立锁 channel(round-robin),失败即降级回共享 channel——“永不比之前差”。

设计取舍

NATS/Redis 下 Bind 退化为 no-op(通配订阅 + 本地过滤),这套计数体系的价值集中在 RabbitMQ;但代码统一走引用计数,让三种 broker 的 bus 层行为一致。计数漂移的排查 成本极高(无报错、只丢消息),因此所有清理路径(解散、驱逐、重连)都走同一条 对账逻辑而不是各自为政。

专题 2:高并发连接层的并发模型

位置:servers/websocket/client.go、client_registry.go、client_manager.go、 ping_scheduler.go、send_policy.go。

问题背景

单实例要稳定承载 10,000+ 长连接、38,000+ msg/s fan-out(实测数字),且延迟 p99 < 200ms。 Go 的 goroutine 便宜,但每连接一个 timer、每消息一次锁竞争、慢消费者阻塞广播 这三件事会在规模下摧毁性能。

难点在哪

  1. 写慢速连接不能拖垮快连接:广播 fan-out 会把同一帧发给上千连接, 任何一个 TCP 窗口满的连接都可能阻塞共享路径;
  2. 海量 timer 的堆开销:每连接心跳 ping 若各自 time.After,1 万连接的 timer 堆 churn 显著;
  3. 背压策略要区分消息等级:聊天消息丢了用户会投诉,typing/状态信令丢了无感—— 不能一刀切。

实现方案

  • 双队列 + 分级背压:每连接 Send(可靠 256)与 BestEffort(尽力 32)两条 channel; classifyOutboundPriority(send_policy.go)按 cmd 白名单分类(typing/状态/会话 更新为 best-effort)。发送路径非阻塞 select + default:可靠队列满 → 判定慢消费者, 主动 shutdown(CloseTryAgainLater) 断连(客户端重连后补拉历史);best-effort 满 → 丢弃并计数(imcore_best_effort_dropped_total)。
  • 批量排水写泵(nextQueuedMessage):写循环非阻塞轮询双队列,有积压时整批只设 一次 write deadline,把 timer-heap churn 从 O(N) 降到 O(1)/批;无积压时阻塞 select。广播帧用进程级共享 PreparedMessage(ping 帧同理),序列化一次、发送 N 次。
  • 计量先于入队:队列水位 gauge 在 channel send 之前递增,避免与 writer 的 即期接收竞态导致指标永久偏高。
  • 分片注册表:连接集按 SessionID 分 32 片(注册时 UserID 可能还是空,SessionID 从 NewClient 起稳定),用户索引按 userID 分片——每个 per-user 操作单分片单锁; 连接总数是 lock-free 原子计数(握手限额检查零锁)。
  • 分片 ping 调度器:interval = pingPeriod / shardCount,每 tick 只快照一个分片, 把 1 万次 ping 写入摊到整个周期;有界 worker 池防某分片慢拖住全局。
  • 事件循环与 worker 池分层:ClientManager.start() 单 goroutine 串行处理六个 生命周期 channel(内存索引增删);重 I/O(broker bind、presence 广播、stats 写入) 下放 per-user 分片 worker 池,队列满内联降级。

实测结果

4C8G 单实例:10,000 连接 multi-room p99 152.8ms、fan-out 38,506 msg/s、 0 错误 0 丢消息;内存 39KiB/连接。历史上两次关键修复都有记录:登录事件循环单 goroutine 雪崩(同 host ~11× 提升)、会话摘要批处理省 CPU 却带来 2× p99 回归 (用 go tool nm 符号隔离定位后回退)——性能工作在这个项目里是数据驱动的。

专题 3:三种消息中间件的语义统一

位置:lib/broker/(接口 + 3 适配器)、lib/broker/aisubjects。

问题背景

同一套业务代码要跑在 RabbitMQ、NATS JetStream、Redis Streams 三种中间件上, 配置切换不改代码。但三者的能力模型差异极大:RabbitMQ 有真绑定但无 ack 期限; JetStream 有 stream/consumer 两级抽象和 WorkQueuePolicy;Redis Streams 只有 consumer group + PEL。更关键的是AI 子系统的付费副作用要求“每条任务恰好一个 worker 处理”,三种中间件要给出一致的竞争消费语义。

难点在哪

  1. fanout 与 workqueue 是两种相反的投递语义,中间件表达方式各不相同 (RabbitMQ/Redis 靠队列/组名,NATS 靠 stream policy + consumer 形态);
  2. NATS 的 push consumer 有两个著名陷阱:无 deliver group 的 durable push 不允许 第二订阅者(第二个副本启动即 panic);push + deliver group 会把消息堆在服务端 选中的成员内存里(慢 worker 占着做不了的工作);
  3. 主题登记必须是封闭集合:NATS 里未登记进任何 stream 的 subject 发布无报错、 无日志、无指标——静默黑洞。

实现方案

  • SubscribeWork 统一入口:竞争消费一律走它;RabbitMQ/Redis 用固定队列/组名表达 (每实例后缀 = fanout,固定名 = 竞争),NATS 用共享 durable pull consumer (Fetch(5s) 循环 + 退避,重连由订阅键去重防 goroutine 泄漏)。
  • pull vs push 的选型理由(代码注释记录了两次真实事故):persist 与 AI work 都用 pull——它是唯一支持多副本竞争且不让慢 worker 私有积压消息的形态。曾经 push durable 无 deliver group 导致第二副本启动 panic,已改为 pull 并留一次性升级步骤 (docs/RUNBOOK_BACKUP_RESTORE.md)。
  • AI 双 stream 设计:<stream>_ai_work(WorkQueuePolicy,ack 即删,JetStream 服务端拒绝同 subject 第二个 consumer——“禁止 fanout”由 broker 强制而非代码评审 保证)与 <stream>_ai_push(LimitsPolicy + 每实例独立 durable,FileStorage—— MemoryStorage 会随 NATS 重启丢 consumer)。为什么核心 stream 装不下 AI 主题: 核心 stream MaxAge 5min 是瞬时分发缓冲,worker 全宕 5 分钟的 AI 任务会被静默吞掉; Retention 是 stream 级属性无法按 subject 覆盖。
  • 主题登记真源:lib/broker/aisubjects 声明全部 ai.* 主题归属(work/push), 无 build tag 的 drift 测试拦住绕过登记的字面量。
  • Redis 特有处理:XAUTOCLAIM 回收死消费者 PEL(AI 用更长 minIdle 阈值,防止 正在跑的 LLM handler 被误回收);NOGROUP 自愈(group 丢失时驱逐本地缓存重建, workqueue 首建从 0 保证启动前发布的任务不丢);Close 的 wg.Wait() 有界等待 (go-redis v9 阻塞读不受 ctx cancel 影响,只能等 Block 窗口)。
  • 运维差异显式文档化:NATS 两条 RabbitMQ 没有的语义——ack 超时重投(AckWait 要按整个 handler 预算估,不是单次模型调用)与 MaxDeliver 用尽即弃;Redis 生产 必须 AOF + maxlen 覆盖恢复窗口最大积压。

设计取舍

保证的是「每次投递只给一个副本」;崩溃/超时重投后仍是 at-least-once,不是业务 exactly-once——因此所有付费/非幂等路径都另有 DB claim 闸(专题 7)。中间件只做 分发,幂等永远是业务层的责任。

专题 4:SQL 方言运行时重写器

位置:lib/dblib/sql_dialect.go(约 400 行核心)。

问题背景

同一套 DAO 要同时跑 MySQL 与 PostgreSQL。传统做法是每条 SQL 写两份或引入 ORM, 但项目有 ~50 张表、大量手写高性能 SQL(批量 INSERT、upsert、CTE),双份维护 必然漂移。选择:以 MySQL 方言为作者语言,Postgres 执行前运行时重写。

难点在哪

重写不是字符串替换,而是带上下文的小型编译器前端:

  1. INSERT IGNORE → ON CONFLICT ... DO NOTHING 需要冲突目标(唯一键清单), Postgres 语法上不可省略,而 MySQL 根本没有这个概念——必须有一张人工维护的 冲突列注册表(postgresConflictTarget,约 50 张表);
  2. ON DUPLICATE KEY UPDATE 里的 VALUES(col) 要变 EXCLUDED.col,IF(a,b,c) 要递归解析成 CASE WHEN,且 update 子句中的裸列要加表限定(Postgres 对 DO UPDATE 中的裸列报 ambiguous)——区分「SET 左值」与「比较表达式」需要括号 深度分析(isUpdateAssignmentLHS);
  3. 一切发生在每次 Exec/Query 的热路径上,必须近零成本。

实现方案

  • dialectDB/dialectTx 包装:driver=postgres 时经 prepareSQL 重写;MySQL 原样直通(达梦 DM8 走 MySQL 兼容 passthrough)。
  • 快路径短路:手写的不分配 hasPrefixFold/containsFold(ASCII 小写比较)先扫 一遍,绝大多数不含关键字的查询零成本通过;正则包级编译一次。
  • 重写规则清单:INSERT IGNORE→ON CONFLICT DO NOTHING(查注册表)、 ON DUPLICATE KEY UPDATE→ON CONFLICT DO UPDATE(含 VALUES→EXCLUDED、IF→CASE、 列限定)、布尔字面量、UNIX_TIMESTAMP→EXTRACT(EPOCH FROM)、反引号→双引号、 ?→$N(带引号字符串感知的 rebind,不会把字符串字面量里的 ? 当占位符)。
  • fail-loud:ON DUPLICATE KEY 查不到注册的 conflict target 时直接报错而非 静默传透(Postgres 必然语法错,早失败好过晚失败)。
  • 性能:方言翻译正则优化后 12.6× 提速(docs/perf 记录),PG 部署热路径开销可忽略。

设计取舍

注册表是人工维护的(新增表要登记),换来的是 SQL 单一来源与零 ORM 税。 重写器只覆盖项目实际使用的 SQL 方言子集(不是通用 SQL 翻译器),因此规则集可控、 可穷举测试——这比“通用正确”更符合工程实际。

专题 5:消息幂等与可靠投递的三层防线

位置:lib/cache/request_state_cache.go、lib/dblib/db_lib_dm_history.go、 servers/websocket/chat_dm_controller.go。

问题背景

IM 的发送语义要求:客户端重发(超时重试)、broker 重投(at-least-once)、批量通道 重试,任何一种情况下同一条逻辑消息只能产生一次副作用(一次落库、一次推送、 一次未读递增),但重发又要拿到与首次相同的 ack(客户端靠 tempID 关联 UI)。

难点在哪

  1. 重发可能发生在任意时间差之后——Redis 窗口过期后的重发只能靠 DB 唯一键识别, 而识别出来之后要精确决定“哪些副作用跳过、ack 里带什么”;
  2. 请求去重的 scope 有讲究:seq 是连接级计数,客户端断线重连后 seq 重叠—— mutation 类去重必须把 SessionID 拼进 scope,否则跨连接误判;
  3. ack 缓存本身要防冒用:tempID 被第三方猜测重放不能取走别人的 ack。

实现方案(三层)

  1. Redis 请求状态机(毫秒~小时级窗口):SETNX balala:request:state:{user:cmd}:{tempID} (processing, TTL 24h);完成态缓存 ack 结果可直接重放;失败删 key 允许重试。 extractCachedAck 校验 ack 归属(user+cmd 匹配)防 tempID 冒用; mutation 类 scope 拼接 SessionID。
  2. DB 持久幂等(窗口过期后兜底):dm_messages 唯一键 (conversation_id, sender_id, client_dedup_id),MySQL INSERT IGNORE / PG ON CONFLICT DO NOTHING; RowsAffected=0 时反查原 message_id,controller 拿到 inserted=false。
  3. 副作用条件执行:inserted=false 分支跳过重投/离线推送/摘要更新,只回带 原始 ID 的 ack——每条分支都有注释论证(为什么不重投:broker 那边首次已投; 为什么回原 ID:客户端两个 tempID 对应同一消息的 UI 合并)。

批量异步通道全部 INSERT IGNORE,配合 broker at-least-once 重投安全; 房间持久化消费以数据库去重计数为最终零丢失判据(混沌测试即用此验证)。

相关机制

  • 离线消息没有独立队列:消息即离线消息(DB 持久化 + Redis 热历史 top-1000), 客户端重连后按游标双向分页补拉;在线投递三级判定(本地 → gRPC → Redis presence) 未命中才入 push_tasks 离线推送(静音时段/通知级别/mention 级别过滤)。
  • 设备同步现状与 sliding-sync 规划见 docs/superpowers/specs/2026-08-26-sliding-sync-sticky-events-research.md (结论:自研窄协议,不照搬 Matrix MSC4186)。

专题 6:AI 流式生成—取消—持久化的竞态控制

位置:lib/aiagent/worker.go、stream_cancel.go、dispatch.go、reply_delivery.go。

问题背景

AI 回复是跨进程流式管线:WS 实例接受用户「停止生成」,任意 ai_worker 副本持有 生成 goroutine,delta 帧经 broker 多跳到达用户。取消请求与生成/持久化并发推进, 必须保证:用户已停止的请求绝不 publish done,同时「完成先于取消到达」的合法完成 不能被误杀。

难点在哪

  1. 取消可能出现在管线任意一点:排队中(还没被 worker 取走)、流中、流结束、 持久化前、publish done 前——每个间隙都要复查;
  2. 跨进程没有共享内存:取消信号要跨「WS 实例 ↔ worker 副本」传递,且不能依赖 pub/sub 的到达时序;
  3. ack 与首帧之间存在「无法取消」窗口:任务发进 broker 但 worker 还没接时, 用户点停止,谁来认领这个取消?
  4. 流中途 worker panic:外层 recover 拿不到消息 ID,发不出带 ID 的终态错误, 客户端流就永远挂着。

实现方案

  • 排队期可取消:DispatchRequest 在 publish 之前就 RegisterQueuedStream (Redis 写 balala:ai:stream:active:<request_message_id> 授权元数据)——取消者 从第一毫秒起就能命中这个 key;发布失败则清理。
  • 跨进程取消 = Redis 轮询而非 pub/sub:watchStreamCancellation 每 100ms EXISTS cancel key——任何 WS 实例接受取消、任何 worker 副本持有生成, Redis 是唯一汇聚点,无时序依赖。
  • 三处显式复查:流中每个 chunk 之间、流结束、持久化前各查一次 streamCanceled.Load();external 路径同构三处。保证「已取消的请求不 publish done」 在所有交错下成立。
  • panic 兜底:recoverStream 作为流 goroutine 的 recover,能发出带 messageID 的 终态 error(外层 handler 的 recover 做不到,那时 ID 还没建立)。
  • 语义显式接受并文档化:「完成可能赢过取消」——持久化完成、done 已发、取消 才到,此时按完成处理(RequestStreamCancel 注释),不追求线性化。
  • 持久化边界:先 insertSyncedDMMessage 落 dm_messages,再 publish chat.stream.done(带 FullText)——done 到达客户端时历史里一定有这条消息。
  • 会话租约:acquireConversationLease(Redis mutex,TTL 10min)保证跨实例同一 会话同时只有一次生成,占锁返回 operation_in_flight;Redis 故障 fail-open。

回复尺寸治理

内联回复上限 500 runes 且 fail-closed 不截断(delta 可能已展示给用户,截断的 done 文本与已展示内容不一致);客户端声明 accepted_modes=["object_ref"] 时超大回复 外置对象存储发 reply_externalized(可恢复)。

专题 7:LLM 付费副作用的幂等闸与双审计

位置:lib/aiagent/tool_executor.go、lib/toolcatalog/、lib/toolaudit/。

问题背景

AI 的 tool calling 会让 LLM 触发真实副作用(HTTP 调用客户业务系统:退款、下单、 改状态)。这类执行同时面对三类重复:broker at-least-once 重投、LLM 在上下文里重放 同一调用、进程崩溃后恢复。而「退款执行两次」是不可接受的事故。同时企业场景要求 人工确认高危操作(挂起 → 管理员批准 → 续跑)。

难点在哪

  1. 幂等键要内容寻址:重投的同一消息与 LLM 重新生成的相同调用必须命中同一个键, 但参数 JSON 的字节表示可能不同(空格、键序);
  2. 挂起-续跑是跨进程状态机:LLM 对话不能无限等,管理员批准可能在几分钟之后, 执行行必须在这段时间里安全地停在中间态;
  3. 审计与执行的顺序是安全边界:审计写不进去时还执行吗?超时后副作用到底发生了没有?

实现方案

  • 统一审计先行、fail-closed:beginAgentToolAudit 写 attempted 事件失败即拒绝 执行(无审计不执行副作用);完成后写 completed。参数与请求只存 SHA-256 摘要 (隐私 + 行宽),幂等键也是摘要。
  • 内容寻址幂等键:sha256(bot + userMessageID + toolID + name + arguments) → ClaimAIToolExecution:已有终态(succeeded/failed/degraded)直接回放持久化结果, 不再执行——重投/重放返回的是上次的结果而非二次副作用。
  • 人工确认状态机:工具标记 RequiresConfirmation 时 claim 返回 pending_confirmation 给 LLM(对话继续,告知用户等待确认);管理员在 admin 端 ApproveAIToolExecution 后 ExecuteApprovedExecution 复用同一执行行续跑 (绕过确认闸但保留全部审计链)。
  • SideEffect 三态:not_started / committed / unknown——超时或 fallback 场景 显式标 unknown(副作用可能已发生),不假装知道;Decision: allowed/denied/pending。
  • 目录契约单一来源:lib/toolcatalog 定义 20 个工具的稳定授权契约 (Key/Version/RequiredScope/Risk 五级/RequiresConfirmation/AllowedActors), HTTP、MCP、Agent 适配器都投影这份目录;OpenAPI 入站写入共用同一张 tool_call_audits 生命周期证据表。
  • 权限 scope:来自 WS 会话的 PermissionScopes,支持 * / module:* 通配。

同类幂等闸

kb.ingest(文档入库)用 TryClaimKBDocumentForIngest CAS 状态迁移、 call.analyze 用 TryClaimCallRecordForAnalysis——每个付费/非幂等主题一个 DB claim 闸 是项目的固定模式;ai.request 没有可 claim 的行,靠 AckWait 预算兜底(运维文档明确 其重投代价:客户收到第二条回复 + 第二次付费补全)。

专题 8:客服会话边界的并发正确性

位置:lib/dblib/cs_session_db.go、lib/aiagent/cs_session_hook.go、 cs_session_closer.go、handover_sweeper.go。

问题背景

IM 的 DM 是一条无起止的流,而客服 KPI(首响时长、会话计数、满意度分母、小结) 需要「一次服务」有边界。要在不动 DM 数据模型的前提下叠加边界,且多副本 worker 并发扫描关闭时不出错。

难点在哪

  1. 「进行中至多一个」的唯一性约束在 MySQL 上有两个坑:唯一索引把多个 NULL 视为 互不相同(挡不住并发双插);TIMESTAMP 哨兵有 1970 下界与时区漂移问题;
  2. 关闭时要做 LLM 小结(最长 60s)——小结期间来了新消息,聚合窗口就错了;
  3. 多副本 sweeper 抢同一个会话、两个相位(关闭 + 记忆回写)共享一个 ticker 与锁, 预算算错会永久饿死其中一个相位;
  4. 聚合窗口用时间区间会把开启消息自己排除在外(started_at 与 DB 默认的 created_at 相差毫秒级)。

实现方案

  • closed_seq 整数标记代替 NULL/时间戳:进行中恒为 0,配合 UNIQUE (conversation_id, closed_seq) 保证至多一个进行中;关闭时置为本行自己的 id(每行唯一),槽位释放、历史行永不冲突。「确保有进行中会话」就是一次 INSERT ... ON CONFLICT DO NOTHING,原子且跨驱动,并发双插只开一行。
  • 热路径只做一件事:DM 持久化后判定为客服会话即执行一次 ensure-upsert, 后续消息只单调推进 last_message_seq 活动水位;message_count/first_response_at 等聚合不在热路径写,关闭时一次 SQL 从 dm_messages 的 id 区间现算 (id 区间而非时间区间——时间区间会漏掉开启消息自身)。
  • 乐观并发关闭:CloseCSSessionIfActivityUnchanged 带 WHERE last_message_seq = ?——小结 LLM 跑了 60s 期间新消息到达则水位已变, 关闭失败、下轮重试;聚合与关闭 UPDATE 同窗比对,消息不会掉进聚合→UPDATE 的空窗。
  • 双相位预算切分:单 Redis 锁(TTL 600s,每轮结束 Del,TTL 只是崩溃兜底)单飞; 两相位共享 tickStart,相位 1(会话关闭,idle 5min)另有 sweepTickBudget/2 = 120s 子预算——否则 20 个到期会话 × 12s 小结吃光 240s 总预算, 相位 2(记忆回写)永久不再运行。最坏一轮 ≤ 300s < 锁 TTL。
  • 扫描窗口双边(idleBefore / staleAfter):下界 7 天不是优化而是正确性—— 已抽取完的会话不会从候选集消失(routing 行永远 human + last_bot_id 非空), 按最旧优先排序它们永远排最前,累计够每轮上限后新会话永远轮不到(无报错无日志 无指标,人→AI 记忆回写永久停摆)。下界让这类会话落窗一次即出局。
  • 水位推进的显式枚举:WS DM 发送、bot 两条回复路径推进; 刻意不推进的三条(问卷卡/催评 DM 在会话关闭后才插入、extsync 历史回填从不开会话) 各有注释;已知漏洞(RTC 通话记录插入不推进水位)记录不修——修它是行为改动。
  • 小结失败不阻塞关闭:边界价值(时长/计数/首响)不依赖模型可用性;小结独立 60s ctx 超时(防“接了连接不吐字节”的网关永久卡住 ticker,连带停掉记忆回写)。

群客服扩展

cs_sessions.group_id(0=DM)是「哪个群的会话」唯一真源(禁止从 conversation_id 反解析或 LIKE 前缀——两个事实来源必漂移;且 SQL 反拼会让索引无法 seek);群会话 指标与 DM 不同义(message_count 只数客户自己的消息——chat_group_messages 无法归因 坐席回复是给哪个客户的),文档明确“不可横向比较”。

专题 9:记忆注入的预算降级阶梯

位置:lib/aiagent/memory_retrieve.go、memory_writer.go、memory_profile.go。

问题背景

AI 长期记忆分三层注入 system prompt:结构化客户档案、滚动摘要、pgvector 长尾事实。 prompt 预算有限(block 上限 1200 runes),超限时必须丢内容——但坐席手工写入的 承诺/禁忌类档案(source=‘operator’)丢不得:那是“该客户已获退款承诺”级别的信息, 丢掉的后果是 AI 再次拒绝客户。

难点在哪

这是一个读侧约束必须反推到写侧 API的闭环设计问题:

  1. 读侧把 operator/always_inject 标记为不可丢 → 如果写侧无总量限制,三个各 400 字的 特权字段就能超满预算,降级阶梯会依次丢光向量事实、丢光摘要、最后连特权字段 自己也被整体截断——无日志无指标,客户每一轮对话都没有记忆;
  2. 「模型永不覆盖 operator 值」要在 upsert SQL 层保证(两种方言语法不同);
  3. 抽取水位按 (bot, user) 还是按会话存?pair 级槽位装会话级计数会导致新会话永不 抽取、大会话每条消息都抽取一次。

实现方案

  • 四级降级阶梯(读侧,formatMemoryAsSystem):① 自由文本长尾事实逐条丢 → ② 历史摘要整段丢 → ③ 非特权档案字段从尾部逐条丢 → ④ 特权字段永不主动丢, 仅在特权字段自身超预算的病态配置下作为最后手段整体截断(注释标注为不得已下限)。
  • 写侧总量闸(MaxOperatorProfileTotalRunes = 400):坐席写入超额拒绝不截断 并报出上限;已有超限数据用 OperatorProfileTotalBefore 允许“只减不增”的单调收敛。 这是读侧“永不丢”承诺的必要条件——不可丢集合自身必须塞不满预算。
  • operator 优先的 SQL 级保证:Postgres ON CONFLICT ... DO UPDATE ... WHERE source <> 'operator' OR EXCLUDED.source = 'operator'; MySQL ON DUPLICATE KEY UPDATE 没有 WHERE、裸列名右侧读旧值——靠这两点实现等价语义。
  • 水位按会话存(ai_memory_conversation_marks,keyed (bot, conversation)): 摘要按 (bot, 客户) 跨会话累积(画像是对的),但触发判定比较的是会话消息数, 作用域必须一致;抽取/解析失败同样推进水位(否则持续故障期间退化为每条消息 一次 LLM 调用);基线读失败 fail-closed(读不到当 0 用 = 每消息一次调用)。
  • MySQL 兼容:档案与摘要表无 vector 列跨驱动可用;向量召回 Postgres-only, VectorRecallSupported() 在 MySQL 上短路在 embedding 付费调用之前。
  • 人机记忆闭环:产品没有“会话结束”事件,靠 sweeper 扫描已转人工的会话回写记忆 (坐席发言被 decodeHistoryRow 归为 assistant,人工处置结论自然进摘要); 转接用 last_bot_id 保留“最近由谁接管”(记忆主键是 (bot, user),没有它就写错 bot)。

专题 10:License 验签与编译期符号隔离

位置:lib/license/、Makefile、scripts/verify_sku.sh、cmd/packagegen。

问题背景

商业软件交付的信任模型:免费版与付费版是同一个代码库构建的,必须保证—— ① 改配置文件无法解锁付费功能;② 换公钥无法伪造授权;③ 免费版二进制里真的没有 付费代码(不是隐藏,是不存在);④ 授权过期不宕机(降级保核心)。

难点在哪

  1. 信任锚必须编译进二进制:任何运行时可修改的配置(公钥路径、enforce 开关) 都是攻击面;
  2. 「二进制里没有」需要验证手段——Go 剥离符号后还能探测什么?嵌入的前端 JS bundle 里有大量行为字符串(ai_bots、processRequest),普通 grep 全是误报;
  3. 过期语义是双刃剑:启动时校验简单,但授权在第 3 天过期时服务要不要停? 停了客户骂,不停就是违约。

实现方案

  • 三层隔离协作:
    1. Build tags:AI/Ultimate 独占文件整文件 tag;共享文件用函数变量 hook (默认 no-op,tag 后 init() 覆盖);中性助手(lib/idutil)basic 可调用;
    2. ldflags 公钥烧入:-X lib/license.embeddedPublicKey=<key>;release 构建的 resolveLicensePublicKey() 只返回内嵌 key——配置/环境变量/文件一律无效。 空 key(免费 basic)意味着拒绝一切签名 license,永远锁定 basic;
    3. pclntab 包路径探测:verify_sku.sh 探测 gated 包路径(函数名在 -s -w 剥离后仍存活于 pclntab),而不是行为符号(前端 JS 误报)或字符串常量(license 别名、表名、命令名本来就应编进 basic)。CI 每次推送跑,Docker 镜像额外验证 不含 ai_worker/sync_worker 二进制。
  • Ed25519 验签(signed.go):SignedDocument{Algorithm, Payload, Signature}; 签名计算在「压缩空白但不排序 key」的字节序列上——验证端对文档内存储的同一 payload 重新压缩得到相同字节。关键约束:不能改成 re-serialize 已解析对象, 那会改变字节序列导致已签发 license 全部失效(对存储字节签名,而非对语义签名)。 公钥支持 PEM/base64(4 变体)/hex 三形态,base64 与 hex 严格 32 字节定长判别。
  • 发布/开发双姿态:trust.go 定义 release 默认(不信明文配置、强制 enforce); trust_dev.go(devlicense tag)反转——该 tag 只出现在 make run/build 等 dev 目标,绝不进发行构建。
  • 过期降级双轨语义:
    • Require/Allows(expiry-unaware):描述“签发时授权了什么”,启动 fail-fast 用 (ValidateViperEntitlements 从配置反推本进程打开了哪些付费功能,未授权即拒绝启动);
    • PermitsFeature(expiry-aware):运行时 defense-in-depth,过期后降级为 defaultFeatures(EditionBasic) 判定——core IM 存活、Pro 功能即刻关闭、打印降级 告警,无需重启。两套约定不可混用。
    • 管理后台:servers/admin.LicensePermitsConsole 以同一份已安装授权检查 admin_console / rbac / audit_log / data_governance 和有效期;未加载授权拒绝, 显式安装的开发态 enforce=false 保留联调行为。启动时无有效后台授权返回 (nil, nil),不引导管理员、不启动后台任务、不监听端口,核心 IM 可继续运行。 HTTP engine 级门控覆盖登录、扩展 API 和静态资源,运行中到期后的新请求返回 403 / license_unavailable;定时公告和数据保留调度在开始下一项工作前复查。 已进入执行的操作不强制中断。授权文件仍在进程启动时加载,更换文件后需重启。
  • 单一事实来源生成链:lib/license(能力/额度)+ packaging/editions.yaml(展示 元数据)→ cmd/packagegen 生成官网 SKU 数据/功能对照文档/sku.mk;CI packagegen-check 防漂移,单测直接 re-render diff 同一份产物(无独立 golden, 两道守卫不可能不一致)。

已知边界

已落实的资源配额:ai_bots 和 kb_documents 在共享数据库写入层执法,覆盖 旧管理 API、控制台、配置初始化以及草稿发布。EffectiveQuotaLimit 使用同一份 运行时授权:缺失或过期降级 Basic,显式安装的 enforce=false 开发授权不限制; 0 禁止新增,只有 -1 表示无限额。签发额度和产品目录未改变。

  • Bot 按数据库全部现存行计数,停用 Bot 仍占名额;知识库按全库普通文档计数, 不按 Bot 分拆,pending/error 文档也计入。删除释放名额;已有资源超过限额时 不自动删除,允许更新和减少数量,阻止新增。KB/RAG 现有受支持部署仍为 PostgreSQL。
  • license_quota_locks 固定行锁 + READ COMMITTED 事务将计数、检查和写入串行化, 对共享同一主库、运行本版本的实例生效;主库/锁表不可用则写入失败。 锁表由 20260924_create_license_quota_locks.sql 创建,升级须先应用迁移。
  • 草稿保存不预占 live 文档名额;发布时按最终数量检查净增长,Bot 修改、KB 增删改 和版本状态在一个事务中提交。满额可以等量替换;配额拒绝/SQL 失败整体回滚, 并发或重复发布同一草稿不能重复创建文档。发布后入队仍沿用现有 broker 流程, 不属于该数据库事务。
  • 评估临时向量载体不占用户文档配额,由专用内部方法从真实 eval run ID 构造, 常规 Bot/文档写入拒绝 eval_run: 保留命名空间。API 超额返回 403 / license_quota_exceeded,包含 quota 和 limit。

网关实例与连接配额:lib/licensecapacity 在同一 Redis DB 内原子计量。

  • cmd/websocket 在启动监听前占用实例名额;每次 WebSocket 握手先预占连接名额, 覆盖待升级和已连接状态。升级失败归还名额,断线先关闭传输再扣减;重复释放和 Redis 命令重试不会重复扣减其他连接。实例计数不包含独立 persist/sync/AI worker。
  • 账本与运维用的可选实例目录独立,使用固定 Cluster hash slot、随机进程身份及 Redis 服务端时间。租约 45 秒、每 10 秒续租,本地单调时钟提供更保守的 30 秒 服务期限。Redis 命令使用 2 秒 context deadline,并启用客户端 socket deadline。
  • Redis 错误、账本缺失或租约丢失后停止准入、关闭连接并触发进程退出,不自动重占。 主 HTTP、管理控制台和 gRPC 在请求进入时检查本地租约,WebSocket 收发也检查; 已进入执行阶段的请求不具备每个数据库/broker 副作用的事务级 fencing。 正常停机先关闭准入和传输,再清理服务,最后归还实例名额;崩溃由租约回收。
  • 连接超限返回 429 / license_quota_exceeded,容量依赖失效返回 503 / license_capacity_unavailable。缺失/过期授权降级 Basic;显式开发授权 无限额但仍要求账本可用。连接额度降低不强踢旧连接,排空至限额下才恢复准入; 实例额度降低按随机进程身份排序保留固定子集,其余实例停止服务。
  • 部署须使用同版本、同授权、同 Redis DB;混合旧版本不构成执法边界。 imcore:{license-capacity}:* 属于保留账本,Redis 应避免 key 驱逐并保护写权限。 Redis 清空/驱逐、服务端时钟跳变、主从切换或复制回滚可能在旧进程发现失效前 造成短暂计数偏差,因此不能宣称共识级全局互斥或跨独立 Redis 的激活绑定。 这符合 Redis 官方关于异步复制无法保证故障切换期间互斥的限制说明 (https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)。

日消息量配额:发送准入在 lib/licenseusage 中由 Redis Lua 原子计量。Redis 服务端时间确定 UTC 日;普通 DM、群发/广播各计一次,AI 回复(包括确定性拒答和外部工作流)、满意度问卷、OpenAPI 流首片和出站 SMTP 邮件在首个副作用前各计一次。分片、回执、输入状态、心跳、内部 fanout/redelivery、历史同步/导入和系统生命周期事件不计。准入后不退款,因数据库、broker、SMTP 或流式传输失败可能无法判定是否已经送达;调用方缓存的幂等 ACK 在配额前返回,缓存过期后的重试可能再次计数。Redis 账本字段缺失、服务不可用或服务端时间回拨时 fail-closed;账本跨重启和授权变更保留。外部邮件入站和历史导入不计,出站邮件计入。该机制依赖受保护的 Redis 与未被篡改的服务代码,不能阻止拥有 Redis/源码权限的操作者直接改账本或移除检查。

授权时间防回拨:特性和资源配额的授权窗口判断统一使用进程内单调时钟护栏,运行中把系统时间拨回不会重新激活已过期授权;签发时间在未来的授权在 issued_at 到达前保持 Basic 基线。Redis 初始化后每 10 秒采样服务端 TIME,并把最高微秒时间持久化到 imcore:{license-time}:floor:v1;样本超过 30 秒、低于持久化水位、服务端时间回退或 Redis 不可用时,强制授权判断 fail-closed,直到新样本追平水位。它是受保护 Redis 上的采样外部时钟,不是共识时间;删除时间 key、恢复旧 Redis 快照、故障转移或拥有 Redis 写权限的操作者仍可影响时间。

授权账本绑定:网关容量和日消息量账本存储完整授权配置的 SHA-256 指纹。同一 Redis 计费域出现不同授权指纹时拒绝继续准入,授权续期或错误混部不能通过更换 key 名称重置计数。不同 Redis 部署仍是不同计费域,不能宣称全局部署绑定。

签名授权载荷可选包含 deployment_id。包含时,所有进程启动必须配置相同的 deployment.id 或 IMCORE_DEPLOYMENT_ID,缺失或不匹配直接 fail-closed;不包含时保持旧授权兼容。它绑定的是运营方定义的部署名,真正的信任根仍是签发私钥和受保护的 Redis/配置。

授权密钥轮换与撤销:签名文档可选携带 key_id,配置中的公钥支持 key_id:公钥,key_id-2:公钥 轮换集合;没有 key_id 的旧文档在只有一把受信公钥时继续兼容。可选的签名撤销清单 license.revocation_file 能按授权 ID 或部署 ID 在启动前拒绝授权,license_tool revoke 生成该清单。撤销清单和公钥都必须受保护,文件更新需重启生效;它提供了私有化部署的离线撤销能力,但不防止拥有本机写权限的人同时替换授权、公钥和撤销清单。

二进制完整性与混淆:lib/integrity 可为发行目录中的每个服务二进制生成 SHA-256 记录,并用独立 Ed25519 私钥签名 manifest。启用 hardened 构建时,验签公钥和强制开关通过 ldflags 写入二进制,进程在安装 license 前验证自身文件;因此替换二进制或 manifest 会被发现,修改普通配置不能关闭校验。cmd/integrity_tool 负责生成密钥、签名和离线验证;make integrity-* 通过显式的 garble 工具链入口构建,package-integrity-* 目标把签名 manifest 放入 config/。混淆只增加逆向成本,不是密码学边界。攻击者若能同时改写二进制、manifest、内嵌公钥或启动环境,仍可绕过,生产应配合只读镜像、最小文件权限及 Release 的 cosign/SHA256/SBOM 验证。

私有化部署的远程授权服务建议:适合作为可选的“在线续期/撤销/设备登记”控制面,不适合作为每条消息或每次请求的硬依赖。离线客户、内网隔离、授权服务故障和跨地域延迟都要求本地签名授权仍能独立运行;在线服务只签发短期或可撤销的 Ed25519 授权凭证,本地缓存最后一次有效凭证并设定明确的宽限期,宽限期结束后仅停用付费能力。服务端不应把 Redis 或数据库中的额度状态作为授权信任根,也不应接受客户端上传的公钥;发布方私钥留在授权服务/HSM,实例通过 mTLS 或一次性注册令牌认证。这样能补上撤销和租户生命周期管理,但不能阻止拥有客户主机 root、二进制写权限或完整源码的操作者绕过本地检查。

仍未落实:跨独立部署的授权绑定。跨重启的授权时间回拨已由 Redis 持久化水位覆盖, 但不构成共识级时间或防篡改存储。二进制完整性和混淆 已提供 hardened 构建入口,但它们仍以未被修改的启动环境和受保护的文件系统为边界;不能阻止 持有数据库/Redis 写权限的人直接改账本,也不能阻止有源码和编译能力的人移除校验。

专题 11:审计日志 HMAC 哈希链

位置:lib/auditlog/、lib/dblib/admin_audit_log_db.go。

问题背景

管理后台的审计日志是问责证据:谁在什么时候改了什么。但日志存在 DB 里, DB 管理员(或拿到 DB 权限的攻击者)可以悄悄删改一行。哈希链让任何篡改可被检测—— 这是区块链之外哈希链最经典的合规应用(RFC 6962 式思路)。

难点在哪

  1. 签名的输入必须与「从数据库读回的值」逐字节一致——线上 MySQL TIMESTAMP 列 只有秒精度,Go 侧亚秒时间戳签出来的哈希读回来无法复现(v1 链就死在这里);
  2. 并发插入时链尾竞争:两条日志同时 claim 同一个前驱,链就分叉了;
  3. 验证本身的一致性:验证器逐行扫日志再读链尾状态,两次 autocommit 查询之间 恰好有新日志提交,就会看到假性的 tail mismatch——验证器误报比不验证更糟;
  4. 审计写入不能拖垮请求(异步),又不能丢(问责证据)——背压策略与普通日志相反。

实现方案

  • 写入管道:goroutine 池(2 worker / 1024 buffer)异步写;Submit 阻塞反压 不丢弃——宁可拖慢请求也不静默丢审计(与 webhookaudit 的 best-effort 刻意相反, 两者语义不同:审计是证据,webhook 观测是可丢弃的遥测)。RedactJSON 递归掩蔽 credential 类 key(密码/secret/token)进入不可变日志之前。
  • 单事务链式签名(InsertAuditLog):
    1. ensureAuditChainState INSERT IGNORE 初始化链状态行;
    2. SELECT last_entry_id, last_hash ... FOR UPDATE——行锁串行化链尾,并发插入 在这里排队,链不可能分叉;
    3. 前驱哈希衔接(仅 v2 签名行参与);
    4. EntryHash = HMAC-SHA256(integrity_key, canonical),canonical 将 14 个字段以 \x1f 单元分隔符连接(字段值不可能包含分隔符歧义);
    5. 同事务 INSERT 行 + UPDATE 链状态。
  • 时间正规化:created_at 统一 UTC 截断到秒——与 MySQL TIMESTAMP 列精度对齐, 保证「签名输入 == 读回值」。v1 链因亚秒精度不可复现而废弃重建(signature_version=2), legacy 行不计入验证。
  • 一致性快照验证:VerifyAuditChain 用 REPEATABLE READ 只读事务包住日志扫描 与链状态读取——消除并发提交造成的假 mismatch。逐行验证 prev_hash 串联 + hmac.Equal 常量时间比较 + 对账链状态尾。
  • 监控友好:Integrity 端点篡改时返回 200 + valid:false(而不是 500)—— 篡改是预期内的证据而非传输错误,监控按 body 告警不按状态码。
  • 密钥解析链:governance.audit_integrity_key → 旧 admin.audit_integrity_key。 不再用 admin.console.jwt_secret 兜底;两个用途必须使用独立密钥,避免 JWT 密钥泄露后同时破坏审计链的不可篡改证明。

专题 12:数据治理的擦除与归档事务边界

位置:lib/governance/、lib/dblib/data_governance_execution.go、 data_governance_message_retention.go、cmd/governance。

问题背景

GDPR/个保法场景:客户行使删除权时,他的消息、档案、AI 记忆、客服会话要可验证地 删除;同时别人的会话不能被毁(客户 A 的删除不能让坐席与客户 B 的对话残缺)。 另有一侧需求:消息按保留策略归档到对象存储后从 DB 删除,且要能恢复。

难点在哪

  1. DB 事务与对象存储不可同事务:擦除客户时要删他上传的附件对象,DB 回滚了 对象删了怎么办?归档反过来:对象传成功了 DB 提交失败,对象成了孤儿;
  2. 假名化要不可逆但稳定(同一人两次擦除产生同一假名,可关联统计、不可识别);
  3. 恢复归档是高危操作:manifest 本身在 DB 里,被篡改的 manifest 不能变成任意 INSERT 的注入通道;
  4. 主体角色不对称:客户删除 = 删行;坐席“删除” = 只假名化他经手的记录 (其他客户的会话要保留)。

实现方案

  • 数据资产清单驱动:14 个 DataAsset 声明表级元数据 + SubjectColumn 精确声明 每个身份列的 delete | pseudonymize 动作——执行器遍历清单而非硬编码。
  • 擦除事务(EraseSubjectDataWithStore):legal hold 前置阻断 → 收集附件对象 key → 单事务内:DM 会话 ID 重写(dm:a:b 规范排序,user id 可含 :)→ 4 张消息表 假名化(sender 替换 + search_text + payload JSON 重写——payload 只替换精确等于 userID 的字符串值,绝不做自由文本子串替换)→ 群/社区 ownership 转假名 → 44 条删除 spec + 5 条假名化 spec(他人相关行保留、只换本主体列,CASE 双臂 UPDATE 同时覆盖双向列)。
  • 对象存储补偿:data_erasure_markers 落对象 key 清单,ReplayErasureMarkers 对擦除后新写入的迟到对象重放删除——最终一致补偿,接受“DB 先成功、对象后补删”窗口。
  • 假名化函数:"deleted-" + HMAC-SHA256(salt, userID)[:20]——盐稳定则同一人稳定, 无盐不可逆。
  • 归档闭环:事务 SELECT ... FOR UPDATE 锁批 → 连同 receipts/audiences/mentions 关联行打包 envelope(SchemaVersion/Columns/Rows/Related/Policy)→ JSON + SHA-256 checksum → 上传对象存储 governance/message-archives/yyyy/mm/dd/<uuid>.json → 写 manifest → 删关联行与主行 → commit;未提交则 defer 删除已上传对象(反向补偿)。 恢复侧三重校验:checksum + envelope/manifest 一致性 + 表/列双白名单 (validArchiveColumns,防篡改 manifest 注入)→ INSERT IGNORE 幂等回插。
  • fail-closed 但不夸大:execution_enabled 默认 false;legacy/不可用表优雅降级为 Warning 而非整体失败(isUnknownTableError)——报表如实反映覆盖范围。
  • CLI 与 admin 双入口均支持 preview(默认)/ execute(显式);备份恢复演练脚本 scripts/governance_drill.sh 要求目标库显式空 DSN,绝不 drop。

专题 13:SSRF 与 DNS Rebinding 防护

位置:lib/netguard/netguard.go。

问题背景

系统有多处由管理员/运营配置 URL、服务端发起请求的场景:消息回调、推送 webhook、 语音转写、AI webhook provider、beforeChat hook webhook、KB 文档 URL 抓取。一旦管理 凭据泄露,这些 URL 就是 SSRF 跳板——攻击者把 URL 指向 http://169.254.169.254/ (云元数据)或内网服务。所有出网点必须统一过防 SSRF 校验。

难点在哪

普通的「校验 host 是否内网 IP」防不住 DNS rebinding:校验时域名解析到公网 A 记录 (通过校验),真正拨号时 DNS 返回内网地址(TTL 极短的第二次解析)——**校验与拨号 之间存在的解析窗口(TOCTOU)**是根问题。环境变量里的 HTTP_PROXY 还会在 guard 之外再做一次解析,是另一条绕过路径。

实现方案

  • GuardURLHost(请求前校验):解析全部 A/AAAA 记录,拒绝 loopback / link-local / RFC1918 / 组播 / 未指定地址;解析后校验而非字符串匹配(公网域名解析到内网 IP 也拦)。域名解析失败的直接拒绝。
  • NewGuardedHTTPClient(拨号时钉死,关闭 TOCTOU 窗口):自定义 Transport 的 DialContext 内重新解析并验证后,用已验证的 IP 直连——URL 仍是主机名 (标准 transport 保留 Host 头与 TLS SNI,HTTPS 证书校验不受影响),但实际连接的 IP 是验证过的那个,第二次 rebind 解析无从发生。Proxy = nil 显式禁用环境代理, 防代理在 guard 之外解析。
  • CheckRedirect:拒绝跨 authority 重定向(http://公网/redirect→内网 链同样堵死)。
  • 所有出网调用方统一复用:callback、push webhook、speech、AI workflow provider (guardWorkflowHost seam)、KB URL 抓取、hook webhook。加新出网点时的固定动作是 netguard.GuardURLHost + NewGuardedHTTPClient 二件套。

设计取舍

校验是请求前的快速失败(配置错误尽早暴露),拨号钉死是真正的安全边界——两层都要, 缺一不可。TLS 场景 SNI/证书校验依赖主机名,因此实现选择了「URL 保留主机名、 socket 用 IP」的透明替换,而不是重写 URL(重写会破坏证书校验)。

专题 14:Webhook 回调的能力提升防护

位置:servers/websocket/chat_message_callback.go、lib/chatseg/。

问题背景

发送前回调(message_callback)允许运营配置的 webhook 整体改写用户消息(文本、 类型、消息段)——这是合法能力(内容加工、翻译、风控改写)。但 imcore 的消息段里 有特权类型:quick_reply/card(富交互段,携带“点击即以用户身份代发文本”的 按钮)与 AI 引用段。如果被攻陷的 operator webhook 能把普通用户消息改写成带按钮的 卡片,等于把运营的改写能力提升成了 bot/坐席的发送能力——点击按钮的用户会 “亲口”说出按钮上写的任何话。

难点在哪

信任级别有两层:改写能力(operator 级)与发送门权限(bot/坐席级)。 改写路径天然绕过发送门(消息已经过门了),必须在校验层保证改写不能跨越能力等级。 而消息段是共享的扁平联合体(ChatSegment),一个 text 段技术上可以夹带整个 Actions 数组——校验必须覆盖所有走私路径。

实现方案

  • 回调不可铸造特权段:applyMessageCallbackReplacement 改写后的消息若包含 入站不存在的 quick_reply/card 交互段 → 拒绝改写;AI 引用段同理 (applyCallbackSegmentsIfNotNewlyInteractive)。「入站没有的交互能力,回调不能造」。
  • 发送门与校验共享同一套规则:lib/chatseg(独立包,因 dblib 不能 import servers/websocket——依赖方向决定共享包位置)的 ValidateInteractiveSegments:
    • 全消息级 action-id 唯一(跨段去重——历史上服务端构造的卡片跳过跨段去重出过 错误应答 bug,这是该包存在的直接原因);
    • 非交互段必须证明 Actions/Rows/Answer 全空(堵 union struct 走私);
    • URL 仅 http/https(防 javascript:)、style 白名单、字段 rune 上限。
  • Answer 是服务端权威字段:客户端入站消息带 Answer 一律拒——否则发送方能 自己声明“已被回答”,让按钮一出生就是灰的。同类先例是 AccessURL。
  • 交互段不可编辑:普通消息也不可被编辑成交互消息——编辑路径既不经过 prepareSendContent(仅 DM 判定在那里)也不经过发送门,“先发文本再编辑成卡片” 本是完整绕过链;且允许改按钮文案 = 允许在客户回答后篡改“他当时点的是什么”。
  • 回答侧代发由服务端执行:chat_interactive_reply 构造 DM 请求直接调 ChatDMSendController(幂等/审核/会话 hook/限额/离线推送整串自动继承), 而不是让客户端自己发——“按钮 id == 实际发出的那句话”这条不变量不能交给客户端 保证(满意度统计依赖它)。失败区分瞬时/永久:永久的回滚 Answer 让客户重点, 否则是一张永远灰着的死卡片。
  • 发送门不对称设计:发送侧空名单 fail-closed(富交互是新能力,无“升级前” 可言——照抄空表放行惯例会让任何有 routing 行的用户都能发“我同意支付 5000 元”卡片); 回答侧完全不设名单门(回答的人是客户,本来就不是坐席)。

专题 15:双方言数据库迁移体系

位置:lib/migrate/、cmd/migrate、migrations/、scripts/check_migrations.sh。

问题背景

同一产品要支持 MySQL 与 PostgreSQL(+ 达梦 DM8 复用 MySQL 方言),迁移文件必须 双方言一一对应,且执行语义要适应两种数据库的根本差异——Postgres DDL 事务性可回滚, MySQL DDL 隐式 autocommit 不可回滚。

难点在哪

  1. 一致性:两个目录的文件集合漂移是静默事故(某表只在一边存在);
  2. 原子性分叉:Postgres 可以「文件执行 + 记账」单事务;MySQL 中途崩溃会留下 「DDL 已执行、记账未写入」的状态,重跑报重复 DDL 错误;
  3. 存量库接入:老客户手工建过表,直接跑迁移会全量报错。

实现方案

  • 目录约定:根目录 47 个文件是冻结的 MySQL 历史集;新迁移双方言分落 migrations/mysql/ 与 migrations/postgres/;migrations/manual/ 仅手工。 FilesFor:mysql/dm 跑根目录 + mysql/;postgres 只跑 postgres/(其 20250101_create_schema.sql 是自包含合并版)。
  • 记账:schema_migrations(filename PK, applied_at) 双方言兼容 DDL; Applied/Pending 按相对路径记账、字典序应用。
  • 执行分叉(Apply):postgres —— 文件执行 + 记录单事务原子提交; MySQL —— 先执行后记录,中途崩溃的恢复路径显式文档化(重跑报重复 DDL → 人工对账 或 Baseline)。
  • Baseline:存量库接入——全部文件标记已应用而不执行。
  • CI 集合一致性:check_migrations.sh 校验 mysql/postgres 文件集合互相对应, 单方言文件必须登记显式白名单(ALLOW_MY_ONLY/pgvector 专属等“已知的有意差异”); 根目录冻结检查(新文件名不得晚于冻结线)。
  • runner 体验:-status / -dry-run / -baseline / -until <前缀排他停止点> / -timeout(默认 10 分钟,大表回填可延长);DSN 解析与 dblib 完全一致; 迁移经 go:embed 打进二进制(部署包无需携带 SQL 目录)。
  • 运行时兼容兜底:sql_dialect.go(专题 4)+ DAO 层 isUnknownColumnError 检测旧 schema 自动降级 legacy 列集,滚动升级期间新旧 schema 并存可服务。

专题 16:协议驱动的四端 SDK 生成

位置:clients/protocol.yaml、cmd/protocolgen、sdk-api/、scripts/sdk_api_surface.py。

问题背景

四个语言(TypeScript/Dart/Swift/Kotlin)的 SDK + 中英文 API 文档 + webhook 契约 + JSON Schema 都要与服务端 ~130 个 WS 命令保持一致。手工同步必然漂移:服务端加了 命令,SDK 与文档三个月后才跟上(或者永远没跟上)。

难点在哪

  1. 漂移检测需要双向闭环:只检查「manifest 里的都注册了」不够,还要「注册了的 都在 manifest 里」(或显式白名单)——漏掉反向检查,未声明的命令就成了契约外的 隐藏 API;
  2. API 面快照要可评审:直接 diff 源码噪声太大,需要提炼出稳定的公共 API 基线;
  3. SDK 行为变更要有迁移叙事:四端客户升级 SDK 时需要知道哪些默认行为变了。

实现方案

  • 单一协议清单 clients/protocol.yaml:命令 + DTO + webhook 声明 + unexposed 白名单,一个文件是全部端点的唯一事实来源。
  • --check 双向交叉校验:manifest 命令必须已在 routers/ 注册(正向); 已注册命令必须已声明或在白名单(反向);DTO 与 models/chat_model.go 对齐; webhook 契约一致。
  • 生成 16 个文件:四语言 commands + 四语言 types、中英 API_REFERENCE、 WEBHOOK_CONTRACTS、contracts/webhooks.schema.json + examples、Go webhook consumer 示例。CI make protocol-check + git diff --exit-code 保证无漂移—— 生成物提交进仓库,任何手改都会被下次生成覆盖并被 check 拦截。
  • API 面快照:sdk-api/snapshots/{web,dart,kotlin,swift}.api 是可评审的公共 API 基线(--write 重新生成,CI --check);default-behavior.json 单独记录默认 行为,任何改动必须附 docs/sdk-migrations/<version>.md 迁移报告; source-digests.json 校验源完整性。
  • 平台适配层:TS SDK 同构核心 + 三个平台入口——Electron(凭据在主进程、preload 只暴露受限 transport,renderer 拿不到 ipcRenderer)、React Native(AsyncStorage + AppState/NetInfo 生命周期)、微信小程序(wx.connectSocket + 异步存储 + wx.uploadFile);Dart/Swift/Kotlin 各自完整实现 + UIKit。
  • 兼容策略:SDK 版本线独立于服务端(clients/SDK_VERSION),协议 additive 前向 兼容(任何服务端 ≥ v1.2.0)。

专题 17:AI 回复管线的工作流编排

位置:lib/aiagent/worker.go、worker_external.go、hooks.go、hooks_worker.go、 handover_sweeper.go。

问题背景

一条客服 DM 到达 ai_worker 后,要经过限流、转写、规则、记忆、检索、生成、工具、 持久化等十来个阶段,其中还有“本地管线 vs 外部工作流平台”的分叉。“编排”要回答三个问题: 阶段顺序怎么定、在哪分叉、后台定时任务怎么共存。这三者在本项目中不是自由组合的 DAG 引擎,而是三类各自收敛的编排机制。

总链路

用户 DM → [ws] dispatchAIBotDM ──publish──► ai.request (workqueue: 恰好一个 ai_worker 消费)
                                              │
                 ┌────────────────────────────┘
                 │  processRequest 管线(固定阶段序)
                 ▼
   ①限流(Lua滑窗) ②语音转写 ③beforeChat规则链 ──stop/handover──► 终止
                 │
           ④ fork 点(isExternalWorkflow?)
           ├─ 是 → 外部工作流(Dify/Coze/webhook):平台拥有记忆/RAG/工具,只传 query+session id
           └─ 否 → 原生管线:⑤记忆组装(预算降级) ⑥KB检索(pgvector) ⑦历史窗口(角色归因)
                 ▼
           ⑧ provider.Stream(SSE→chunk chan)
                 ▼
           ⑨ tool loop ≤3轮 ──finish_reason=tool_calls──► ExecuteTool
                 │     (审计先行 → claim幂等 → 执行 → 可能挂起等人工批准,见专题 18)
                 ▼
           ⑩ 持久化 dm_messages → publish chat.stream.done (push: 每实例 fanout → 本地投帧)

   [ai_worker 后台] 一个 ticker 两个相位的 sweeper:会话关闭(5min) → 记忆回写(30min)
   [ai_worker 后台] 排队路由派发器(5s):cs_routing_queue → RouteCSConversation(专题 19)

17.1 固定阶段序与显式 fork 点

processRequest 是固定顺序的阶段链,每个相对位置都有明确理由:

  • 限流 → 转写 → hooks 在最前:钱和外部调用相关的门槛先过;
  • fork 点在 hooks 之后、本地组装之前(isExternalWorkflow 判定,worker_external.go:29): 外部工作流平台(Dify/Coze/自建 webhook)拥有对话记忆、RAG 和工具,本地完全跳过 ⑤⑥⑦ 组装,只发一条用户 query + 平台上轮 session id(ai_bots_external_sessions 按 (bot, conversation) 存映射);回复仍走同一套 chat.stream.* 协议——WS 层和前端 完全不感知后端是谁;
  • 持久化边界在 publish done 之前:chat.stream.done 到达客户端时,dm_messages 里一定已有这条回复(insertSyncedDMMessage 先于 publish)。

17.2 声明式规则链:beforeChat hooks

最接近传统工作流引擎的部分——每 bot 可配一组 when <condition> then <action> 规则 (ai_hooks 表),在管线 ③ 处求值。核心引擎是纯函数 evaluateHooks (lib/aiagent/hooks.go:120-146),三个语义要点:

  1. 优先级稳定排序:sort.SliceStable 按 priority 升序(同优先级保持配置序);
  2. 动作分累积型与终止型:webhook 动作命中后累积并继续评估后续规则; stop_reply / handover 命中即 return 终止整条链(handover 隐含 stop_reply), 镜像融云 Agent Hooks 的 priority + stopReply 语义;
  3. fail-safe 到字节级:条件 JSON 解析失败、未知条件类型、未知比较运算符一律返回 false(hooks.go:70-72 注释:“a broken rule never fires rather than firing unexpectedly”)——坏配置不触发,不误伤主流程。

条件目前两种(keyword 子串匹配 / turns 会话轮数,支持 > >= < <= ==),刻意保持 确定性——意图识别是文档明确的后续增强。被抑制的回复发 chat.stream.error(code="blocked", detail=<hook名>) 终态并审计 status=blocked;webhook 副作用在 hooks_worker.go 执行: SSRF guard + fail-open(hook 坏不阻塞主流程)。

17.3 外部工作流平台委托

provider 分两类接口:LLMProvider.Stream(openai 兼容,走完整原生管线)与 WorkflowProvider.StreamWorkflow(dify / coze / webhook,isExternalWorkflow 后 fork)。 统一产出 StreamChunk(SessionID 字段专为平台会话续接设计);api key 经 ai_worker.api_keys 的 ref 解析;拨号前过 SSRF guard。外部平台的上下文隔离交给平台 (本地历史完全不发),handover/审计/dm_messages 落库与原生路径一致。

17.4 多相位后台调度器:一个 ticker,两个相位,预算切半

ai_worker 没有散装的“每任务一个定时器”,而是共享 ticker + Redis 锁单飞 + 相位预算 (handover_sweeper.go):

  • 相位 1 = 会话关闭(idle 5 分钟,要快——小结和满意度尽快生效);相位 2 = 记忆回写 (idle 30 分钟,要准——不在对话半截抽结论)。两个阈值刻意不同,共 ticker 不共配置;
  • 相位 1 有 总预算/2 = 120s 子预算——否则 20 个到期会话 × 每个 ~12s 的 LLM 小结就能 吃光 240s 总预算,相位 2 首次预算检查即失败,记忆回写在该负载下永久停摆;
  • 最坏一轮 ≤ 300s,必须 < 锁 TTL 600s(锁每轮结束 Del,TTL 只是崩溃兜底)。

排队路由派发器(专题 19)是同进程另一个独立循环,5 秒一拍,互不干扰。

专题 18:Tool Calling 的流式累积与受控副作用执行

位置:lib/aiagent/tool_loop.go、tool_executor.go、tool_builtin.go、 lib/toolcatalog/、lib/toolaudit/。

问题背景

LLM 的 function calling 会让模型触发真实副作用(HTTP 调用客户业务系统:退款、 下单、改状态),同时 tool_calls 在 SSE 流里是分片到达的。要解决两件事:流式分片如何 重组成可执行的完整调用;LLM 决策的副作用如何在一个不受信任的决策源之下安全执行 (重投/重放/崩溃三类重复 + 人工确认挂起)。

18.1 Schema 下发与流式分片累积

  • toolsToSchema(tool_loop.go:49-69):ai_tools 表行 → OpenAI tools schema; 空 parameters_json 替换为宽容默认 schema;disabled 工具跳过;
  • 流式累积是第一道技术坎:同一个调用的 arguments JSON 字符串会被拆成多个 delta。 accumulateToolCalls(tool_loop.go:14-34)按 Index 键累积到 map: ID/Type/Name 非空才覆盖,Arguments 字符串追加;流结束后 sortedToolCalls 按 index 排序输出完整调用列表——没有这一步,半截 JSON 无法解析执行。

18.2 Tool Loop 的收敛保证

worker.go 的循环:finish_reason=tool_calls → 逐个执行 → 结果以 role=tool 追加到 messages → 重新发起流式请求。三条硬约束:

  1. 最多 3 轮(maxToolLoops);最后一轮请求不再携带 tools 字段——从能力上 剥夺模型继续调工具的可能,强制产出纯文本,保证终止;
  2. 中间 tool 消息只在内存:只有最终 assistant 文本落 dm_messages(历史回放不含 工具协议细节);
  3. 每次调用一行 ai_tool_call_audit。

18.3 执行防御纵深

ExecuteTool(tool_executor.go:43-177)的步骤顺序本身就是安全设计:

① 统一审计先行(beginAgentToolAudit)── attempted 事件写失败 ⇒ fail-closed 直接拒绝
   (无审计不执行副作用;参数只存 SHA-256 摘要,不存明文)
② 工具存在 + enabled 校验、参数 JSON 解析
③ 权限 scope:hasToolPermission,支持 * / module:* 通配
④ 内容寻址幂等 claim:
   key = sha256(bot + userMessageID + toolID + name + arguments)
   ClaimAIToolExecution 已有终态 ⇒ 直接回放持久化结果,不再执行
⑤ 执行:builtin 注册表(handover_to_human)/ HTTP 工具
   (SSRF guard、4KB 响应上限、per-tool 超时、失败回落 fallback tool 并标记 degraded)
⑥ 双审计收尾:SideEffect 三态 not_started/committed/unknown
   (超时或 fallback 成功但主调用结局不明 ⇒ 显式标 unknown,不假装知道)

幂等键内容寻址是关键决策:重投的同一条消息和 LLM 重新生成的相同调用命中同一个键, “重放返回上次结果”而非二次退款。同类 claim 闸还用在 kb.ingest (TryClaimKBDocumentForIngest)与 call.analyze(TryClaimCallRecordForAnalysis)—— 每个付费/非幂等主题一个 DB claim 闸是项目的固定模式;ai.request 本身没有可 claim 的行,靠 AckWait 预算兜底(重投代价文档明示:客户收到第二条回复 + 第二次付费)。

18.4 人工确认状态机(挂起 → 批准 → 续跑)

目录里 RequiresConfirmation 的工具(高危):claim 返回 pending_confirmation,LLM 拿到“等待人工确认”继续与用户对话;管理员在 admin 端 ApproveAIToolExecution 后, ExecuteApprovedExecution(tool_executor.go:204-275)复用同一执行行续跑——绕过 确认闸但保留完整审计链。这是一条跨进程(worker ↔ admin)、跨分钟级延迟的挂起状态机。

18.5 工具目录契约:一处声明,多方投影

lib/toolcatalog 为 20 个已评审工具定义稳定授权契约(Key / Version / RequiredScope / Risk 五级 / RequiresConfirmation / AllowedActors)。HTTP 工具、MCP、AI Agent、OpenAPI 入站写全部投影这一份目录,而不是各养一套权限元数据;OpenAPI 写入与 AI 工具执行共用 同一张 append-only tool_call_audits 生命周期证据表(attempted → completed, 参数是摘要信封)。

专题 19:任务流转链路——从 broker 主题到排队路由状态机

位置:lib/broker/aisubjects、lib/dblib/cs_routing_runtime.go、 lib/aiagent/cs_routing_queue.go、lib/cschannel/、lib/dblib/cs_channels.go。

问题背景

跨进程的任务(AI 请求、KB 入库、客服分配)要回答:谁来做(恰好一个 vs 全员)、做重了 怎么办(幂等)、做不了怎么办(重试/推迟/取消)。项目里有两套并存的流转基础设施: broker 主题拓扑(跨进程消息任务)与数据库队列(客服排队分配),后者不依赖 broker,用纯 SQL 实现了持久队列 + 租约协调。

19.1 主题登记与双 stream:流转的“类型系统”

所有 AI/客服任务的 subject→通道映射是封闭集合,真源在 lib/broker/aisubjects (drift 测试拦截绕过登记的字面量——未登记主题在 NATS 发布无报错无日志,是静默黑洞):

类别 主题 NATS stream 语义
work ai.request / kb.ingest / call.analyze / ai.handover.brief / ai.survey.ready _ai_work(WorkQueuePolicy) N 副本竞争,恰好一个消费;JetStream 服务端拒绝同 subject 第二个 consumer——“禁止 fanout”由 broker 强制而非代码评审
push chat.stream.delta/done/error / ai.routing.changed / ai.handover.brief.ready _ai_push(LimitsPolicy) 每实例独立 durable,全量 fanout

work 用共享 durable pull consumer(不用 push+deliver group:那会把消息堆在服务端 选中的成员内存里,慢 worker 私有积压付费任务);RabbitMQ/Redis 用固定队列名/组名表达 同一语义(SubscribeWork 统一入口,详见专题 3)。判定纪律:每个新增主题必须过一遍 work/fanout 判定——有付费副作用或非幂等写入的(如 ai.survey.ready 会递增未读、 发离线推送)必须进 work 侧,否则 NATS 上会按副本数重复扣费/重复写入。

19.2 消费语义:总 ack + 幂等下沉

worker handler 一律 defer ack() + defer recover()——总是确认,失败通过 chat.stream.error 事件暴露而非 broker 重试(防重试风暴);at-least-once 的重复由 各业务点的 claim 闸吸收。这让“消息会不会重投”从运维问题变成“每个副作用点有没有 幂等闸”的代码评审问题。

19.3 排队路由状态机:纯数据库的任务队列

客服分配是任务流转最完整的实例——cs_routing_queue(建表 migrations/{mysql,postgres}/20260826_complete_cs_routing_runtime.sql,逻辑在 lib/dblib/cs_routing_runtime.go):

                    ┌─────────────────────────────────────────────┐
                    │          cs_routing_queue                    │
                    │  PK = conversation_id(每会话一行,天然幂等)  │
                    └─────────────────────────────────────────────┘
  入队(upsertRoutingQueueTx,attempts+1):
    · RouteCSConversation 无合格坐席 ──► waiting (next_attempt_at=now+15s, "no eligible agent")
    · 营业时间外 & offline_policy=queue ──► waiting ("outside business hours")
    · DM 团队转接无候选 ✗ 不进此队列(直接返回 team_unavailable)

  派发循环(ai_worker, interval_sec=5s, 批 100):
    SELECT ... WHERE status='waiting' AND next_attempt_at <= now
    ORDER BY priority DESC, enqueued_at ASC
    FOR UPDATE SKIP LOCKED                ← 多 worker 不抢同行
    然后 next_attempt_at = now+30s        ← 取件即租约
    │
    ├─ 会话已关            → cancelled("session no longer open")
    ├─ widget 禁用/改manual → cancelled
    ├─ 非营业 & policy≠queue→ cancelled;policy=queue → 推迟 now+5min
    ├─ RouteCSConversation 出错 → 推迟 now+5s
    └─ 分配成功 → assigned + SnapshotOpenGroupCSSessionClaim(写首响/归属)

  另有:群客服 release → assigned→waiting 且 next_attempt_at=now(立即重派)
        会话关闭器/换手释放 → cancelled

三个值得单独点出的设计:

  1. next_attempt_at 一列两用:既是重试到期时间又是多 worker 租约——没有独立 lease 列。别的 worker 30 秒内取不到这行;worker 崩溃后批次 30 秒自动重新到期被 接管,无需任何清理任务。跨 worker 租约语义有集成测试守卫 (TestListDueCSRoutingQueue_LeasesRowsAcrossWorkers);
  2. 固定退避而非指数退避:错误 5s、入队/重试 15s、非营业 5min、租约 30s——量纲 各自对齐自己的节奏(扫描间隔/人工修正时长/营业时段),场景上不存在“越重试越该 等更久”的负荷;
  3. 取消是被动发现:停用 widget、切 manual 都不是管理端同步取消队列项,而是派发 循环 5 秒内扫到即取消——管理面零耦合,代价是至多 5 秒延迟。

空闲关闭器还刻意跳过 waiting 会话(ListDueOpenCSSessions 里 NOT EXISTS (queue.status='waiting'))——排队中的会话不能被 5 分钟空闲超时关掉, 这是两个后台循环之间的一条隐性契约。

19.4 容量预占状态机:cs_agent_assignments

 reserved (reserved_until = DB时钟+120s)  ──FinalizeCSAssignmentReservation──►  active
     │                                          (锁定未过期的预占行,改写 conversation_id)
     │ 进程中途崩溃 ⇒ 无清理任务:所有容量谓词直接无视 reserved_until <= now 的行
     ▼
  active ──会话关闭(closeCSSession 同事务: 乐观锁防双关 + 释放容量 + 取消队列)──► released
  active ──DM 转接换手/预占回滚(ReleaseCSConversationAssignment)──────────► released

预占解决跨多步的 DM 换手(转接要先校验双方、建路由、写简报,中途不能被别人抢走 容量);TTL 用数据库时钟而非应用时钟(多实例时钟不一致时以 DB 为准)。过期的 reserved 行不写 released、无清理任务——靠谓词忽略实现“逻辑过期”,杜绝泄漏 (TestReserveCSAgent_ExpiredReservationDoesNotLeakCapacity)。

19.5 分配事务:RouteCSConversation

cs_routing_runtime.go:373-453,单事务完成“选人 + 占容量 + 认领 + 写队列”, 并发正确性靠两把锁:

  1. 坐席行锁:SELECT ... FROM cs_agents WHERE enabled AND ready ORDER BY user_id ASC FOR UPDATE——按 user_id 确定性排序加锁,两个并发分配者以相同顺序拿锁,不会 死锁、也不会花掉同一个最后容量槽。锁后在内存过滤:在线(跨实例 presence)、班次、 技能/语言、countCapacityUsageTx 逐个数实际占用(active + 未过期 reserved);
  2. 会话认领乐观锁:claimConversationRoutingTx 幂等 INSERT + 条件 UPDATE (仅 switched_by='' 或是自己)+ 回读 holder——没抢到但 holder 是有效坐席则 归属 holder(并发下两人同时分配同一会话,后到者接受先到者的结果而不是报错)。

选人策略在 chooseLockedRoutingAgent(:494-528):widget 主团队严格优先于 overflow_team_ids(逗号/分号/换行分隔,去重保序);least_loaded 按 active 数稳定 升序;round_robin 查该 widget 最近一条 assignment 找上次坐席、环形取下一个—— 游标存在 DB 里而非内存,多副本安全。

19.6 渠道入站:统一 Envelope + 事件级幂等(含当前边界)

外部渠道(首个适配器 Email)的入站流转:HTTP 入口(X-IMCore-Channel-Secret 常量 时间 SHA256 校验)→ adapter.Decode 归一化为统一 Envelope(EventID / 外部会话 id / 身份 / 附件)→ IngestCSChannelEnvelope 单事务落库:cs_channel_audit 以 (channel_id, external_event_id) 唯一键 INSERT IGNORE 做事件级幂等(webhook 重发直接返回 ErrDuplicateChannelEvent)→ 身份归并(ext_email_<sha256>)→ 建外部 会话。当前边界(如实记录):渠道入站尚未接入 19.3 的排队路由—— RouteCSConversation 的生产调用点只有群客服钩子与派发器,channel 上的 RoutingMode 目前只是存档字段;后续企微/WhatsApp 适配器必须先映射成 Envelope,不得绕过统一表 单独落库。

19.7 三条设计原则(收束)

  1. 幂等永远在业务层——broker/队列只承诺 at-least-once,每个付费或非幂等点配 DB claim 闸(工具执行、KB 入库、通话分析、渠道事件),“重复投递”被转化为“回放上次结果”;
  2. 状态机显式化且收敛有保证——tool loop 末轮摘 tools 强制终止;规则链 stop/handover 短路;排队项要么 assigned 要么 cancelled,没有悬空态;reserved 容量 靠谓词过期而非清理任务;
  3. 协调机制贴着数据走——能一条 SQL 解决的(SKIP LOCKED 取件、乐观锁认领)不引入 额外组件;跨进程才用 Redis(取消信号、会话租约、sweeper 单飞);需要审计与持久 证据的(排队、容量、工具执行)全部进数据库。

附录 A:关键文档索引

主题 文档
开发约定与全量设计记录 CLAUDE.md
API 参考 docs/API_REFERENCE_ZH.md(由 protocolgen 生成)
Webhook 契约 docs/WEBHOOK_CONTRACTS_ZH.md
部署形态 docs/DEPLOYMENT_PROFILES_ZH.md、docs/KUBERNETES_ZH.md
数据治理 docs/DATA_GOVERNANCE_ZH.md
备份恢复 docs/RUNBOOK_BACKUP_RESTORE_ZH.md
混沌测试 docs/CHAOS_TESTING_ZH.md
Worker 观测 docs/WORKER_OBSERVABILITY_ZH.md
性能报告 docs/perf/、loadtest-reports/
SDK docs/SDK_ELECTRON_ZH.md、docs/SDK_REACT_NATIVE_ZH.md、docs/SDK_WECHAT_MINIPROGRAM.md
设计规格全集 docs/superpowers/specs/
功能/价格对照 docs/IM_PRODUCT_FEATURES_ZH.md(由 packagegen 生成)

附录 B:术语表

术语 含义
SKU / Edition 构建 SKU(编译期 build tags 裁剪)/ 可售 edition(运行时 license 门控)
Bus / Broker 跨实例消息粘合层(bus.go)/ 可插拔消息中间件抽象(RabbitMQ/NATS/Redis)
workqueue vs fanout 竞争消费(每消息恰好一个消费者)vs 广播(每实例一份)
presence 在线状态(Redis session 三 key 结构),跨实例投递路由依据
routing 行 chat_conversation_routing:会话当前由 bot 还是人工服务(客服状态机)
episode 一次客服服务会话(cs_sessions 一行,closed_seq 标记边界)
活动水位 cs_sessions.last_message_seq:聚合窗口上界 + 乐观锁值
claim 闸 DB CAS 状态迁移,at-least-once 重投下保证付费/非幂等操作只执行一次
origin 去重 user 定向消息同实例直送 + broker 投远端的两段式投递防双投
pclntab Go 二进制函数名表(剥离符号后仍存),SKU 符号隔离探测依据
DSR Data Subject Request,数据主体访问/删除请求
legal hold 法律保留,阻断相关数据的擦除与归档删除