☰
WuKongIM v2 全量迁移后空历史 API 验收:从 channel not found 到空页响应
2026/10/17 1:52:23 网站建设 项目流程
  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

本篇文章围绕 WuKongIM 仓库中的 v2→v3 迁移验收报告《全流频道迁移后的空历史 API 验收》(见 docs/superpowers/reports/2026-09-08-v2-migration-empty-history-api.md),系统讲解"全流频道(仅含流消息、普通消息被全部排除)迁移后,首次正常发送前公开 API 如何正确返回空结果"这一问题的发现、修复与验收全过程。读完本文,你将理解 WuKongIM 消息读取链路中"存储层 NotFound / 路由后频道不存在"与 HTTP 状态码的映射关系、空会话/空历史场景下单条与批量同步的边界行为,以及如何用纯 v3 对照 + 真实迁移产物 HTTP 双轨测试来证明迁移后 API 行为正确。

一、问题背景:全流频道为什么"读不到历史"

在 v2→v3 迁移中,存在一类特殊的源数据:全流频道(stream-only channel)。这类频道中所有普通消息都被标记为"待排除的流消息",迁移策略同时排除 CMD 消息。由于流消息本身不落为普通历史消息,迁移完成后:

  • 目标频道没有保留任何历史消息;
  • 频道上也不存在消息运行元数据(message runtime meta)。

此时如果直接调用集群读接口读取该频道历史,底层会返回channel: channel not found。原因在于消息读取需要先定位频道日志,而"没有消息、没有运行元数据"的频道在存储层就是一个不存在的频道。

问题在于错误映射:旧迁移工作分支把存储层的NotFound直接当成 HTTP 400 返回给客户端。而消息业务层此前只"识别"存储层的 NotFound 一种情况,对于路由后(post-route)出现的频道不存在错误没有兜底处理,导致客户端在正常打开一个全新/已迁移的空频道时收到 400,而不是一个合法的空页。

二、通用修复:fix(message): handle empty conversations without errors

报告指出,最新 main 分支提交ded2c0bd4已包含通用修复提交065317e0bcbe8c6bcc0457410f974d0020718db1,提交信息为fix(message): handle empty conversations without errors。该修复的核心思路:

  1. 在成员校验之后,将两类"频道不存在"错误——存储层 NotFound 与路由后的频道不存在错误——都映射为空页;
  2. 同时处理新单聊(首次发送前无成员关系)、批量对齐(batch 中部分频道为空)及空会话未读操作;
  3. 保留既有限制:非成员、已移除成员(tombstone)、路由失败、存储失败仍然按原语义拒绝,不得因为"空"而被放过。

修复完成后,验收团队在独立工作副本上用纯 v3 新建空群验证了 main 已有行为,再在旧迁移分支上复现了相同请求返回 400 的问题,随后把原提交的用例代码、单元/集成测试与 FLOW 变更同步到迁移候选分支。整个过程中没有新增错误绕过、伪造消息、预置空频道日志,也未改变 v3 存储/复制逻辑。

三、源码级原理:同步读取链路的空页映射

修复的核心实现位于消息同步用例层 internal/usecase/message/sync.go。

3.1 单条同步:SyncChannelMessages

page, err := a.reader.SyncMessages(ctx, prepared.query) if errors.Is(err, metadb.ErrNotFound) || errors.Is(err, ErrChannelNotFound) { return SyncChannelMessagesResult{Messages: []SyncedMessage{}}, nil }

对应 sync.go 第 192-195 行:只要读取结果是metadb.ErrNotFound(存储层)或ErrChannelNotFound(路由后的频道不存在),就返回Messages: []SyncedMessage{}的空切片,而不是错误。注意返回的是非 nil 空切片,确保 JSON 序列化结果为messages: []而非null,这与集成测试中require.NotNil(t, page.Messages)的断言一致。

其中ErrChannelNotFound定义在 internal/usecase/message/errors.go,它直接复用了频道追加层的channelappend.ErrChannelNotFound。该错误在集群适配层(internal/infra/cluster/error_map.go)由channelruntime.ErrChannelNotFound包装而来,完整保留了"路由后定位频道失败"这一语义链。

3.2 批量同步:SyncChannelMessagesBatch与位置对齐

if !persisted && (errors.Is(readResult.Err, metadb.ErrNotFound) || errors.Is(readResult.Err, ErrChannelNotFound)) { continue }

对应 sync.go 第 262-267 行:批量同步中,如果某个条目的读取结果是空/频道不存在,直接跳过该条目,保持其Messages: []且Error为空,不破坏输入顺序的位置对齐(positional alignment)。其他真实失败(如非成员)仍在对应条目上报错误。注意SyncPersistedChannelMessagesBatch走persisted=true路径,不吞掉错误,用于会话 recents 的磁盘读取场景。

3.3 新单聊:prepareSyncMembership的空标记

if !ok && query.ChannelType == channelTypePerson { // The first persistent person SEND establishes membership. Opening a new // chat before then has no visible history and must not create membership. return preparedSyncChannelMessages{empty: true}, nil }

对应 sync.go 第 333-339 行:对尚未有任何成员关系的新单聊,同步直接标记为empty,SyncChannelMessages在 sync.go 第 189-191 行 提前返回空页,根本不发起消息读取——这是对"打开新聊天不产生任何可见历史、也不创建成员关系"语义的精确表达。批量路径 sync.go 第 246-249 行 同样只把非空条目加入读取列表。

3.4 成员隔离与解散限制仍然保留

  • 非成员 / tombstone 成员:prepareSyncMembership返回ErrSyncMembershipRequired(sync.go 第 340-342 行),对应 HTTP 400;
  • 频道解散:validateSyncChannelState对metadb.ErrNotFound容忍(空频道允许读),但一旦频道存在且Disband != 0则返回ErrSyncChannelDisbanded(sync.go 第 362-370 行);
  • 路由/存储真实失败:SyncMessages返回的其他错误照常向上传播。

单元测试 internal/usecase/message/sync_empty_test.go 对这一行为做了精确锁定:TestSyncEmptyPersonConversationDoesNotReadMessages断言空单聊同步与批量同步都没有触发任何底层读取(reader.queries == 0 && reader.batchCalls == 0);TestSyncEmptyPersonBatchPreservesAlignment验证"空人会话 + 活跃群 + 空人会话"三元素批量中只有活跃群被读取且保持位置对齐;TestSyncEmptyConversationKeepsMembershipFences则锁定"缺失群 / 已移除人会话 / 已移除群"三种场景仍必须返回ErrSyncMembershipRequired。

四、纯 v3 对照:TestEmptyGroupHistoryHTTPBeforeFirstSend

报告的第一条验收路径是纯 v3 对照:不使用任何 v2 数据,通过公开 HTTP API 创建普通群与成员,分别在单节点集群与三节点集群中验证。对应集成测试实现位于 internal/app/empty_history_integration_test.go。

4.1 测试构造

  • 每个节点通过singleNodeClusterAppConfig构造独立配置,256 个哈希槽,副本数按集群规模设置(L31-L40);
  • 通过POST /channel创建群empty-history-group(channel_type=2),订阅者empty-reader、empty-sender(L78);
  • 关键设计:每个节点、每轮循环都先调用GetChannelRuntimeMeta并断言meta.ErrNotFound(L83-L84),证明"空读前后消息运行元数据始终不存在"——即这不是一个已初始化过的空日志,而是与全流迁移后完全一致的"无运行态"条件。

4.2 验证的 API 行为

请求期望结果
POST /channel/messagesync(成员、limit=10)HTTP 200,messages: [],more: 0
POST /channel/messagesyncbatchHTTP 200,单条目,messages: []、error为空,位置对齐
POST /conversation/sync(msg_count=10)HTTP 200,对未激活空群返回空列表[]
POST /channel/messagesync(非成员 outsider)HTTP 400(成员隔离保留)
POST /message/send(首次正常发送)ACKmessage_seq = 1

空读完成后再次断言meta.ErrNotFound(L110-L111),直接验证"空读不得创建消息运行元数据"。整轮测试在两轮循环间执行完整集群停止与重启(L113-L116),确认重启后行为可重复。

4.3 首次发送的序号分配

测试最后一阶段通过/message/send发送first-normal-send(payload 为 base64 的hello),断言 ACK 序号为 1,且每个节点入口(每个 app 的 HTTP handler)都能读到同一条消息(message_seq=1、client_msg_no="first-normal-send")。这说明空频道的首次发送正常分配序号 1,发送链路(internal/usecase/message/send.go 的Send→ 频道追加 submitter)不受"频道此前为空"影响。

对比验证:旧迁移分支的相同单节点测试失败记录保留为migration-baseline.log——/channel/messagesync返回 HTTP 400,原因正是路由后的频道不存在错误未做映射。main 对照通过后才将修复同步到迁移候选。

五、实际导入后的 HTTP 验收:TestStreamOnlyMigrationHTTP

纯 v3 对照只能证明"新空频道"行为正确,还不能覆盖"真实 v2 导入产物"场景。第二条验收路径使用原版格式的私有合成副本:将所有普通消息标记为待排除的流消息、CMD 同时排除,走完整迁移流程后再启动真实产品 HTTP 服务进行验证。对应集成测试位于 internal/infra/migrationv2/stream_api_integration_test.go 与testMigratedMembershipHTTP(L45-L251)。

5.1 迁移流程

  1. streamExclusionFixture(t, true)构造源数据(参考 stream_exclusion_test.go 的标记逻辑:通过改写原版索引,把普通消息的流标记列置位 / 为部分消息写入伪造流号);
  2. 迁移策略MessagePolicy{ExcludeCMD: true, ExcludeStreams: true, CompactSequences: true}(L64);
  3. 执行migration.Prepare完整 prepare,断言目标消息数Conversion.Messages == 0(L87-L93);
  4. 执行migrationv3.Install原生安装与migration.VerifyTargets独立 verify;
  5. 用迁移目标节点配置启动真实product.App(HTTP 监听),单节点源分别迁入单节点集群与三节点集群。

5.2 HTTP 层逐项验证

测试通过真实 HTTP client(3 秒超时、1 MiB 响应上限)逐一核对(check函数 L180-L224):

  1. 每个 HTTP 入口(所有节点 url)的单条历史、批量历史、旧会话同步均返回空结果:messages: [](非 nil)、more: 0、无error;
  2. /conversations/clearUnread与/conversations/setUnread(unread=0)成功返回 200;非成员not-a-member的历史查询仍被 400 拒绝;
  3. 通过正常/message/send发送两条消息(normal-after-exclusion-0、normal-after-exclusion-1),ACK 序号依次为 1、2,message_id非零且与历史中任何消息 ID 不同;
  4. 所有入口逐条核对历史中的message_id、message_seq、client_msg_no、payload与more;
  5. 循环三轮,期间执行两次完整集群停止与重启(L225-L250),每次重启后重新start()+check(),确认消息无遗漏、无重复、序号不回退。

这轮验收是"真实安装产物"的 HTTP 验证——报告特别强调:不把直接调用集群读接口的 ChannelNotFound 当作 API 成功。HTTP 层看到的是修复后的空页语义,底层错误被正确吸收,这正是修复的价值所在。

六、证据目录与验收边界

6.1 证据归档

报告对应的私有证据目录为tmp/server-rehearsal-20260908/empty-history-38/,包含:源/工作副本文档摘要、原提交补丁、迁移分支失败基线、main 对照、单元测试、HTTP 集成测试、FLOW 校验及 SHA-256 清单,最终状态以目录内validation.json为准。

本轮验收结论:

  • 消息、会话、集群适配器和 HTTP 适配器相关完整单元测试通过;
  • main 已有新单聊集成测试与新增空群对照在迁移候选通过;
  • 实际全流导入的 HTTP 集成测试通过,耗时39.189 秒;
  • 上游补丁反向校验与flow-doc-contracts通过。

6.2 尚未验收的独立门槛

报告明确划出了未覆盖的边界,这些能力不因本轮空历史修复而改变,也不授权切流:

  • 实际客户端 SDK、本地缓存与旧游标切换仍未验收;
  • 真实源权威、完整 prepare、插件、现场目标运行与100 GiB 性能验收仍各有独立门槛;
  • 插件相关验证按用户要求暂缓。

七、小结:空历史语义是迁移可用性的地基

"全流频道迁移后没有历史"不是异常,而是迁移策略(排除流消息)的预期结果。WuKongIM 的修复让公开 API 对这一预期结果给出正确的空页语义:

  • 空页而不是报错:存储层 NotFound 与路由后频道不存在统一映射为messages: []、more: 0;
  • 空读不产生副作用:不创建消息运行元数据、不创建成员关系;
  • 权限与失败语义不变:非成员、已移除成员、解散频道、路由/存储真实失败仍按原规则拒绝;
  • 迁移产物可验证:通过纯 v3 对照与真实导入 HTTP 双轨集成测试(internal/app/empty_history_integration_test.go 与 internal/infra/migrationv2/stream_api_integration_test.go),从测试构造、断言到重启一致性都留下了可复跑的验收证据。

对正在推进 v2→v3 迁移的团队,这份报告给出的方法论同样适用:先在纯 v3 环境锁定语义基线,再用真实迁移产物走完整 prepare/install/verify 与产品 HTTP 入口验证,最后以重启与逐入口核对收尾——每一层都回答了"空历史场景下 API 究竟应该怎么表现"这个迁移可用性的地基问题。

  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

相关推荐

上一篇:如何快速实现JWT与OAuth2认证:Ky HTTP客户端的完整集成指南
下一篇:5个关键点深度解析rqlite集群快照机制:高可用数据库的终极备份方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询