# Auth 认证方案 ## 架构总览 ``` ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ appks │ │ imks │ │ (浏览器/ │ │ (core) │ │ (IM服务) │ │ APP) │ │ │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ 1. POST /api/v1/auth/login │ │───────────────────▶│ │ │ 2. {access_token, refresh_token} │ │◀───────────────────│ │ │ │ │ │ 3. WS/gRPC/HTTP 携带 JWT │ │──────────────────────────────────────▶│ │ │ │ │ │ 4a. VerifyToken RPC (RPC模式) │ │◀─────────────────│ │ │ 4b. GetSigningKeys (本地模式) │ │◀─────────────────│ │ │ │ │ │ 5. TokenClaims / SigningKeys │ │─────────────────▶│ │ │ │ │ 6. 业务响应 │ │ │◀─────────────────────────────────────│ ``` **角色分工:** | 服务 | 职责 | |------------------|----------------------------------------------------| | **appks** (core) | 颁发 JWT、刷新 JWT、撤销 JWT、管理签名密钥、提供 `TokenService` gRPC | | **imks** (IM) | 接收客户端 JWT,通过 RPC 或本地密钥验证用户身份 | ## Proto 契约 定义在 `proto/core/auth.proto`,package `appks.core.v1`。 appks 和 imks 各自维护一份相同的 proto 文件: - appks 编译为 **server** stub(提供服务) - imks 编译为 **client** stub(调用服务) ### TokenService RPC ```protobuf service TokenService { // 令牌生命周期 (appks 内部调用) rpc IssueToken(IssueTokenRequest) returns (IssueTokenResponse); rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse); // imks 验证 (RPC 模式) rpc VerifyToken(VerifyTokenRequest) returns (VerifyTokenResponse); // imks 密钥拉取 (本地验证模式) rpc GetSigningKeys(GetSigningKeysRequest) returns (GetSigningKeysResponse); } ``` ## JWT 令牌 ### 结构 JWT Header: ```json { "alg": "HS256", "typ": "JWT", "kid": "01909a..." // 签名密钥 ID,用于匹配 SigningKey } ``` JWT Payload (`TokenClaims`): ```json { "sub": "user-uuid", "iss": "appks", "iat": 1718000000, "exp": 1718003600, "jti": "01909b...", "scope": "im:read im:write", "extra": { "workspace_id": "..." } } ``` ### 令牌类型 | 类型 | 格式 | 存储 | 用途 | |-------------------|----------------------|----------------------------------------------|---------------------------------------| | **access_token** | JWT (HS256) | 无状态,客户端持有 | 每次请求携带,验证用户身份 | | **refresh_token** | `rt_{UUIDv7}` 不透明字符串 | Redis `core:token:refresh:{token}` → user_id | 换取新的 access_token + refresh_token(旋转) | ## 双模式验证 imks 可选择以下任一模式验证客户端 JWT: ### 模式 A:RPC 验证(`VerifyToken`) ``` imks → appks TokenService.VerifyToken(jwt) → {valid, claims} ``` - **优点**:实时权威,能感知撤销 - **缺点**:每次请求增加一次 RPC 往返 - **适用场景**:高安全要求操作(管理员操作、敏感数据) ### 模式 B:本地验证(`GetSigningKeys`) ``` imks 启动时 → appks TokenService.GetSigningKeys() → 缓存密钥到本地 后续请求 → imks 用本地密钥解码 JWT(HS256 验签) 定期刷新 → 根据 next_rotation_at 拉取新密钥 ``` - **优点**:零 RPC 延迟,appks 不可用时仍能验证 - **缺点**:撤销有最多一个密钥窗口(3h)的延迟 - **适用场景**:高频低延迟操作(消息收发、实时通信) ### 推荐策略 混合使用: - 普通操作(发消息、读频道)→ 本地验证 - 敏感操作(踢人、删频道、改权限)→ RPC 验证 ## 签名密钥管理 ### 密钥窗口 ``` 时间轴: ─────────┬──────────┬──────────┬──────── │ key A │ key B │ key C │ (过期) │ (活跃) │ (未来) └──────────┴──────────┴──────── issued_at issued_at issued_at +3h +3h +3h ``` - 每个签名密钥有效期 **3 小时** - 同一时刻可能有 **2 个有效密钥**(滚动窗口,平滑过渡) - JWT header 的 `kid` 字段标识使用哪个密钥签名 ### 密钥轮换流程 ``` 1. 当前密钥到达 3h → TokenService.rotate_if_needed() 2. Redis 分布式锁 (core:token:rotation_lock, 10s TTL) 防止多实例竞争 3. 旧密钥标记 active=false,仍保留在 Redis 用于验证旧 token 4. 生成新密钥,active=true 5. ArcSwap 原子替换当前签名密钥 6. 旧密钥 TTL = 6h (2× window) 后从 Redis 自动清除 ``` ### 密钥存储(Redis) ``` core:token:active_key → kid (当前活跃密钥 ID) core:token:key:{kid} → SigningKey JSON (TTL = 6h) core:token:rotation_lock → "1" (TTL = 10s, 分布式锁) ``` ### SigningKey 结构 ```rust pub struct SigningKeyInfo { pub kid: String, // UUIDv7 pub algorithm: String, // "HS256" pub key_material: String, // base64(32 bytes random) pub issued_at: i64, pub expires_at: i64, // issued_at + 3h pub active: bool, } ``` ## 撤销机制 ### Redis 布局 ``` core:token:revoked:{jti} → "1" (TTL = token 剩余有效期) core:token:refresh:{token} → user_id (TTL = 7d) ``` ### 撤销方式 | 操作 | RPC | 效果 | |--------------|------------------------|-----------------------| | 撤销单个 token | `RevokeToken(jti)` | 将 jti 加入撤销列表 | | 撤销用户所有 token | `RevokeToken(user_id)` | 删除该用户所有 refresh token | ### 撤销感知延迟 | 验证模式 | 延迟 | |-----------------------|------------------------------| | RPC (`VerifyToken`) | **实时** — 每次检查撤销列表 | | 本地 (`GetSigningKeys`) | **最多 3h** — 密钥过期前无法感知 jti 撤销 | ## appks 实现 ### 模块结构 ``` service/internal_auth.rs → TokenService (业务逻辑) grpc/auth.rs → TokenGrpcService (gRPC handler) grpc/mod.rs → TokenServiceServer 注册到 tonic server api/internal/issue_api_key.rs → REST: POST /api/v1/internal/tokens ``` ### TokenService 核心 ```rust pub struct TokenService { redis: AppRedis, current_key: Arc>, // 无锁读 } ``` - 启动时从 Redis 加载活跃密钥,无则生成 - 签名使用 `jsonwebtoken` crate (HS256) - 密钥轮换使用 Redis 分布式锁,支持多实例部署 - `ArcSwap` 保证签名密钥读取无锁、写入原子 ## imks 实现指南 ### 启动流程 ```rust // 1. 连接 appks TokenService let mut token_client = TokenServiceClient::connect(appks_addr).await?; // 2. 拉取签名密钥 let resp = token_client.get_signing_keys(GetSigningKeysRequest { kid: "" }).await?; let keys = resp.keys; let next_rotation = resp.next_rotation_at; // 3. 缓存密钥到本地 (HashMap) key_store.insert_all(keys); // 4. 安排定时刷新 tokio::spawn(async move { loop { let delay = next_rotation - now(); tokio::time::sleep(Duration::from_secs(delay as u64)).await; let resp = token_client.get_signing_keys(...).await; key_store.update(resp.keys); } }); ``` ### 连接时验证 ```rust // 客户端建立 WebSocket/gRPC 连接时携带 JWT fn on_connect(headers: &Headers) -> Result { let token = headers.get("Authorization") .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(AuthError::MissingToken)?; // 本地验证 (快速路径) let header = decode_header(token)?; let kid = header.kid.ok_or(AuthError::MissingKid)?; let key = key_store.get(&kid).ok_or(AuthError::UnknownKey)?; let mut validation = Validation::new(Algorithm::HS256); validation.set_issuer(&["appks"]); validation.validate_exp = true; let data = decode::(token, &key, &validation)?; Ok(data.claims) } ``` ### 敏感操作验证 ```rust // 敏感操作走 RPC 验证 (权威路径) async fn on_sensitive_action(token: &str) -> Result { let resp = token_client.verify_token(VerifyTokenRequest { token: token.to_string(), }).await?; if resp.valid { Ok(resp.claims.unwrap()) } else { Err(AuthError::from(resp.reason)) } } ``` ## 安全考虑 1. **密钥传输**:appks → imks 的 gRPC 连接应使用 mTLS,防止密钥在传输中被截获 2. **密钥生命周期**:3h 窗口平衡了安全性和可用性;缩短窗口可减少撤销延迟但增加轮换频率 3. **HS256 vs 非对称**:当前使用 HS256(对称密钥),imks 拿到的密钥可以伪造 token。如果 imks 不可完全信任,应改用 RS256/EdDSA,imks 只持有公钥 4. **Refresh Token 安全**:每次刷新都旋转(旧 token 立即失效),防止重放 5. **撤销列表 TTL**:与 token 剩余有效期对齐,过期 token 无需保留撤销记录 --- # IM 服务 Proto 说明书 以下是 imks 侧 `proto/core/` 下各 gRPC 服务的完整说明。所有 IM 服务定义在 `appks.im.v1` 包下,由 appks 提供 server 端,imks 消费 client 端。 ## 服务总览 | Proto 文件 | 服务 | RPC 数量 | 职责 | |---|---|---|---| | `auth.proto` | TokenService | 5 | JWT 令牌生命周期 + 验证 + 密钥分发 | | `channel.proto` | ChannelService | 10 | 频道/分类 CRUD + 统计 | | `member.proto` | MemberService | 7 | 成员邀请/踢出/加入/离开/查询 | | `permission.proto` | PermissionService | 7 | 权限检查 + 覆盖规则 + 频道解析 | | `channel_settings.proto` | ChannelRoleService | 4 | 频道自定义角色 | | | ChannelInvitationService | 4 | 邀请生命周期 | | | ChannelWebhookService | 4 | Webhook CRUD | | | ChannelSlashCommandService | 4 | 斜杠命令注册 | | | ChannelRepoLinkService | 3 | 频道 ↔ 代码仓库关联 | | | ImIntegrationService | 4 | 外部平台集成(Slack/Discord 等) | | | CustomEmojiService | 3 | 工作区自定义表情 | | | ForumTagService | 4 | 论坛频道标签 | | | VoiceService | 2 | 语音频道参与者状态 | | | StageService | 4 | 舞台频道管理 | | | ChannelAuditService | 1 | 频道审计日志查询 | --- ## ChannelService(`channel.proto`) 频道和分类的 CRUD 管理,以及频道统计。 ### 枚举 **ChannelType** — 频道类型: | 值 | 含义 | |---|---| | `PUBLIC` | 公开频道,workspace 内所有人可见 | | `PRIVATE` | 私有频道,仅被邀请成员可见 | | `DIRECT` | 私聊(一对一) | | `GROUP` | 群聊(多人私聊) | | `REPO` | 仓库关联频道(自动与 git repo 绑定) | | `SYSTEM` | 系统频道(公告、通知等,只读) | **ChannelKind** — 频道形态: | 值 | 含义 | |---|---| | `TEXT` | 文本频道 | | `VOICE` | 语音频道 | | `STAGE` | 舞台频道(主持人+观众模式) | | `FORUM` | 论坛频道(帖子/主题式讨论) | | `ANNOUNCEMENT` | 公告频道(仅管理员可发消息) | **Visibility** — 可见性级别(从低到高): | 值 | 含义 | |---|---| | `PUBLIC` | 所有人可见(含未登录用户) | | `WORKSPACE` | workspace 成员可见 | | `INTERNAL` | 内部可见(组织成员) | | `PRIVATE` | 仅频道成员可见 | | `PROTECTED` | 受保护(不可被搜索/索引) | | `HIDDEN` | 隐藏(不显示在频道列表中) | | `SECRET` | 机密(仅通过直链访问) | ### RPC 列表 ``` GetChannel(channel_id) → Channel 获取频道详情 ListChannels(workspace, ...) → [Channel], total 列出频道(支持分类/类型/形态过滤) CreateChannel(workspace, name) → Channel 创建频道 UpdateChannel(channel_id, ...) → Channel 更新频道属性 DeleteChannel(channel_id) → {} 删除频道 GetChannelStats(channel_id) → ChannelStats 获取频道统计(成员/消息/线程/反应数) ListCategories(workspace) → [ChannelCategory] 列出分类 CreateCategory(workspace, name) → ChannelCategory 创建分类 UpdateCategory(category_id, ...) → ChannelCategory 更新分类 DeleteCategory(category_id) → {} 删除分类 ``` ### 核心消息 **Channel** — 频道主体: | 字段 | 类型 | 说明 | |---|---|---| | `id` | UUID | 频道 ID | | `workspace_id` | UUID | 所属 workspace | | `category_id` | UUID? | 所属分类(可选) | | `parent_channel_id` | UUID? | 父频道(用于子频道/线程) | | `name` | string | 频道名称 | | `topic` / `description` | string? | 主题 / 描述 | | `channel_type` | ChannelType | 频道类型 | | `channel_kind` | ChannelKind | 频道形态 | | `visibility` | Visibility | 可见性 | | `position` | int32 | 排序位置 | | `nsfw` | bool | NSFW 标记 | | `read_only` | bool | 只读(仅管理员可发消息) | | `archived` | bool | 已归档 | | `rate_limit_per_user` | int32? | 慢速模式(秒/消息) | | `last_message_id` / `last_message_at` | — | 最后一条消息信息 | **ChannelStats** — 频道统计: | 字段 | 说明 | |---|---| | `members_count` | 成员数 | | `messages_count` | 消息数 | | `threads_count` | 线程数 | | `reactions_count` | 反应数 | | `mentions_count` | @提及数 | | `files_count` | 文件数 | | `last_activity_at` | 最后活跃时间 | --- ## MemberService(`member.proto`) 频道成员管理。 ### 枚举 **Role** — 角色层级(从高到低): | 值 | 含义 | |---|---| | `OWNER` | 频道所有者 | | `ADMIN` | 管理员(全部权限) | | `MAINTAINER` | 维护者(管理频道设置、成员) | | `MODERATOR` | 版主(管理消息、踢人) | | `MEMBER` | 普通成员 | | `CONTRIBUTOR` | 贡献者(可发消息,部分限制) | | `VIEWER` | 观察者(只读) | | `GUEST` | 访客(临时访问) | | `BOT` | 机器人 | **MemberStatus** — 成员状态: | 值 | 含义 | |---|---| | `ACTIVE` | 活跃成员 | | `INVITED` | 已邀请(尚未加入) | | `LEFT` | 已离开 | | `KICKED` | 被踢出 | | `BANNED` | 被封禁 | ### RPC 列表 ``` ListMembers(channel_id, ...) → [ChannelMember], total 列出成员(支持状态过滤) InviteMember(channel_id, user_id) → ChannelMember 邀请用户加入频道 UpdateMember(channel_id, user_id) → ChannelMember 更新成员(角色/禁言/置顶) KickMember(channel_id, user_id) → {} 踢出成员 JoinChannel(channel_id, user_id) → ChannelMember 用户主动加入 LeaveChannel(channel_id, user_id) → {} 用户主动离开 IsMember(channel_id, user_id) → is_member, role 检查是否为成员 ``` ### 核心消息 **ChannelMember** — 频道成员: | 字段 | 说明 | |---|---| | `channel_id` / `user_id` | 频道 + 用户 | | `role` | 角色(Role 枚举值字符串) | | `status` | 状态(MemberStatus 枚举值字符串) | | `muted` | 是否被禁言 | | `pinned` | 是否被置顶(频道侧标记) | | `last_read_message_id` / `last_read_at` | 已读进度 | | `joined_at` / `left_at` | 加入/离开时间 | --- ## PermissionService(`permission.proto`) 频道级权限系统,独立于 workspace/repo 的通用权限。 ### 权限枚举(ImPermission) | 权限 | 说明 | |---|---| | `READ_CHANNEL` | 查看频道 | | `SEND_MESSAGE` | 发送消息 | | `MANAGE_THREADS` | 管理线程 | | `MANAGE_REACTIONS` | 管理反应 | | `MANAGE_PINS` | 管理置顶消息 | | `INVITE_MEMBERS` | 邀请成员 | | `KICK_MEMBERS` | 踢出成员 | | `MANAGE_CHANNEL` | 管理频道设置 | | `MANAGE_ROLES` | 管理角色 | | `MANAGE_WEBHOOKS` | 管理 Webhook | | `MANAGE_EMOJIS` | 管理自定义表情 | | `VIEW_AUDIT_LOG` | 查看审计日志 | | `MANAGE_INTEGRATIONS` | 管理外部集成 | | `SEND_TTS` | 发送 TTS 消息 | | `USE_SLASH_COMMANDS` | 使用斜杠命令 | | `ATTACH_FILES` | 上传文件 | | `MENTION_EVERYONE` | @所有人 | | `MANAGE_MESSAGES` | 管理消息(删除他人消息) | | `ADMIN` | 管理员(拥有所有权限) | ### RPC 列表 ``` CheckPermission(channel, user, perm) → allowed, role 检查单项权限 GetPermissions(channel, user) → [ImPermission] 获取用户全部权限 SetPermissionOverwrite(channel, target) → Overwrite 设置权限覆盖 GetPermissionOverwrites(channel) → [Overwrite] 获取覆盖列表 DeletePermissionOverwrite(channel, target) → {} 删除覆盖 ResolveChannel(channel_id) → 频道摘要信息 解析频道元数据 EnsureReadable(channel, user) → allowed 确保用户可读(快速检查) ``` ### 权限覆盖(PermissionOverwrite) 权限覆盖允许对特定用户/角色在特定频道上覆盖默认权限: | 字段 | 说明 | |---|---| | `target_type` | `"user"` 或 `"role"` | | `target_id` | 用户 ID 或角色 ID | | `allow` | 显式允许的权限列表 | | `deny` | 显式拒绝的权限列表 | 权限解析优先级:`deny 覆盖 > allow 覆盖 > 角色权限` --- ## ChannelSettings 服务组(`channel_settings.proto`) 所有频道配置相关的服务定义在同一个 proto 文件中。 ### ChannelRoleService — 频道自定义角色 频道级别的自定义角色(不同于 `member.proto` 中的全局 Role 枚举)。 ``` ListChannelRoles(channel_id) → [ChannelRole] CreateChannelRole(channel, name) → ChannelRole UpdateChannelRole(role_id, ...) → ChannelRole DeleteChannelRole(role_id) → {} ``` **ChannelRole** 字段:`name`, `permissions[]`(ImPermission 字符串列表), `assignable`(是否可被普通成员分配) ### ChannelInvitationService — 邀请管理 ``` ListInvitations(channel_id) → [ChannelInvitation] CreateInvitation(channel, user) → ChannelInvitation AcceptInvitation(invitation_id) → ChannelInvitation RevokeInvitation(invitation_id) → {} ``` **ChannelInvitation** 字段:`invited_by`, `invited_user_id`, `role`(预设角色), `status` ### ChannelWebhookService — Webhook 管理 ``` ListWebhooks(channel_id) → [ChannelWebhook] CreateWebhook(channel, name, url) → ChannelWebhook UpdateWebhook(webhook_id, ...) → ChannelWebhook DeleteWebhook(webhook_id) → {} ``` **ChannelWebhook** 字段:`name`, `url`, `secret`(签名验证用), `events[]`(订阅事件列表), `active` ### ChannelSlashCommandService — 斜杠命令注册 ``` ListSlashCommands(channel_id) → [ChannelSlashCommand] CreateSlashCommand(channel, cmd, url) → ChannelSlashCommand UpdateSlashCommand(command_id, ...) → ChannelSlashCommand DeleteSlashCommand(command_id) → {} ``` **ChannelSlashCommand** 字段:`command`(命令名如 `/deploy`), `description`, `request_url`(回调地址), `scopes[]` ### ChannelRepoLinkService — 仓库关联 将频道与代码仓库关联,自动推送仓库事件到频道。 ``` ListRepoLinks(channel_id) → [ChannelRepoLink] CreateRepoLink(channel, repo, type) → ChannelRepoLink DeleteRepoLink(link_id) → {} ``` **ChannelRepoLink** 字段:`repo_id`, `link_type`, `events[]`(订阅的仓库事件:push、pr、issue 等) ### ImIntegrationService — 外部平台集成 与 Slack、Discord 等外部平台的消息同步。 ``` ListIntegrations(channel_id) → [ImIntegration] CreateIntegration(channel, provider, ...)→ ImIntegration UpdateIntegration(integration_id, ...) → ImIntegration DeleteIntegration(integration_id) → {} ``` **ImIntegration** 字段:`provider`(平台名), `external_channel_id`(外部频道 ID), `sync_direction`(`inbound`/`outbound`/`bidirectional`), `active` ### CustomEmojiService — 自定义表情 工作区级别的自定义表情管理。 ``` ListCustomEmojis(workspace_id) → [CustomEmoji] CreateCustomEmoji(workspace, name, url) → CustomEmoji DeleteCustomEmoji(emoji_id) → {} ``` **CustomEmoji** 字段:`workspace_id`, `name`(表情名如 `:appks:`), `image_url` ### ForumTagService — 论坛标签 论坛频道(`ChannelKind::FORUM`)的帖子分类标签。 ``` ListForumTags(channel_id) → [ForumTag] CreateForumTag(channel, name, ...) → ForumTag UpdateForumTag(tag_id, ...) → ForumTag DeleteForumTag(tag_id) → {} ``` **ForumTag** 字段:`name`, `moderated`(是否需要管理员审核), `position` ### VoiceService — 语音频道 语音频道的参与者状态管理。 ``` ListVoiceParticipants(channel_id) → [VoiceParticipant] UpdateVoiceState(channel, user, ...) → VoiceParticipant ``` **VoiceParticipant** 字段:`user_id`, `muted`(静音), `deafened`(屏蔽音频), `joined_at` ### StageService — 舞台频道 舞台频道(`ChannelKind::STAGE`)的管理。主持人说话,观众收听。 ``` GetStage(channel_id) → Stage CreateStage(channel, topic, ...) → Stage UpdateStage(stage_id, ...) → Stage DeleteStage(stage_id) → {} ``` **Stage** 字段:`topic`(当前话题), `privacy_level`, `discoverable`(是否可被发现), `started_at` / `ended_at` ### ChannelAuditService — 审计日志 频道操作审计日志查询(只读)。 ``` ListChannelEvents(channel_id, ...) → [ChannelAuditEvent], total ``` **ChannelAuditEvent** 字段:`actor_id`(操作者), `event_type`(事件类型字符串), `target_type` / `target_id`(操作对象), `old_value` / `new_value`(变更前后值) --- ## imks 与 appks 的调用关系 ``` ┌────────────────────────────────────────────────────────────┐ │ imks │ │ │ │ Socket.IO / WebSocket / WebTransport │ │ │ │ │ ▼ │ │ 连接握手 ──→ TokenService.VerifyToken() 或 本地密钥验证 │ │ │ │ │ ▼ │ │ 消息收发 ──→ ChannelService + MemberService │ │ │ PermissionService.EnsureReadable() │ │ │ │ │ ▼ │ │ 频道管理 ──→ ChannelService CRUD │ │ │ ChannelRoleService │ │ │ ChannelInvitationService │ │ │ │ │ ▼ │ │ 语音/舞台 ──→ VoiceService + StageService │ │ │ │ 集成/扩展 ──→ WebhookService + SlashCommandService │ │ RepoLinkService + ImIntegrationService │ │ │ │ 审计查询 ──→ ChannelAuditService │ └────────────────────────┬───────────────────────────────────┘ │ gRPC ▼ ┌────────────────────────────────────────────────────────────┐ │ appks │ │ TokenService server │ Channel/Member/Permission server │ │ Redis (JWT keys) │ Postgres (channel data) │ └────────────────────────────────────────────────────────────┘ ``` ### imks 本地缓存建议 | 数据 | 缓存策略 | 刷新时机 | |--------------------------|--------------|--------------------------| | 签名密钥 (`SigningKey[]`) | 内存 HashMap | `next_rotation_at` 到达时拉取 | | 频道信息 (`Channel`) | LRU + TTL | 频道更新事件 (NATS) | | 成员列表 (`ChannelMember[]`) | LRU + TTL | 成员变更事件 (NATS) | | 权限缓存 | 短期 TTL (30s) | 权限变更事件 (NATS) | | 自定义表情 | 全量加载 + 事件增量 | emoji 增删事件 (NATS) |