- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
本文基于 WuKongIM 仓库中的真实 E2E 测试报告 2026-09-24-send-ban-delivery.md 展开,结合 send_ban_test.go 的源码实现,完整解读"被拒绝的发送请求绝不能产生任何投递"这一核心承诺:从用户禁发 / 频道禁发、到 Slot 失去 quorum 时的失败关闭,以及解禁 / 恢复后的精确历史对照。读完本文,你将掌握该 E2E 用例的拓扑构建、断言矩阵、运行命令与底层权限检查实现,可直接在自己的 WuKongIM 三节点集群上复现同样的验证。
一、为什么需要"无投递验证"
消息系统的权限拒绝通常只验证"请求被拒绝"这一面(返回错误码),但更隐蔽的风险在于:被拒绝的请求是否仍然泄漏到了接收端或历史存储。一旦出现"被禁发的消息仍然被对方收到"或"被 503 拒绝的消息仍然写入历史"的情况,就构成数据完整性与合规性的严重缺陷。
本报告对应的测试 TestRejectedSendHasNoDelivery 正是针对这一风险设计的黑盒 E2E 证明:它要求所有被禁发 / 被 quorum 拒绝的请求必须同时满足——
- 响应中不带任何消息 ID 与消息序号(
MessageID == 0 && MessageSeq == 0); - 接收端在有界空读窗口内收不到任何 RECV 包;
- 解禁后旧拒绝消息不得排在成功消息前面;
- 完整历史中只包含成功的请求身份,一条被拒请求都不许出现。
该测试的状态为:真实三节点黑盒 E2E 通过,耗时 44.86 秒,且没有修改任何生产代码。这意味着它运行在未打补丁的生产二进制之上,证据只来自公开 API、公开指标与真实进程行为。
二、测试环境与拓扑:公开 Placement 定位 Slot Owner
测试不是随机挑选节点,而是先通过公开拓扑信息确定发送者 UID 所属 Hash Slot 的真实 Owner,再把发送、接收连接都建立在 Owner 上。关键环境参数如下(见 send_ban_test.go):
| 参数 | 值 | 说明 |
|---|---|---|
WK_CLUSTER_HASH_SLOT_COUNT | 256 | 逻辑 Hash Slot 总数 |
WK_CLUSTER_INITIAL_SLOT_COUNT | 12 | 初始物理 Slot 数 |
| 副本数 | 3 | 消息数据三副本 |
WK_MESSAGE_PERMISSION_CACHE_TTL | 1h | 辅助权限缓存 TTL |
WK_GATEWAY_TOKEN_AUTH_ON | false | 测试期间关闭 token 认证 |
| 拓扑确认 | Manager HTTPMustSlots | 通过公开 placement 定位 UID Slot Leader |
源码中通过crc32.ChecksumIEEE([]byte(sender)) % 256计算发送者 UID 的 Hash Slot,再遍历公开 Slot 列表找到该 Hash Slot 的Runtime.LeaderID,从而确定 Owner 节点:
hashSlot := uint16(crc32.ChecksumIEEE([]byte(sender)) % 256) var ownerID uint64 for _, slot := range cluster.ManagerClient(t, 1).MustSlots(t) { if slot.HashSlots != nil { for _, h := range slot.HashSlots.Items { if h == hashSlot { ownerID = slot.Runtime.LeaderID } } } }发送者与接收者的 WKProto 连接都在禁发建立之前完成,并全程保持在 Owner 节点上——这排除了"连接迁移导致的偶发缺投"这一干扰变量,让后续的禁发拒绝与 quorum 失败都是纯粹的权限 / 一致性行为。
三、禁发场景验证矩阵:48 次明确拒绝
测试将"用户禁发"与"频道禁发"两种作用域分别展开,形成一个完整的矩阵(源码见 send_ban_test.go):
- 两种作用域:用户禁发(
SetUserSendBan)、频道禁发(SetChannelSendBan); - 两种频道类型:私聊(
channel_type=1,person)、群聊(channel_type=2,group); - 两种协议入口:HTTP 三个接入节点 + 既有 WKProto 连接;
- 三种消息形态:普通消息、
NoPersist(不持久化)、SyncOnce(只同步 / 消费一次)。
组合起来即2 作用域 × 2 类型 × 3 形态 × 4 入口 = 48 次明确禁发拒绝。每一次拒绝断言都必须是:
require.Equal(t, want, ack.ReasonCode, label) if want != frame.ReasonSuccess { require.Zero(t, ack.MessageID) require.Zero(t, ack.MessageSeq) }即拒绝码为frame.ReasonSendBan(定义见 common.go:ReasonSendBan // 用户或目标频道禁止发送),且响应中消息 ID 与序号必须为零。
禁发期间的"有界空读窗口"
每个作用域禁发执行完毕后,测试在接收端执行一次有界空读:ReadRecv等待 2 秒,必须返回context.DeadlineExceeded且没有收到任何包:
quiet := func(label string) { packet, err := recv.ReadRecv() require.ErrorIs(t, err, context.DeadlineExceeded, label) require.Nil(t, packet) }四个空读窗口分别覆盖:用户禁发后(user-no-delivery)、频道禁发后(channel-no-delivery)、quorum 丢失后(quorum-no-delivery)、最终收尾(final-no-delivery)。这正是"无投递"的直接黑盒证据:不只是响应里没有 ID/序号,接收端的真实连接上也确实收不到任何 RECV 包。
解禁后的精确对照
每次解禁后,测试通过两种协议再次发送成功对照,并精确断言接收端收到的正是当前这次成功请求,禁止旧拒绝消息排在前面:
packet, err := recv.ReadRecv() require.NoError(t, err, label) require.Equal(t, label, string(packet.Payload), "a rejected packet must not leak ahead of the positive control") require.Equal(t, sender, packet.FromUID)这一断言非常关键:它证明被拒绝的消息既没有"插队"出现在成功消息之前,也没有以任何形式残留在接收队列里。
四、Quorum 拒绝验证:失败关闭与恢复
禁发之外,测试还覆盖了更严苛的一致性场景——UID Slot 失去 quorum 时的失败关闭:
- 保留接收连接所在进程存活,停止另两个节点进程,让 UID Slot 失去 quorum(源码见 send_ban_test.go);
- 通过 Owner 节点发起一条 HTTP SEND,必须返回HTTP 503,且响应中无消息 ID / 序号;
- 接收端再次执行空读,确认仍为空;
- 重启节点,等待公开 HTTP readiness(
WaitHTTPReady)与实际 Slot Leader 稳定(WaitSlotLeadersStable)之后,原接收连接收到成功对照; - 最后再执行一次空读收尾。
quorum 拒绝的直接断言如下:
require.ErrorAs(t, err, &statusErr) require.Equal(t, http.StatusServiceUnavailable, statusErr.StatusCode) require.Zero(t, got.MessageID) require.Zero(t, got.MessageSeq)这条 quorum SEND 的 503 / 零 ID / 序号由用例直接断言,而随后的空读与恢复后的精确历史又进一步排除了它的任何副作用——即使它"看似失败",也必须确凿地没有任何投递与持久化痕迹。
五、产物统计:70 条观察的完整证据链
整个测试共产生70 条观察记录,全部写入WK_E2E_SEND_BAN_REPORT指定的 JSON 产物(本用例写为<路径>.delivery.json,见 send_ban_test.go):
| 观察类别 | 数量 | 说明 |
|---|---|---|
| 明确禁发拒绝 | 48 | 上述 2×2×3×4 矩阵,均零 ID/序号 |
| 已发送且已接收的成功对照 | 16 | warm / 用户解禁 / 频道解禁 / quorum 恢复各 4 条 |
| 有界空读窗口 | 4 | 四个 2 秒无 RECV 窗口 |
| 完整历史 | 2 | 私聊与群历史各严格包含 8 条成功请求 |
其中"完整历史各 8 条成功请求"的由来:4 次controls(warm、user-recovered、channel-recovered、quorum-recovered)每次对每个 kind 产生 HTTP + WKProto 两条成功,因此 kind=1(私聊)与 kind=2(群)各有 8 条。测试通过messagesync拉取完整历史并断言ElementsMatch,只允许这 8 个成功身份出现。
测试报告同时明确了证据边界:该测试只对观测窗口负责,不声称"有限等待能证明任意未来时刻永不产生异常投递"——这是对黑盒测试能力边界的诚实声明,也是后续任意时间点回归验证的前提。
六、运行方式与复现命令
测试属于 e2e build tag,需要真实进程。完整命令如下(与报告一致):
WK_E2E_SEND_BAN_REPORT=/tmp/send-ban-delivery.json GOWORK=off go test -tags=e2e ./test/e2e/message/send_ban -run '^TestRejectedSendHasNoDelivery$' -count=1 -timeout=3m -p=1 -v关键参数说明:
WK_E2E_SEND_BAN_REPORT:JSON 产物输出路径,不设置时默认写入系统临时目录下的wukongim-send-ban-report.json;GOWORK=off:关闭 Go workspace 模式,保证只编译仓库自身模块;-tags=e2e:启用 e2e 构建标签(源码文件头部即//go:build e2e);-run '^TestRejectedSendHasNoDelivery$':精确选中无投递验证用例;-count=1:禁止测试缓存;-p=1:串行执行,避免多进程干扰。
报告记录的运行环境为 Go 1.25.11 / Darwin arm64;测试源码基于提交2f49a2869加未提交测试,而生产二进制输入仍对应1ba4a6352——即被测的是仓库既有生产代码。前一持续压力运行已在编译执行前终止,确保观测窗口干净。更完整的包级说明见 send_ban 目录 AGENTS.md,其中要求:保持黑盒断言、覆盖缓存准入、跨接入点变更、私聊双向、群隔离、CAS 与拒绝消息缺失,且"被禁发的拒绝绝不能重试直到成功"。
相关配置项取值含义
上述环境变量即 WuKongIM 的运行时配置项,在 suite 中通过WithNodeConfigOverrides注入:
WK_CLUSTER_HASH_SLOT_COUNT=256:Hash Slot 总数,决定 UID / 频道在集群中的分布粒度;WK_CLUSTER_INITIAL_SLOT_COUNT=12:初始物理 Slot 数,每个物理 Slot 承载一组 Hash Slot;WK_MESSAGE_PERMISSION_CACHE_TTL=1h:辅助权限缓存 TTL,测试中固定为 1 小时,避免缓存过期引入的时序抖动;WK_GATEWAY_TOKEN_AUTH_ON=false:关闭 token 认证,把验证焦点完全放在权限禁发本身。
七、源码级原理:权限检查链路与事实计划
"无投递"之所以成立,底层依赖发送路径上严格的权限前置检查。核心实现在 internal/usecase/message/permission.go:
- 入口:
checkSendPermission对所有 SEND 请求先执行用户级检查checkSenderSendPermission——通过GetUserSendPolicy读取 UID 的禁发策略,非零即observeSendBan("user", 1)并返回ReasonSendBan(见 permission.go); - 频道级检查:
checkTerminalChannelPermission/checkGroupSendPermission读取源频道的元数据,SendBan != 0时返回ReasonSendBan(见 permission.go); - 拒绝即终止:只要返回
ReasonSendBan,发送流程在进入消息 ID 分配、序号生成、持久化与 fanout 之前就已终止,因此拒绝响应天然是零 ID / 零序号,接收端也绝不会得到投递包。
在批量发送路径上,权限读取被组织为固定的事实计划(fact plan):checkSendPermissionsBatch把用户策略、源频道与辅助集合事实收集进一个 plan 中统一读取(见 permission_batch.go),并公开wukongim_message_permission_counts_total系列指标(facts_before/facts/messages/users/channels/node_envelopes/slot_groups)。这正是配套测试(如 TestSendBanGatewayDistributions)用来证明"拒绝请求不会走接收者遍历"的公开证据:10 万人大群的拒绝场景中,facts增量固定、fanout 增量为零。
管理面写入接口同样公开可查:用户禁发走POST /user/send_ban,频道禁发走POST /channel/send_ban,请求体为{"uid": ..., "send_ban": value}与{"channel_id": ..., "channel_type": ..., "send_ban": value},响应带版本号send_ban_version(封装见 test/e2e/suite/send_ban.go)。send_ban取值为1(禁发)与0(解禁),管理用例还覆盖了 CAS 冲突(带错误expected_version返回 HTTP 409)与非法参数(HTTP 400)等边界。
八、结论与边界声明
WuKongIM 的TestRejectedSendHasNoDelivery用一次真实三节点黑盒 E2E(44.86 秒、零生产代码改动)完成了对"禁发与 quorum 拒绝无投递"的完整证明:48 次禁发拒绝全部零 ID/序号、四个 2 秒空读窗口无任何 RECV、解禁后成功消息精确排位、quorum 丢失时 HTTP 503 失败关闭、恢复后私聊与群历史各严格等于 8 条成功请求。
需要强调的是,该测试的证据边界同样明确:有限等待窗口的观察只证明观测期间无异常投递,并不声称任意未来时刻永不产生异常投递。如需在任何新版本、新拓扑或新配置上维持这一承诺,应原样重跑该用例——命令与断言矩阵已全部沉淀在仓库中,可随时复现。
- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
相关推荐
WuKongIM 非副本入口的禁令缓存转换验证:三节点 E2E 如何证明禁发/解禁不受辅助缓存 TTL 影响
WuKongIM 非副本入口的禁令缓存转换验证:三节点 E2E 如何证明禁发/解禁不受辅助缓存 TTL 影响 导读 在 WuKongIM 的哈希槽(Hash S
即时通讯后端DLSS Swapper 完整指南:3 步给老游戏换上新版 DLSS/FSR/XeSS DLL,可一键回滚
DLSS Swapper 完整指南:3 步给老游戏换上新版 DLSS/FSR/XeSS DLL,可一键回滚 DLSS Swapper 是一款免费开源的 Wind
桌面应用Orleans 流投递语义详解:投递保证、顺序、重放与恢复
Orleans 流投递语义详解:投递保证、顺序、重放与恢复 本文是 Orleans 流(Streams)编程模型的实战指南,聚焦"投递语义"这一核心主题:生产者
后端微服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考