- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
本指南以仓库内 docs/superpowers/plans/2026-07-30-channel-member-management.md 为骨架,结合当前仓库中
internal/usecase/management、internal/usecase/channel、pkg/slot/proxy、pkg/db/meta、internal/access/manager与web/src/pages/channels-biz的落地实现,系统讲解 WuKongIM 如何让 Manager 用户从业务频道清单直接查看并管理订阅者(subscribers)、拉黑名单(denylist)与白名单(allowlist)成员,同时保持频道创建/编辑行为不变。读者将掌握:基于 Slot 多 Raft 权威元数据路径的精确变更计数、管理用例端口与 DTO 设计、Manager HTTP 路由与隐私受限审计日志、以及 React 侧滑面板双模式交互的完整技术方案。
一、目标与总体架构:从“频道清单”到“成员管理”
该计划的总体目标是:让 Manager(管理后台)用户在业务频道列表上即可查看并管理订阅者、黑名单与白名单成员,同时保持频道创建/编辑行为完全不变。架构上它不是简单堆叠 CRUD,而是围绕 WuKongIM 的集群元数据语义做了三层设计:
- 扩展
internal/usecase/management频道清单模块,为它注入窄化的 channel/member 端口(port),对外只暴露“条目无关”的 DTO,避免依赖 HTTP 或具体基础设施。 - 用 channel use case 做编排:真正的元数据读写走Slot-proxy 支撑的权威(authoritative)元数据路径,保证精确读取与可复现的变更结果——所有读写都发生在普通集群语义之上(包括单节点集群)。
- 恢复 Manager HTTP 路由,显式区分 create 与 patch 语义,校验全部边界输入,输出隐私受限(privacy-bounded)的结构化审计日志;前端把既有 React 侧滑面板重构为Detail / Member 双模式,支持 URL 持久化、精确搜索、有界游标分页、权限感知变更与写后权威刷新。
技术栈为:Go、Gin、Slot Multi-Raft 元数据 FSM、React + TypeScript、Vite/Vitest、Testing Library(前端实际页面在 page.tsx 中基于react-router-dom的useSearchParams实现 URL 状态)。
计划被拆分为 7 个任务,自底向上逐层落地:写入层计数 → 集群基础设施 → 管理用例 → HTTP 路由 → 前端客户端契约 → 前端交互 → 文档与回归验证。下文逐一展开,并结合当前仓库源码验证每一层的真实实现。
二、任务 1:让订阅者变更返回“精确持久化变更数”
2.1 要解决的问题
传统的 set 变更(add/remove)接口往往只返回“成功”,无法让管理端判断“这次到底新增/删除了几行”。该任务要求:
- 添加已存在的 UID 或移除不存在的 UID,返回0 变更;
- 真实插入/删除,返回精确的去重后行数。
这样前端才能向操作者展示“请求了 N 个、实际变更了 M 个”(processed-vs-changed),而不是模棱两可的成败。
2.2 变更结果类型与计数写入路径
变更结果类型定义在 table_subscriber.go:
// SubscriberMutationResult reports the distinct requested UIDs and rows that // changed in one committed subscriber-set mutation. type SubscriberMutationResult struct { RequestedCount int ChangedCount int }RequestedCount是规范化去重后的请求 UID 数;ChangedCount是提交后真实变化的行数。计数发生在元数据写批次内部,且不破坏既有兼容方法——compat.go 在保持原有AddSubscribers/RemoveSubscribers兼容签名不变的前提下,新增:
func (b *WriteBatch) AddSubscribersCounted(hashSlot uint16, channelID string, channelType int64, uids []string, mutationVersion ...uint64) (*SubscriberMutationResult, error) func (b *WriteBatch) RemoveSubscribersCounted(hashSlot uint16, channelID string, channelType int64, uids []string, mutationVersion ...uint64) (*SubscriberMutationResult, error)二者的核心实现是同一个stageSubscribers(compat.go#L1244),其计数逻辑要点:
- 先用
normalizeSubscriberUIDs去重、校验并得到RequestedCount; - 提交闭包内
ChangedCount从 0 开始,基于当前持久化状态重新判断每个 UID 是否存在; - add 时仅当
!exists才写入行、channel.SubscriberCount++、ChangedCount++; - remove 时仅当
exists才删除行、递减计数、ChangedCount++; mutationVersion作为“持久化变更栅栏(fence)”:若已存在行的SubscriberMutationVersion大于传入版本则返回冲突,避免旧版本覆盖新状态;- add 有真实变更时调用
flushSubscriberSequence推进订阅者序号。
2.3 FSM 命令的计数结果编解码
复制到 Slot 多 Raft FSM 的订阅者命令在 command.go 中提供:
func EncodeAddSubscribersCommandChecked(channelID string, channelType int64, uids []string, subscriberMutationVersion ...uint64) ([]byte, error) func EncodeRemoveSubscribersCommandChecked(channelID string, channelType int64, uids []string, subscriberMutationVersion ...uint64) ([]byte, error)两个方法都会先执行ValidateSubscriberCommandLimits(uids)做有界校验。Apply 之后,结果通过DecodeSubscriberMutationResult(带subscriberMutationResultMagic魔数前缀防错)从复制结果字节中解码出RequestedCount与ChangedCount。
FSM 层的测试(state_machine_test.go)精确验证了计数语义:add 3 个 UID 后结果为requested=3 changed=3;随后 remove 其中 2 个(1 个不存在)结果为requested=2 changed=1。
2.4 Proxy Store 暴露计数方法
store.go 是业务层对外的存储门面,新增:
func (s *Store) AddChannelSubscribersCounted(ctx context.Context, channelID string, channelType int64, uids []string, subscriberMutationVersion ...uint64) (metadb.SubscriberMutationResult, error) func (s *Store) RemoveChannelSubscribersCounted(ctx context.Context, channelID string, channelType int64, uids []string, subscriberMutationVersion ...uint64) (metadb.SubscriberMutationResult, error)二者统一走mutateChannelSubscribersCounted:先用s.cluster.SlotForKey(channelID)定位物理 Slot、hashSlotForKey计算哈希槽,再proposeWithHashSlotResult提交命令并等待 FSM apply 结果,最后DecodeSubscriberMutationResult解码。这样“计数”本身也是复制提交后的持久化事实,而不是调用方本地猜测。
三、任务 2:通过集群基础设施暴露权威通道/成员操作
3.1 打破 proxy 与 cluster 的注册依赖环
计划要求“用函数处理器注册端口打破当前 proxy→cluster 注册依赖,让默认集群节点可以拥有一个 proxy store 而不产生 import 环”。这一步保证 Slot 元数据 DB 创建时能初始化 Slot proxy,并在 shutdown/restore teardown 时清理——权威读写路径与恢复路径互不污染。
3.2 ChannelMetadataStore:优先权威读、保留恢复读
internal/infra/cluster/channel_metadata.go中的ChannelMetadataStore是 usecase 与集群 Slot 元数据之间的适配器,它通过类型断言挑选能力:
GetChannel走authoritativeChannelMetadataNode.GetChannelMetadataAuthoritative(权威 Slot 领导者读取),不再读本地陈旧副本;ListChannelSubscribers/ContainsChannelSubscriber/HasChannelSubscribers分别走ListChannelSubscribersAuthoritative/ContainsChannelSubscriberAuthoritative/HasChannelSubscribersAuthoritative;- 计数变更通过
countedChannelSubscriberMutationNode的AddChannelSubscribersCounted/RemoveChannelSubscribersCounted(channel_metadata.go#L205-L226); - 恢复专用读取
ListChannelSubscribersForRestore在 Controller 维护期仍可读本地 Slot 元数据,与普通业务读路径分离(channel_metadata.go#L242-L255)。
3.3 错误映射与背压语义
权威路径不可用时(无领导者、路由未就绪、传输失败、权限门繁忙等),mapChannelPermissionReadError(channel_metadata.go#L600)把slotproxy.ErrPermissionBusy归类为“路由不可用 + 背压”,把ErrNoLeader/ErrReadStaleRoute/ErrRouteNotReady/传输类错误归类为“路由不可用”。管理端据此返回 503 而非错误数据。
3.4 反向成员投影只针对普通订阅者
计划明确:“仅对普通订阅者保留 UID 反向成员投影行为”。ChannelMetadataStore的UpsertChannelMemberships/TombstoneChannelMemberships(channel_metadata.go#L387-L400)只处理普通订阅者;allow/deny 列表走内部派生频道,不触碰该投影索引(见任务 3 的派生频道设计)。
四、任务 3:管理用例层——详情、创建/编辑、列表、精确搜索与变更
4.1 端口设计:窄化、条目无关
internal/usecase/management/channels_biz.go定义了两个关键端口:
ChannelBusinessReader:按物理 Slot 扫描频道元数据页(ScanChannelsSlotPage),用于清单列表;ChannelBusinessOperator:权威详情、成员页、精确成员判定、非空判定与三类计数变更(GetMetadata/CreateMetadata/PatchMetadataFlags/HasSubscribers/HasAllowlist/HasDenylist/Contains*/List*Page/Mutate*Counted)。
配套的类型全部是管理域自有的条目无关 DTO:BusinessChannelKey、BusinessChannelInfo、BusinessChannelFlags、BusinessChannelMemberPageRequest/Result、ChannelListCursor、ChannelMemberCursor等,不依赖 access 层 DTO(这也是计划 Task 3 第 4 步的明确要求)。
4.2 清单列表:Slot 扫描 + 快照排序 + 游标绑定
ListBusinessChannels(channels_biz.go#L322)的执行链路:
- 先判本地/远端(
businessChannelRequestTargetsLocal),远端走RemoteBusinessChannelReader; - 从
cluster.LocalControlSnapshot拿到控制器快照,得到排序后的 Slot ID 列表; - 逐 Slot 调用
ScanChannelsSlotPage扫描,按businessChannelMatches过滤(剔除内部频道、可选type过滤、可选keyword子串匹配); - 凑满
Limit(默认 50、上限 200)即返回HasMore与NextCursor。
游标ChannelListCursor是不透明的:包含SlotID、最后一条ChannelID/ChannelType,并把TypeFilter与KeywordHash(crc32哈希)绑定进去——用错误的关键字/类型复用旧游标会被validateChannelListCursor拒绝,防止跨条件翻页串数据。
4.3 详情:has_* 三标志
GetBusinessChannel(channels_biz.go#L391)在权威GetMetadata之外,还会查询HasSubscribers、HasAllowlist、HasDenylist三个非空标志,返回BusinessChannelDetail。HTTP 层对应响应字段has_subscribers/has_allowlist/has_denylist(channels_biz.go DTO),前端据此展示成员区入口。
4.4 创建与编辑的显式语义
- 创建(create-only):
CreateBusinessChannel先做“新 ID 校验”(validateNewBusinessChannelKey:非空、UTF-8 合法、非内部频道前缀、ID 字节 ≤ 256、禁止#@字符),再调用CreateMetadata——底层是CreateChannelMetadataStrict(Slot 领导者的create-only 条件写入,已存在则返回ErrAlreadyExists,HTTP 映射 409)。 - 编辑(edit-only patch):
UpdateBusinessChannel只 patchBan/Disband/SendBan三个 Manager 可编辑标志;SendBanSet独立携带“是否显式设置”,从而保留 patch 中未提及的其余元数据字段。底层PatchChannelBusinessFlags在权威路径上原子地只改这三个标志,并对不存在的频道返回ErrNotFound(HTTP 404)。
4.5 成员页、精确搜索与游标互斥
ListBusinessChannelMembers(channels_biz.go#L468):
uid与cursor互斥(HTTP 层与用例层双重校验);- 精确模式:规范化单个 UID 后做权威
Contains*点查,命中返回一行、未命中返回空数组; - 分页模式:
AfterUID作为排他游标,返回HasMore与NextCursor; - 成员游标
ChannelMemberCursor绑定ChannelIDHash(crc32)、ChannelType、ListKind,防止跨频道/跨列表复用游标; - 每个成员列表默认页大小 100、最大 500(见任务 4 的参数解析)。
4.6 计数变更:派生频道与 500 去重上限
MutateBusinessChannelMembers(channels_biz.go#L534):
normalizeBusinessMemberUIDs对 UID 做 trim、UTF-8 校验、单条 ≤ 256 字节、拒绝空白与控制字符、去重,且去重后仍不得超过maxBusinessChannelMutationUIDs = 500;- 个人频道(
ChannelTypePerson)的 subscribers 列表只读:直接返回ErrInvalidArgument(HTTP 400); - allow/deny 列表的“首次添加”只创建内部派生频道,且必须先通过权威父频道校验。派生频道 ID 由
internal/contracts/channelmembers/channelmembers.go生成:
func namespacedListChannelID(kind string, key ChannelKey) string { encoded := base64.RawURLEncoding.EncodeToString([]byte(key.ChannelID)) return fmt.Sprintf("__wk_internal_memberlist__/%s/%d/%s", kind, key.ChannelType, encoded) }即形如__wk_internal_memberlist__/allow/2/<base64>的稳定、兼容遗留命名空间的频道 ID;管理用例层在isInternalBusinessChannelID中将其从普通清单中隐藏(channels_biz.go#L694-L696)。channel usecase 的mutateMemberListCounted在 add 时用ensureChannelExistsStrict为派生频道做 create-only 建立,remove 时要求派生频道已存在(app.go)。
- 响应统一返回
RequestedCount(规范化去重后的请求数)与ChangedCount(精确持久化变更数),这正是任务 1 计数能力在上层消费的地方; - 普通订阅者变更维持既有“反向索引投影契约”,allow/deny 变更不触碰该投影(见 internal/usecase/channel/FLOW.md)。
五、任务 4:Manager HTTP 路由恢复与审计日志
5.1 路由清单与权限分组
internal/access/manager/server.go的registerRoutes中,读路由挂cluster.channel:r权限组,写路由挂cluster.channel:w权限组(server.go#L556-L567、server.go#L600-L615):
| 方法 | 路径 | 语义 |
|---|---|---|
| GET | /manager/channels/:channel_type/:channel_id | 权威频道详情(含 has_* 标志) |
| POST | /manager/channels | create-only 创建 |
| PATCH | /manager/channels/:channel_type/:channel_id | edit-only 补丁 |
| GET | /manager/channels/:channel_type/:channel_id/{subscribers\|allowlist\|denylist} | 成员分页或精确 uid 查询 |
| POST | /manager/channels/:channel_type/:channel_id/{list}/add | 成员批量添加 |
| POST | /manager/channels/:channel_type/:channel_id/{list}/remove | 成员批量移除 |
同一文件中的其他频道相关读路由(/manager/channels清单、/manager/channel-runtime-meta、/manager/messages、/manager/conversations)也都挂在cluster.channel:r下。
5.2 参数校验与状态映射
channels_biz.go(access 层)定义了可复现的边界常量与校验函数:
const ( defaultBusinessChannelsLimit = 50 maxBusinessChannelsLimit = 200 defaultBusinessChannelMembersLimit = 100 maxBusinessChannelMembersLimit = 500 maxBusinessChannelJSONBodyBytes = 1 << 20 )parseBusinessChannelMembersLimit:默认 100、范围 1~500;parseBusinessChannelTypeParamForRequest:channel_type必须为 1~255 的整数;parseBusinessChannelIDParamForRequest:channel_id必须经过 URL 反转义、非空且 UTF-8 合法;bindStrictUTF8JSON:请求体上限 1 MiB,且整体必须是合法 UTF-8,否则按 400 拒绝;uid与cursor同时出现即 400。
状态映射统一收敛在writeBusinessChannelError/writeBusinessChannelListError(channels_biz.go#L418-L448):
| 错误 | HTTP 状态 |
|---|---|
ErrInvalidArgument | 400 bad_request |
ErrNotFound | 404 not_found |
ErrAlreadyExists | 409 conflict |
ErrBusinessChannel*Unavailable(reader/operator/authority/control) | 503 service_unavailable |
| 其他 | 500 internal_error |
5.3 隐私受限的结构化审计日志
auditBusinessChannelMemberMutation(channels_biz.go#L488-L536)为每次成员变更输出一条结构化日志,包含:事件名internal.access.manager.channel_member_mutation、operator(登录用户名)、channel_id、channel_type、list_kind、operation(add/remove)、requested_count、changed_count、result(ok/error)、time(UTC RFC3339Nano)。
隐私约束:绝不记录完整 UID 集合,最多只带1 个脱敏样本:
func redactBusinessChannelUIDSample(uid string) string { runes := []rune(uid) switch len(runes) { case 0: return "" case 1: return "*" case 2: return "**" default: return string(runes[0]) + "***" + string(runes[len(runes)-1]) } }5.4 装配:把 channel usecase 与权威 store 接进管理端
internal/app/wiring.go的newManagerManagement把newManagerChannelBusinessOperator(a.channels)作为ChannelBusinessOperator、NewChannelBusinessReader(channelNode)作为读端口注入(wiring.go#L1141-L1189)。适配器本身在 manager_channel_business.go 中把 channel usecase 实现翻译成管理端口;契约测试 manager_channel_business_contract_test.go 验证了元数据与成员契约的保持,以及每种操作形态下权威失败(ErrRouteNotReady)到ErrBusinessChannelAuthorityUnavailable的映射。
六、任务 5:Manager API 客户端契约
前端契约在web/src/lib/manager-api.types.ts与web/src/lib/manager-api.ts:
export type ManagerBusinessChannelDetailResponse = ManagerBusinessChannelListItem & { has_subscribers: boolean has_allowlist: boolean has_denylist: boolean } export type BusinessChannelMemberListKind = "subscribers" | "allowlist" | "denylist" export type MutateBusinessChannelMembersResponse = { channel_id: string channel_type: number list: BusinessChannelMemberListKind requested_count: number changed_count: number }客户端函数(manager-api.ts)包括:createBusinessChannel(POST /manager/channels)、getBusinessChannelMembers(携带limit/uid/cursor查询参数)、addBusinessChannelMembers与removeBusinessChannelMembers。它们与后端路由、参数互斥规则、requested_count/changed_count响应字段一一对应;配套的manager-api.test.ts覆盖 create/patch 分离、uid 查询、不透明游标与计数断言。
七、任务 6:频道列表成员数据体验(前端)
7.1 Detail / Member 双模式与 URL 持久化
page.tsx 用一个侧滑面板(DetailSheet)承载两种模式,由 URL 查询参数驱动:
channel_id、channel_type:选中频道(支持深链接);member_list:取值subscribers/denylist/allowlist,存在即进入成员模式,否则进入详情模式(page.tsx#L244-L245)。
setSearchParams负责写入/清除这些参数,因此浏览器前进/后退即可关闭或切换侧滑面板,深链接可直接定位到某个频道的某个成员列表。
7.2 标签顺序、搜索重置与分页游标栈
const memberKinds: BusinessChannelMemberListKind[] = ["subscribers", "denylist", "allowlist"]标签顺序固定为订阅者 → 黑名单 → 白名单;切换标签时useEffect会清空精确搜索框与当前分页状态,重新加载第一页。
分页采用单页 100 行 + 客户端游标栈:
type MemberState = { items: ... hasMore: boolean nextCursor?: string currentCursor?: string previousCursors: Array<string | undefined> ... }memberPageLimit = 100;- 翻页时把当前游标压入
previousCursors,支持Previous/Next; - 下一页失败时保留当前页(
preserve模式),首页失败则整页错误态; - 精确搜索命中/未命中由
loadMemberExact实现,清空搜索回到第一页(clearMemberSearch)。
7.3 权限感知
hasManagerPermission(permissions, "cluster.channel", "w")决定创建/编辑/添加/移除按钮的可见性;cluster.channel:r可读。个人频道(channel_type=1)的订阅者列表只读:
const isSubscriberMutationBlocked = selectedChannel?.channelType === 1 && activeMemberKind === "subscribers"7.4 变更交互与反馈
- 严格粘贴校验:
parseUIDs按,/;/换行拆分,逐条校验 UTF-8 字节 ≤ 256、无空白/控制字符、去重,且总数 ≤ 500,错误时列出前 5 个非法值; - 添加操作可从精确未命中场景发起;移除可从精确命中场景发起;单条/批量移除均需确认对话框(
ConfirmDialog); - 成功后展示
requested_countvschanged_count的processed-vs-changed反馈(MemberNotice); - 失败提示“结果不确定 + 手动刷新”(
uncertainFailure文案),因为权威写可能在超时后已提交; - 添加成功后回到第一页;移除导致当前页为空时自动回退到上一页游标(
refreshMembersAfterMutation); - 精确搜索模式下,变更后保持精确查询状态(
loadMemberExact(..., preserve=true))。
7.5 明确的功能排除
计划与实现均明确不提供:总数(totals)、跨页选择、自动轮询、导出、清空列表操作——保持交互窄而可靠。配套 UI 测试位于 page.test.tsx,覆盖双模式、URL 深链、标签切换、精确命中/未命中、权限与变更反馈等场景。
八、任务 7:文档、回归验证与提交
计划要求同步更新internal/access/manager/FLOW.md、internal/usecase/management/FLOW.md、internal/usecase/channel/FLOW.md、internal/infra/cluster/FLOW.md(必要时含pkg/cluster/FLOW.md、pkg/db/meta/FLOW.md),覆盖:恢复后的路由、权威读取、内部派生列表创建、计数集合变更与反向索引投影。
回归验证包括:
gofmt与前端格式化/lint;- 每个改动包的聚焦 Go 测试;
- 全量 Go 测试:
GOWORK=off go test ./cmd/... ./internal/... ./pkg/... ./scripts/... ./docker/... -count=1; - 前端测试、typecheck 与生产构建;
- diff 检查(密钥、无关编辑、生成文件、API 术语、已确认的 UX 排除项);
- 基于分支基线的
code-review工作流,修复后再做一次全新验证; - 最终在
codex/channel-member-management分支上创建一个意图明确的提交。
九、从计划到实现的落地对照与阅读索引
当前仓库中该计划的核心落地已经就位,可按下表对照阅读源码:
| 计划任务 | 关键落地文件 |
|---|---|
| 任务 1:精确计数 | table_subscriber.go、compat.go、command.go、store.go、state_machine_test.go |
| 任务 2:权威基础设施 | channel_metadata.go、node.go、node_slot_proxy_port.go |
| 任务 3:管理用例 | channels_biz.go、app.go、types.go、channelmembers.go |
| 任务 4:HTTP 路由与审计 | server.go、channels_biz.go、wiring.go、manager_channel_business.go |
| 任务 5:客户端契约 | manager-api.types.ts、manager-api.ts |
| 任务 6:前端体验 | page.tsx、page.test.tsx |
| 任务 7:文档 | internal/usecase/management/FLOW.md、internal/usecase/channel/FLOW.md |
关键设计要点回顾
- “权威优先、恢复兜底”:业务读写一律走 Slot 领导者权威路径,恢复专用读取单独保留,绝不让陈旧本地数据混入管理端判断。
- 计数是复制提交后的事实:
ChangedCount来自 FSM apply 结果解码,而非调用方估算,天然具备分布式一致性。 - 窄端口 + 条目无关 DTO:管理用例不依赖 HTTP 与 access DTO,任何上层(HTTP、RPC、将来的其他入口)都可复用。
- 隐私内置:审计日志最多一个脱敏 UID 样本,全量 UID 永不落日志。
- 前端状态即 URL:深链接、浏览器后退、标签切换与精确搜索全部由
channel_id/channel_type/member_list三个查询参数驱动,交互状态可分享、可复现。 - 边界全部显式:100 行默认页、500 去重上限、256 字节 UID、uid/cursor 互斥、游标条件绑定(crc32 哈希)、400/404/409/503/500 状态映射——每一处都有源码与测试背书。
- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
相关推荐
WuKongIM 管理端频道业务(Manager Channels Business):从插槽元数据扫描到 `/channels-biz` 页面的全链路实现指南
WuKongIM 管理端频道业务(Manager Channels Business):从插槽元数据扫描到 /channels biz 页面的全链路实现指南 导
即时通讯后端WuKongIM Manager 频道运行时元数据详情接口:从 TDD 计划到 slot 权威读实现解析
WuKongIM Manager 频道运行时元数据详情接口:从 TDD 计划到 slot 权威读实现解析 导读 :本文基于 WuKongIM 仓库中的实现计划文
即时通讯后端WuKongIM MQTT 权威 Slot 访问:分布式元数据路由、读屏障与 CAS 写入契约解析
WuKongIM MQTT 权威 Slot 访问:分布式元数据路由、读屏障与 CAS 写入契约解析 本指南以 WuKongIM 仓库中的 MQTT author
即时通讯后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考