Compare commits

..
438 Commits
Author SHA1 Message Date
richjohnrambo 73c3dc0541 feat(media): add MinIO storage switch and HTTPS OSS endpoint 2026-10-17 22:35:04 +08:00
richjohnrambo 4c2c35b55b fix: enforce group file upload governance 2026-10-17 17:08:52 +08:00
richjohnrambo 178ecff21f fix(ci): align test CPU request with ArgoCD 2026-10-17 13:15:04 +08:00
richjohnrambo 9132207e21 fix: group moderation and profile sync 2026-10-17 10:12:20 +08:00
Austin ea29fef40f docs: 更新IM接口文档,新增avatar_url字段及乐观锁说明 2026-10-15 14:38:17 +08:00
Austin dbc7c6a3e9 fix(avatar): 租户路径文件 ACL 补齐头像放行条
fileMapper.UserHasAccess 恒经 TenantFileUserHasAccess,共享库多租户
模式走 tenant_adapter 的 SQL;此前第 9 条(头像链接行对登录用户放行)
只加在了基础路径 FileUserHasAccess,导致共享库环境下他人下载头像仍
403。同步补齐,与 FileUserHasAccess 保持一致。
2026-10-15 13:38:39 +08:00
Your Name 3b186298a0 fix: configure Kafka brokers at runtime 2026-10-15 12:26:09 +08:00
Austin ab572a6c36 fix(avatar): 修复群成员拉取他人头像 403 及补充 avatar_url 回填规则 2026-10-14 22:30:13 +08:00
richjohnrambo 441009b81c fix privacy friend verification updates 2026-10-14 17:11:41 +08:00
Austin 297f1fa0b7 docs: 补充注册 username 规则说明
新增服务端 username 强校验规则,包括长度、字符限制、自动生成规则及错误返回详情,明确最小长度来自部署配置、上限为 10。
2026-10-14 16:49:19 +08:00
richjohnrambo 027dda786e fix media downloads through authenticated origin 2026-10-14 14:51:24 +08:00
richjohnrambo 87ec8f9833 fix kafka attachment message id binding 2026-10-14 12:13:45 +08:00
richjohnrambo 6930b23cce fix media download CORS headers 2026-10-14 10:09:59 +08:00
Austin e8804883fc feat: 新增群组成员数量统计功能并在接口中返回
实现了群组成员数量统计的接口,包含数据库层统计逻辑,在群组详情、设置等接口中返回实时成员数,同时修复了测试用例中参数空格问题。
2026-10-13 17:58:05 +08:00
Austin 43808c36d1 fix: 将im_filemsglinks.msgid字段从INT改为BIGINT适配雪花ID
修复msgid为INT类型无法容纳雪花ID的问题,避免插入时数值溢出导致Kafka消费分区停滞
同时新增数据库 schema 校验逻辑,提前发现不符合要求的字段类型
2026-10-13 17:07:41 +08:00
Austin b126b13b77 fix(server): Kafka消息持久化重试耗尽时死信处理避免无限重试
在Kafka消息持久化重试耗尽时,新增将消息死信的逻辑,而非保留未提交的偏移量,避免无法解决的故障导致分区消息无限重试阻塞后续消息。同时补充死信处理的日志,确保问题可追踪。
2026-10-13 15:40:19 +08:00
Austin f3b1a06da5 feat(server): 支持上传文件关联会话话题并做权限校验
新增上传时读取会话话题参数,校验上传者是否为该话题的成员,通过后将文件与话题关联,允许其他成员下载。权限校验仅针对点对点和群组话题,关联失败不会阻止文件上传。
2026-10-13 11:59:36 +08:00
Austin 6be7796049 feat: 为应用版本查询增加appCode参数支持
新增应用版本类型的AppCode字段,修改相关接口和实现以支持通过appCode查询指定应用的最新版本,增加appCode合法性校验,完善缓存键以区分不同应用的版本信息
2026-10-12 14:59:23 +08:00
Austin 0a33d21eb8 feat: 新增禾圃集的法律页面及路由 2026-10-12 09:33:34 +08:00
richjohnrambo 5d792f9f96 fix kafka message persistence completion 2026-10-12 09:19:28 +08:00
richjohnrambo c219ef6455 fix(server): suppress expected websocket close write errors 2026-10-11 22:50:54 +08:00
richjohnrambo 1e0b4c181d fix(server): raise topic publish queue capacity 2026-10-10 09:37:42 +08:00
Austin 54c594f39b docs: 更新清除聊天记录功能的服务端实现说明 2026-10-09 19:54:50 +08:00
Austin 2e1ceec717 docs: 更新系统通知相关接口文档及问题修复记录 2026-10-09 10:00:48 +08:00
Austin 1d8f9e3be4 feat: 为系统通知添加明确的操作结果摘要
为风险挑战确认和好友申请处理场景添加对应操作结果的中文摘要,替代原有的状态文本,让用户能更清晰地理解系统通知内容。
2026-10-08 20:25:28 +08:00
Austin 8228ceb89f fix: 处理撤回消息解密时的500错误
撤回消息会清空content但保留key_version,新增判断跳过此类消息解密,避免整个查询窗口被毒化
2026-10-08 19:43:27 +08:00
richjohnrambo f24133f5de fix(server): coalesce platform evict fanout 2026-10-08 00:23:12 +08:00
richjohnrambo e30b70a3a7 fix(server): limit implicit large group subscription meta 2026-10-07 23:13:47 +08:00
richjohnrambo ac6cd3c6e8 fix(server): shed overloaded group websocket fanout 2026-10-07 21:23:22 +08:00
richjohnrambo 58c15efad5 fix(loadtest): submit observed after quiet timeout 2026-10-07 00:41:54 +08:00
richjohnrambo 2732e05a83 fix(server): harden user cache update queue under load 2026-10-06 23:32:17 +08:00
richjohnrambo d3054f62b5 test(load): add idle and tunable group scenarios 2026-10-05 17:36:18 +08:00
richjohnrambo 965f94f154 fix(kafka): preserve partially fetched message batches 2026-10-04 23:37:10 +08:00
richjohnrambo b05e8f60dd perf(group): reduce websocket fanout serialization 2026-10-04 10:15:18 +08:00
richjohnrambo c46209f4e7 fix(deploy): diagnose stalled statefulset rollouts 2026-10-04 01:06:26 +08:00
richjohnrambo 7b25365ec3 fix(group): bound push pressure and synchronize load agents 2026-10-04 00:49:27 +08:00
richjohnrambo 746f75ab0f fix(kafka): retain failed message persistence 2026-10-03 22:32:08 +08:00
richjohnrambo db92c41241 fix(loadtest): apply direct dial IP assignment 2026-10-03 20:35:31 +08:00
richjohnrambo 80808349fb fix: absorb multiplex close bursts 2026-10-02 15:53:03 +08:00
richjohnrambo a406d63154 fix: stop writes to closed cluster sessions 2026-10-02 00:28:02 +08:00
richjohnrambo d43b1b63d3 fix: preserve cross-pod message order 2026-10-01 19:00:16 +08:00
richjohnrambo dccd0969d8 fix: register cluster kv payloads 2026-10-01 16:08:00 +08:00
richjohnrambo 73a594146f fix: trace delayed websocket acknowledgements 2026-10-01 15:19:57 +08:00
richjohnrambo 19584d0939 fix: harden high-fanout delivery under load 2026-09-30 23:04:54 +08:00
richjohnrambo 2b631ab176 fix: harden websocket heartbeat under load 2026-09-30 17:36:33 +08:00
richjohnrambo b83800c0f3 docs: update legal agreements 2026-09-30 17:04:49 +08:00
richjohnrambo 05b3da55ac fix: align group private chat permission semantics 2026-09-30 16:11:30 +08:00
richjohnrambo 99ae6328ff fix: handle backend issue list regressions 2026-09-30 15:35:03 +08:00
richjohnrambo 7685fb0c02 fix: persist upload original filename 2026-09-30 14:25:44 +08:00
richjohnrambo 097b028a19 docs: record exact 600 regression runs 2026-09-30 11:54:04 +08:00
richjohnrambo dcb131dcea fix: make ai task events realtime only 2026-09-30 11:37:49 +08:00
richjohnrambo d75c6fa0b4 docs: record async push load regression 2026-09-30 10:52:01 +08:00
richjohnrambo 06e08a65e4 fix: handle opaque attachment references 2026-09-30 10:50:42 +08:00
richjohnrambo 9f30995787 fix: move group push work off topic loop 2026-09-30 10:01:59 +08:00
richjohnrambo da8efb6e38 feat: expose websocket delivery pressure metrics 2026-09-29 23:15:29 +08:00
richjohnrambo b1545b6876 fix: keep load connections through verification 2026-09-29 17:52:13 +08:00
richjohnrambo 4b00b5ae37 fix: harden single-machine load handling 2026-09-29 17:52:13 +08:00
Austin ab73b69870 feat: 新增修改密码和注销账号功能
新增修改密码和注销账号的API接口,实现用户通过验证旧密码修改密码,以及通过短信验证流程注销账号的功能,同时补充相关常量和文档说明。
2026-09-29 16:13:32 +08:00
Austin cf9d559944 fix: 修复资源不存在/已解散返回错误码及网关502问题
修复好友申请不存在时返回正确的 ErrNotFound 避免解引用 panic,新增通用函数判断群不存在或已解散并返回对应错误码,更新文档统一资源不存在类错误语义,避免服务端返回5xx错误。
2026-09-29 13:34:24 +08:00
Austin 1a8043bee2 docs: 更新 IM 消息历史分页的接口约定和向上翻页规则
补充向上翻页加载更早历史的参数约定,明确 REST 消息接口为空占位禁止使用,历史分页统一走 WebSocket 的 get data 接口,同步更新接口文档说明。
2026-09-29 11:08:48 +08:00
Austin 7c550aaf9d feat: 新增关系类型及按话题查询通话记录接口
- 新增 RelationshipSelf 关系类型
- 新增 serveIMCallsByTopic 接口处理按话题查询通话记录
- 实现群成员资料接口返回本人时的 self 关系处理
- 更新接口文档说明本人查询时返回 relationship: self
2026-09-29 10:46:32 +08:00
Austin 0718da784f docs: 移除 IM API 文档中接口的“待实现”标记 2026-09-29 10:26:27 +08:00
Austin 2f8ded104d docs: 添加单聊已读/已收回执协议约定文档 2026-09-29 09:53:24 +08:00
Austin 1ff0894205 fix(db): 移除消息查询中已废弃的 deletedat 条件
docs: 更新文件上传和消息补偿相关接口文档
2026-09-28 17:45:04 +08:00
richjohnrambo a974572f72 test: stabilize distributed load verification 2026-09-28 09:50:33 +08:00
richjohnrambo d3c679878d test: throttle history verification 2026-09-28 08:17:14 +08:00
richjohnrambo cb2210aa3e test: harden group load verification 2026-09-28 00:58:24 +08:00
richjohnrambo 622fc50418 fix: keep load agents connected before start 2026-09-28 00:16:51 +08:00
richjohnrambo 6b8ed4a2cc perf: batch group unread fanout 2026-09-27 23:10:28 +08:00
richjohnrambo fc875cc84e test: report ambiguous group publish outcomes 2026-09-27 22:59:17 +08:00
richjohnrambo e90e92a59e docs: standardize load test runtime package names 2026-09-27 22:31:53 +08:00
richjohnrambo 72e6c7b2c7 docs: record p2p result and next load stage 2026-09-27 22:17:57 +08:00
richjohnrambo ea8732e2b1 fix: keep load test websocket sessions alive 2026-09-27 21:41:50 +08:00
richjohnrambo bde02e8eac perf: expose and reduce message publish latency 2026-09-27 21:08:35 +08:00
richjohnrambo 8e778726e0 fix: cache tenant routing on message hot path 2026-09-27 20:10:18 +08:00
richjohnrambo 36b52e120c docs: record p2p 500 pool retest 2026-09-27 20:01:32 +08:00
richjohnrambo 3bb6774786 fix: protect publish path from mysql exhaustion 2026-09-27 18:35:54 +08:00
richjohnrambo 36eb76a5e3 docs: record p2p 500 load test bottleneck 2026-09-27 17:45:39 +08:00
richjohnrambo 0cf88e6e97 docs: add staged dual-machine pressure test runbook 2026-09-27 17:10:01 +08:00
richjohnrambo 3fb95fd5d9 test: report exact ACK latency percentiles 2026-09-27 17:05:06 +08:00
richjohnrambo d96f661f30 fix: reduce persistence lag and retry audit deadlocks 2026-09-27 16:33:05 +08:00
richjohnrambo 704c65a7a4 docs: record post-GitOps p2p pressure results 2026-09-27 16:14:22 +08:00
richjohnrambo 188164ce82 test: capture ordering regressions and enforce deploy resources 2026-09-27 14:05:21 +08:00
richjohnrambo d0e1e98f5f repair stale loadtest friendship setup 2026-09-27 13:40:58 +08:00
richjohnrambo ecfe13ff0e use unique p2p preflight idempotency keys 2026-09-27 13:36:12 +08:00
richjohnrambo 8280ac5987 fix loadtest p2p preflight replay 2026-09-27 13:35:17 +08:00
richjohnrambo f9f7321b11 fix p2p permission refresh during load tests 2026-09-27 12:53:09 +08:00
richjohnrambo a57d1e8d4a test: support staged pressure connection counts 2026-09-27 09:48:46 +08:00
richjohnrambo 96eb30b46f ci: tolerate optional ingress RBAC 2026-09-27 09:37:19 +08:00
richjohnrambo ce3250be64 fix: harden multi-pod delivery and load verification 2026-09-27 03:37:32 +08:00
richjohnrambo a2de100f2b docs: record message persistence recovery 2026-09-27 03:09:34 +08:00
richjohnrambo ae2dc0d45e fix: align private message ids with snowflake 2026-09-27 02:53:25 +08:00
richjohnrambo c8d08e26e2 docs: analyze p2p load test cluster overload 2026-09-27 02:34:58 +08:00
richjohnrambo 9958b3c462 docs: add pressure runtime package transfer steps 2026-09-27 01:58:31 +08:00
richjohnrambo 07eb0b05ef docs: record allrich pressure test run 2026-09-27 01:50:24 +08:00
richjohnrambo 6d17154a72 fix: retry load-test websocket setup 2026-09-27 01:39:24 +08:00
richjohnrambo b3b5dd55a6 feat: support verified bulk load-test setup 2026-09-27 01:34:39 +08:00
richjohnrambo 021169555a fix: use group message shard directory 2026-09-27 00:40:24 +08:00
richjohnrambo 6e416fcfd0 docs: add machine B pressure test instructions 2026-09-27 00:24:28 +08:00
richjohnrambo c235139d83 docs: add dual-host pressure test runbook 2026-09-27 00:23:02 +08:00
richjohnrambo 2b90a2e759 feat: add dual-host IM pressure test tool 2026-09-26 21:50:43 +08:00
richjohnrambo f898a7171c feat: batch kafka message persistence 2026-09-24 22:45:07 +08:00
richjohnrambo 7f2df1dd4f fix: reject unrouted messages without seq 2026-09-24 18:33:09 +08:00
richjohnrambo 1f35d4f786 fix forwarded message read state on proxy topics 2026-09-24 17:24:46 +08:00
richjohnrambo 31a70c0bac feat: consume workbench kafka events 2026-09-24 16:56:33 +08:00
richjohnrambo 7405277f27 fix: omit admin-only actions from group notices 2026-09-24 16:47:19 +08:00
richjohnrambo 6453abdbd1 fix: return username format error on registration 2026-09-24 16:47:14 +08:00
richjohnrambo d41956019a fix unread cache invalidation for forwarded messages 2026-09-24 16:37:05 +08:00
richjohnrambo f952249dc3 feat: cache topic and subscription reads 2026-09-24 14:46:00 +08:00
richjohnrambo 0881256103 fix: retain cultivate feed media references 2026-09-24 14:27:17 +08:00
richjohnrambo 5cea4d1e2e fix: sync persist conversation system notices 2026-09-24 11:26:27 +08:00
richjohnrambo 5ce04bad05 docs: track pending image review worker 2026-09-24 11:13:30 +08:00
richjohnrambo 48ca3ae684 fix: align member meta default avatars 2026-09-24 11:00:19 +08:00
richjohnrambo 75216c311d fix cluster proxy cleanup routing 2026-09-24 10:18:07 +08:00
richjohnrambo 9893452817 fix: reserve seq for p2p system messages 2026-09-24 10:03:27 +08:00
richjohnrambo a3df9d269c fix: broadcast forwarded messages after kafka publish 2026-09-24 00:00:50 +08:00
richjohnrambo 68a0228dff feat: include avatar url in member public meta 2026-09-23 23:53:26 +08:00
richjohnrambo da02eae6a4 fix: broadcast async forwarded messages after persistence 2026-09-23 18:14:34 +08:00
richjohnrambo 3e92021254 feat: support quoted message headers 2026-09-23 17:46:51 +08:00
richjohnrambo aa9f562e1e refactor: retire legacy tinode database routing 2026-09-23 17:04:30 +08:00
richjohnrambo 204043382c fix: scope login audit records by tenant 2026-09-23 15:54:07 +08:00
richjohnrambo 7d4bc4d049 fix: notify recipients for forwarded messages 2026-09-23 15:18:27 +08:00
Austin 134ac8580e fix(security): 修复宵禁策略未生效于群聊话题的问题
添加注释说明宵禁仅作用于私聊和群聊,同时将话题类型判断从仅私聊扩展至群聊,确保两类消息话题均会应用宵禁策略。
2026-09-23 14:14:44 +08:00
richjohnrambo 40aba2a3f5 fix tenant-scoped user search 2026-09-23 13:45:39 +08:00
Austin 6bbe955e75 feat: 新增共库模式下登录失败日志表及相关功能
- 新增登录失败日志表(共库多租户版),添加 tenant_id 列实现租户隔离
- 调整登录失败日志逻辑,仅记录触发账号锁定的事件
- 新增管理端接口功能,为登录失败日志补充实时锁定状态
- 更新测试用例适配共库模式下的租户过滤逻辑
2026-09-23 13:18:28 +08:00
Austin b8b21d3548 feat(server): 为内部管理查询添加租户隔离支持
实现共库模式下的租户数据隔离,新增 TenantColumn 字段标识查询表的租户列,添加 internalAdminTenantCondition 函数生成租户过滤条件。修改所有内部管理查询的构建逻辑,在列表和详情查询中自动注入租户条件,防止跨租户数据泄漏。同时更新所有资源查询规格,配置对应表的租户列。
2026-09-23 12:11:26 +08:00
richjohnrambo a0d0bf574e unbind device push token on logout 2026-09-23 11:39:16 +08:00
richjohnrambo c4355ac236 fix: accept raw phone secret for personal registration 2026-09-23 11:33:16 +08:00
richjohnrambo ac20f1ed25 fix message mutation responses 2026-09-23 10:47:31 +08:00
richjohnrambo b413560455 fix tenant group topic and member cache 2026-09-23 10:35:06 +08:00
richjohnrambo 6303d07580 fix: avoid stale profile projections in shared schema 2026-09-23 10:28:15 +08:00
richjohnrambo 7f0903cd31 fix tenant scoping for group business tables 2026-09-23 10:10:08 +08:00
richjohnrambo 35a70ce9ff fix group tenant topic creation 2026-09-23 09:59:53 +08:00
richjohnrambo 6fa3009987 fix tenant sharding for group topics 2026-09-23 09:49:14 +08:00
richjohnrambo a968fd252e fix: use shared tables for tenant group members 2026-09-23 09:41:45 +08:00
richjohnrambo 7c1fab5065 租户库共库分表实现 2026-09-23 09:36:17 +08:00
richjohnrambo 137d6fb6da fix verification cache for redis-backed challenges 2026-09-23 09:02:56 +08:00
richjohnrambo e37b52c3c0 feat: complete shared database tenant sharding 2026-09-23 02:19:07 +08:00
richjohnrambo 1ec2feee47 fix: keep only tenant realname review policy 2026-09-23 02:08:13 +08:00
richjohnrambo 62856194e5 fix HTTP idempotency insert placeholders 2026-09-23 00:28:26 +08:00
richjohnrambo 1909074898 fix tenant friend request row scans 2026-09-22 20:26:43 +08:00
richjohnrambo 9ddf6ec5ee fix shared-db tenant runtime gaps 2026-09-22 19:28:33 +08:00
Austin 49e43aa3cf fix(mysql): read realname policy fallback from im_tenant
Tenant config was merged into im_tenant on the manager side; switch the
DB fallback query accordingly. Keep ErrNoRows/unknown-column fallbacks
so environments before the phase-1 migration degrade to defaults.
2026-09-22 19:08:42 +08:00
richjohnrambo 8e04f00e96 fix shared database account sequence initialization 2026-09-22 18:00:18 +08:00
richjohnrambo e4416da04a fix im server shared database config 2026-09-22 16:45:24 +08:00
richjohnrambo b8c93e8d6a fix(mysql): route tenant data to physical shard tables 2026-09-22 16:11:10 +08:00
richjohnrambo 76b462e334 docs(sql): add shared database sharding migrations 2026-09-22 15:19:56 +08:00
richjohnrambo 2e0612acbb fix(mysql): adapt tenant shared database fields 2026-09-22 14:40:15 +08:00
richjohnrambo 865b436264 fix: skip challenge requirement for fixed test code 2026-09-22 00:46:35 +08:00
richjohnrambo ac10b31aa1 fix: accept fixed legacy registration code 2026-09-22 00:39:14 +08:00
richjohnrambo 3276c6d1cd fix: allow legacy enterprise registration payloads 2026-09-22 00:23:53 +08:00
richjohnrambo dc36ce292e refactor: unify internal api authentication 2026-09-21 23:08:15 +08:00
richjohnrambo 186cfa21c8 feat: validate personal and enterprise auth tenants 2026-09-21 21:24:25 +08:00
richjohnrambo fc393cd685 docs: add aliyun realname integration notes 2026-09-21 21:24:25 +08:00
Your NameandClaude Sonnet 5 b30cce950a fix(docker): mirror tinode's own log output to stdout
Confirmed live: /var/log/tinode.log-only redirection meant Loki never
received a single real tinode runtime log line (election, rehash,
connection, error output) since the container started -- Promtail
collects standard container stdout/stderr, not files written inside
the container. Every Pod's collected log was just this script's own
"NO_DB_INIT=true: skipping..." init line, nothing from tinode itself.

Now more consequential with native Cluster enabled: failover/rehash
state transitions were completely invisible to the normal Grafana/Loki
monitoring path, visible only via manual kubectl exec.

tee mirrors to both stdout (collected) and the file (still readable via
kubectl exec). PIPESTATUS[0] is required since a bare $? after a pipe
would report tee's exit status, not tinode's -- which also let this
drop the now-redundant "tail last 200 lines to stderr on crash" block,
since the full live stream already went to stdout throughout the run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:23:25 +08:00
Your NameandClaude Sonnet 5 4abd6604d7 fix(deploy): raise rollout timeout for StatefulSet's sequential update
StatefulSet's RollingUpdate replaces Pods one at a time in strict
sequence (unlike a Deployment, and unaffected by podManagementPolicy:
Parallel, which only governs initial scale-up/down) -- confirmed live:
run #152 restarted correctly (2/3 pods updated, then the 3rd) but hit
the old 120s ceiling before the last pod turned Ready, so CI reported
"failure" for a rollout that actually completed successfully seconds
later. 300s gives enough headroom for three sequential pod cycles
(image pull + start + native-Cluster rejoin) instead of tuning it to
the exact observed timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 19:49:44 +08:00
Your NameandClaude Sonnet 5 eca2ee56c0 fix(deploy): update rollout commands for StatefulSet migration
im-server moved from Deployment to StatefulSet in im-deploy-config
(native Cluster enablement) -- this workflow's kubectl commands still
targeted the old resource type, so every deploy since the migration
failed at the final step with "deployments.apps im-server not found"
(image built/pushed fine, just never got rolled out).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 19:40:26 +08:00
Austin 9820e89e1c refactor(server/auth): 移除账号掩码函数并简化审计日志存储
1.  移除不再使用的maskLoginAccount工具函数
2.  登录审计日志直接存储原始账号而非掩码后的版本
3.  重命名测试函数以匹配新的业务逻辑
2026-09-21 17:50:55 +08:00
richjohnrambo 55c59878cf fix: resync user account sequence 2026-09-21 16:55:24 +08:00
Austin a147dc718a refactor(server): 优化登录锁定提示文案和时长计算逻辑
更新了loginLockedMessage函数,细化了锁定时长的提示分级,增加了过期回退文案,让提示更准确友好
2026-09-21 13:55:02 +08:00
richjohnrambo 407842db91 fix: prepare native cluster deployment 2026-09-21 13:52:34 +08:00
richjohnrambo 5f717c2162 refactor: read user profile fields from profile table 2026-09-21 13:44:04 +08:00
Austin d398467fb9 docs: add IM contact us interface docking documentation
Complete the 4251-line docking document for IM contact us interface, including function description, interface definition, client rendering rules, local debugging example, operation configuration steps and reference implementation
2026-09-21 13:28:12 +08:00
Austin 9748c7a478 Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev 2026-09-21 13:14:30 +08:00
Austin 7829990180 feat: 添加联系我们配置接口和字典配置缓存
1. 新增DictionaryItem字典配置结构体
2. 添加字典配置缓存及失效逻辑
3. 实现联系我们接口的业务逻辑和测试用例
4. 注册联系我们API路由
5. 集成字典配置到admin内部客户端
6. 处理字典配置更新的kafka事件
2026-09-21 13:11:42 +08:00
richjohnrambo 05ad37ba80 fix: persist settings for websocket groups 2026-09-21 11:58:36 +08:00
richjohnrambo c891afba73 test: 更新登录锁定表初始化断言 2026-09-21 09:55:29 +08:00
richjohnrambo e3b93113f1 feat: 接入消息加密与实名认证能力 2026-09-21 09:53:39 +08:00
Austin 841c0c77cd feat: 实现登录失败锁定功能
新增了登录失败次数统计和临时锁定机制,包括:
1. 定义登录锁定数据结构和数据库表结构
2. 实现租户级别的锁定状态持久化接口
3. 添加登录速率限制安全策略配置
4. 在登录流程中加入锁定前置检查和失败计数逻辑
5. 提供管理员手动解锁的内部API接口
6. 新增中文本地化的锁定提示文案
2026-09-20 17:08:38 +08:00
Austin 21063d4af0 Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev 2026-09-20 16:39:46 +08:00
Austin 532ff49737 fix(im): parse Drafty entities for message type classification (#31)
Favorites from Drafty messages (link/file/image/video/voice) always fell back to text because imMessageType only recognized flat file_id/url content. Derive the type from Drafty ent[] entities: LN->link, IM->image, VD->video, AU->voice, EX refined by mime. Attachments win over inline links.
2026-09-20 16:38:51 +08:00
richjohnrambo 5c276596b8 fix: support removing group members 2026-09-20 15:37:56 +08:00
Austin 48b3369799 Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev 2026-09-20 14:52:08 +08:00
Austin b2e8b64c0b refactor(mysql): 适配PXC严格模式,替换全局锁方案
重构MySQL迁移逻辑,新增acquireMigrationLock适配Percona XtraDB Cluster的pxc_strict_mode限制,将原有的GET_LOCK迁移锁替换为更兼容的方案,同时为各迁移流程添加快速检查路径减少锁竞争
2026-09-20 14:52:01 +08:00
richjohnrambo db66a965fa feat: 支持养成后台调用IM内部接口 2026-09-19 17:07:39 +08:00
richjohnrambo de01a95c96 Merge remote-tracking branch 'origin/dev' into dev 2026-09-19 15:25:40 +08:00
richjohnrambo a08c0bd921 feat: 提供养成服务内部 IM 能力 2026-09-19 15:24:55 +08:00
richjohnrambo 0fcbbb9318 fix: publish member joined after group invitation 2026-09-18 20:58:00 +08:00
Austin 753a04ed12 fix: broadcast offline presence as soon as the last session drops
Logout left contacts seeing the user online until the me topic idle timeout
fired presUsersOfInterest(off), and even that frame was silently dropped
for p2p routes by handlePresence's xoriginal check. MarkUserOffline now
returns the remaining session count and cleanUp notifies users of interest
immediately once it hits zero; p2p presence is delivered straight to the
contact's me topic, mirroring presSingleUserOffline. Zentao #22.
2026-09-18 17:42:18 +08:00
Austin f4cedc7d75 fix: push realtime del presence to topic sessions on message recall
The REST recall response reached only the caller, so peers kept showing the
recalled message until a full resync. broadcastIMMessageRecalled now pushes
the native {pres what:del delseq} tombstone through the hub for both single
and batch recall (empty pres topic for p2p, required by handlePresence's
xoriginal check); clients flush the message and render the recalled
placeholder. Zentao #16, #2.
2026-09-18 17:42:09 +08:00
richjohnrambo 8b20118570 feat: enforce youth mode policy and improve search 2026-09-18 14:51:40 +08:00
richjohnrambo 9482bb19f6 fix: wake app for call pushes 2026-09-18 13:51:22 +08:00
richjohnrambo 153f541078 fix: add call push category 2026-09-18 13:38:57 +08:00
richjohnrambo 192bdede33 fix: route call accept across pods 2026-09-18 11:15:19 +08:00
Austin 4c809129eb Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev 2026-09-18 10:56:00 +08:00
richjohnrambo 3a92d81fca feat: enforce friend add and curfew policies 2026-09-18 10:55:53 +08:00
Austin 7a52db3437 fix: auto-provision im_user_privacy_settings columns on startup
Zentao #24/#25: commit 50f8581 added friend_request_verification and group_invite_verification to userPrivacyByUID, but existing databases lack those columns, so GetPrivacy failed with Error 1054 and every cross-user profile/qrcodes/friend-request endpoint returned 500.

Add user_privacy_schema.go which, under GET_LOCK, checks information_schema and ALTERs in any missing privacy columns, wired into the root adapter Open() and per-tenant TenantAdapterFor so tenant-scoped databases are provisioned too.
2026-09-18 10:55:13 +08:00
richjohnrambo c6e7f6b923 feat: improve group member events and push delivery 2026-09-18 01:00:41 +08:00
richjohnrambo 0e2f4e73d1 refactor: remove workbench code from im server 2026-09-17 23:38:44 +08:00
richjohnrambo 69a777c81f feat: apply configured default user avatars 2026-09-17 21:36:19 +08:00
richjohnrambo b9aaccc830 fix: update uid encryption key defaults 2026-09-17 16:59:47 +08:00
richjohnrambo ac75caba86 fix: load uid key from manager config 2026-09-17 16:30:47 +08:00
richjohnrambo c8d9146604 fix: include username in me endpoint 2026-09-17 15:23:47 +08:00
richjohnrambo ed976c76ff docs: update im integration and schema notes 2026-09-17 14:59:28 +08:00
richjohnrambo 50f8581667 fix: resolve im backend issue list regressions 2026-09-17 14:59:16 +08:00
richjohnrambo 760de35e10 fix: allow username-only registration without invite 2026-09-17 13:45:57 +08:00
richjohnrambo 8279ae577f fix: decouple meeting note upload binding 2026-09-17 13:38:55 +08:00
richjohnrambo 734e042af6 feat: expose account fields in user profiles 2026-09-17 13:37:34 +08:00
richjohnrambo 816be46e03 fix: fan out unread presence to online sessions 2026-09-17 10:24:39 +08:00
richjohnrambo 7df3313c78 fix: shorten session heartbeat interval to 15s 2026-09-17 00:16:14 +08:00
richjohnrambo 0f8e3b82b7 feat: enforce tenant content moderation 2026-09-16 22:21:25 +08:00
richjohnrambo 0ae4af0c31 fix: fan out receipts and align tenant schema 2026-09-16 22:09:52 +08:00
richjohnrambo 6729f40665 fix: sync topic sequence after server data 2026-09-16 16:21:53 +08:00
richjohnrambo 7461216c03 feat: merge registration invite and verification input 2026-09-16 14:29:59 +08:00
richjohnrambo 370e9878ed fix: notify p2p message mutation on user channel 2026-09-16 13:49:33 +08:00
richjohnrambo fa95dd72af fix: 后台登录审计查询按字符串返回用户ID 2026-09-16 13:39:50 +08:00
richjohnrambo 1038d25abd fix: 后台消息查询按字符串返回用户ID 2026-09-16 13:24:02 +08:00
Austin a01cdae971 fix(realtime): evict fanout 增加时效校验,防止延迟重放误踢新登录
deliverRealtimeSessionControl 分发前校验 Ctrl.Timestamp,超过 10s 的 session-control(evict)消息直接丢弃并记 skip_stale 指标,避免 consumer 重启重放 PEL / stream 积压时旧消息的 skipSid 与新会话不符导致新登录被误踢。无时间戳消息放行以兼容旧发布方。

验证:TestRealtime 3/3、regression_kick_notification 14/14、regression_mobile_kick 21/21。
2026-09-16 13:22:47 +08:00
richjohnrambo c0ab94723c fix: 后台用户查询按字符串返回用户ID 2026-09-16 13:10:37 +08:00
Austin 846615db69 Merge branch 'dev' of https://git.118im.com/im_team/im_server into dev 2026-09-16 10:23:33 +08:00
Austin 4280b535c2 feat(session): 实现同平台抢登互踢功能,优化会话踢下线逻辑
新增同平台会话踢下线能力,将iOS/Android/mobile归为同一移动分组,web仅匹配web;扩展会话存储接口支持按平台、带原因踢除会话,同步更新集群广播逻辑与各调用点,统一传递踢下线原因参数
2026-09-16 10:23:27 +08:00
richjohnrambo 68b196215f fix: keep upload urls unchanged 2026-09-16 09:56:15 +08:00
richjohnrambo 1de69fcc5b fix: force file urls to https 2026-09-16 09:52:55 +08:00
richjohnrambo 9626e8ee7c fix: invalidate group settings cache on membership changes 2026-09-16 01:03:40 +08:00
richjohnrambo 139681fadb feat: shard group messages by month 2026-09-15 23:53:51 +08:00
richjohnrambo b7c2536527 fix: persist group join system notices 2026-09-15 19:20:07 +08:00
Austin dbb0a06db0 fix: 修复P2P主题别名订阅的兼容性问题
1. 订阅重复检查新增Original与RcptTo匹配判断,避免别名订阅误判已订阅
2. 重构订阅get查询处理逻辑,提取为独立函数
3. 针对P2P别名订阅场景返回200响应而非304,帮助客户端完成别名到标准主题名的重命名,避免发布失败
4. 优化群摘要接口的权限校验和异常处理逻辑
2026-09-15 18:53:49 +08:00
richjohnrambo 563ae9ee5e fix: fanout p2p system notices to users 2026-09-15 17:58:55 +08:00
richjohnrambo e29b5d3589 test: cover blocked profile visibility 2026-09-15 16:02:36 +08:00
richjohnrambo 152e2402fa fix: allow members to query own group mute 2026-09-15 15:55:13 +08:00
richjohnrambo bc27a5c574 feat: add message mutation sync 2026-09-15 15:54:52 +08:00
Austin 55afd976c9 fix(server): 修复群组权限缓存与解散群问题
1. 新增group事件处理逻辑,实时刷新内存中的群组权限与状态
2. 加载topic时过滤已解散的群组,防止被旧成员恢复
3. 补充群成员离开事件的用户ID字段,完善事件处理
2026-09-15 15:18:14 +08:00
Austin 5dd0a42002 fix: harden message seq allocation against stale redis replicas
- use sentinel failover client for group message runtime when
  sentinel_master_name/sentinel_addrs are configured, so seq counter
  reads never hit a stale replica after failover
- re-seed missing redis seq counter from DB topic seqid instead of
  trusting the caller in-memory baseline
- on unique-key conflict during async persist, re-allocate seq from DB
  latest, bump redis counter and fix the idempotency record so resends
  with the same client mid return the final seq
- add uk(topic, seqid) unique keys for im_messages in tenant DDL
2026-09-15 12:41:38 +08:00
richjohnrambo 4f6a996922 feat: support batch message recall 2026-09-15 10:46:07 +08:00
richjohnrambo 9ec5da5b02 fix: persist login sessions for workbench auth 2026-09-15 10:23:47 +08:00
richjohnrambo ef8091d805 fix: reject friend request to missing user 2026-09-15 00:48:36 +08:00
richjohnrambo 15abc1b6e8 fix: bake test middleware config 2026-09-14 23:59:35 +08:00
richjohnrambo 0cf2fb61a7 fix: restore docker middleware env config 2026-09-14 23:52:00 +08:00
richjohnrambo 5384a8fa7b fix: avoid profile default hot path writes 2026-09-14 23:34:22 +08:00
richjohnrambo fca0a5baf9 fix: keep friend request success when event publish fails 2026-09-14 23:29:33 +08:00
richjohnrambo 40510a616e fix: bake test middleware config into image 2026-09-14 23:28:07 +08:00
richjohnrambo 7196d0c839 fix: include jpush handler in docker config 2026-09-14 21:09:28 +08:00
richjohnrambo 79babdb6aa fix: scope realtime fanout dedup per node 2026-09-14 20:15:22 +08:00
kk123kk123 6291fa2b60 feat(群组): 添加群组解散状态支持与过滤逻辑
新增群组解散状态常量,在群组列表和详情接口中增加解散群组的过滤和报错处理,避免返回已解散的群组数据
2026-09-14 20:06:41 +08:00
richjohnrambo 275c9e5fcd fix: avoid auth version hot path writes 2026-09-14 19:04:36 +08:00
richjohnrambo 02eddb6010 fix: accept jpush token metadata fields 2026-09-14 17:34:12 +08:00
richjohnrambo 2581d7b8f0 fix: support account creation on pxc 2026-09-14 14:26:29 +08:00
richjohnrambo 8f849efc25 chore: point test config to new middleware 2026-09-14 13:58:08 +08:00
richjohnrambo 1c7417b8f3 fix: support legacy auto ids during registration 2026-09-14 12:35:00 +08:00
richjohnrambo 6f8933c35a fix: avoid main call timeout dependency 2026-09-14 12:18:05 +08:00
richjohnrambo 789e662bdb fix: expire stale call pushes 2026-09-14 12:11:43 +08:00
richjohnrambo 4854e090ef fix: harden group lifecycle and registration writes 2026-09-14 12:04:10 +08:00
Your NameandClaude Sonnet 5 990526647c ci: retarget deploy workflow at the k3s test cluster
Old VM's blue-green im-server is decommissioned. App config
(MySQL/Redis/API key/admin-internal secrets) now lives in the
cluster's own Deployment manifest, not injected through CI --
this workflow's job is just build (from source, via
docker/tinode/Dockerfile.build as before), push under :test,
and roll the deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:41:03 +08:00
richjohnrambo 67e8c2c6f6 feat: add workbench kafka migration support 2026-09-13 22:17:02 +08:00
richjohnrambo c46cb4b382 fix: join group directly when invitation needs no approval 2026-09-13 17:21:21 +08:00
richjohnrambo 7b304412b0 fix: repair group management edge cases 2026-09-13 16:04:31 +08:00
richjohnrambo a9afc6ff6a fix: align group management APIs 2026-09-13 16:03:55 +08:00
richjohnrambo f1279ac607 fix: preserve p2p topic for IM system notices 2026-09-13 16:00:58 +08:00
richjohnrambo ccbd385d2f chore: disable runtime mysql schema creation 2026-09-13 13:56:24 +08:00
richjohnrambo eefc3c2fbd fix: add group mute all system notice 2026-09-13 13:12:42 +08:00
richjohnrambo d107fba8bf fix: align IM client interaction edge cases 2026-09-13 10:06:52 +08:00
richjohnrambo e5fbca735d feat: centralize app release configuration 2026-09-12 17:07:49 +08:00
richjohnrambo 2728f35069 feat: expose public legal agreement pages 2026-09-12 14:18:24 +08:00
richjohnrambo ff432bc3ce fix: stabilize me subscription sync metadata 2026-09-12 14:09:43 +08:00
richjohnrambo ca3957e3b3 feat: support group invite privacy verification 2026-09-12 11:20:48 +08:00
richjohnrambo 80c802af20 add nickname to group member payloads 2026-09-12 10:06:28 +08:00
richjohnrambo a9a4970d9f fix p2p topic canonicalization and stale subscriptions 2026-09-12 00:08:30 +08:00
richjohnrambo f1dfffcfb5 include join request user profiles 2026-09-11 18:04:01 +08:00
richjohnrambo 1d433971d4 fix group invitation notifications 2026-09-11 17:34:16 +08:00
richjohnrambo cfab33f45a fix: link group invitations to join requests 2026-09-11 17:26:05 +08:00
richjohnrambo 367bed2940 fix im workbench and profile regressions 2026-09-11 17:25:24 +08:00
richjohnrambo 1c2132eebc fix: omit invalid jpush cid 2026-09-11 11:57:41 +08:00
richjohnrambo bafaf2d7b9 fix: normalize jpush aliases 2026-09-11 11:43:48 +08:00
richjohnrambo 05f4087240 fix: deliver friendship system notices live 2026-09-11 11:01:37 +08:00
richjohnrambo b9dc56eccf feat: auto-friend invite bound users 2026-09-11 10:46:20 +08:00
richjohnrambo 926b4bbd81 fix im forwarding and message list regressions 2026-09-11 09:00:38 +08:00
richjohnrambo 75d1aabcb0 fix group member display names 2026-09-10 21:57:25 +08:00
richjohnrambo 928a45ecfb feat: assign IM user account numbers 2026-09-10 21:51:23 +08:00
richjohnrambo 51fee50343 fix im profile display name remark priority 2026-09-10 16:01:00 +08:00
richjohnrambo 1f3a98605e fix im heartbeat and friend display name 2026-09-10 15:23:27 +08:00
richjohnrambo 14ee3d937f fix: order workbench modules 2026-09-10 15:19:29 +08:00
richjohnrambo 085a48c3d9 feat: publish text message for group announcements 2026-09-10 10:27:38 +08:00
richjohnrambo 6b2f1e06e0 feat: implement precise token logout 2026-09-10 09:43:02 +08:00
richjohnrambo 98234db263 fix: 兼容历史收藏源避免列表崩溃 2026-09-09 17:40:11 +08:00
richjohnrambo e0a6ee27bc fix: include api key in tenant websocket endpoint 2026-09-09 17:32:49 +08:00
richjohnrambo 23afc1b7f1 fix: 修复文件预览重定向与扩展名解析 2026-09-09 16:32:49 +08:00
richjohnrambo cae5039afe fix: verify OSS objects before serving 2026-09-09 16:28:56 +08:00
richjohnrambo c3e5cc0831 fix: reject dangling file attachment links 2026-09-09 15:55:18 +08:00
richjohnrambo bef158941a fix: validate group avatar attachments 2026-09-09 15:48:55 +08:00
richjohnrambo be61ec2b71 fix: backfill user public profile projections 2026-09-09 15:34:20 +08:00
richjohnrambo 45c6c7eacb fix: avoid admin dependency on login entrypoints 2026-09-09 15:19:13 +08:00
richjohnrambo 9159ec7fc7 feat: add IM service health and metrics endpoints 2026-09-09 14:24:40 +08:00
richjohnrambo 4c9bdd64e0 fix: 修复OSS头像预签名下载 2026-09-09 14:08:01 +08:00
richjohnrambo c43c099c5e fix: 兼容历史头像文件下载 2026-09-09 13:24:36 +08:00
richjohnrambo 92a72d1bea feat: enforce tenant login IP whitelists 2026-09-09 09:40:37 +08:00
richjohnrambo 9ac2b412cc fix: 优化群成员分页并补齐头像资料 2026-09-09 09:38:20 +08:00
richjohnrambo aae13c9265 feat: load oss media config from sys config 2026-09-08 22:19:00 +08:00
richjohnrambo 0f984b0ab4 Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 16:29:05 +08:00
richjohnrambo 0b3c0b1c78 fix: resolve P1 backend consistency issues 2026-09-08 16:23:21 +08:00
richjohnrambo 034678fa53 fix: 兼容查询用户头像数据 2026-09-08 15:53:56 +08:00
richjohnrambo 06945a4050 fix: 统一用户资料投影并放开头像访问 2026-09-08 15:26:05 +08:00
richjohnrambo 62e989487b Backfill group file metadata on read 2026-09-08 14:17:10 +08:00
richjohnrambo 20c0b92d77 Fix group file metadata and access 2026-09-08 13:50:04 +08:00
richjohnrambo 8116fb6fe8 feat: 完善用户治理与登录白名单 2026-09-08 10:55:26 +08:00
richjohnrambo d239bd9ad0 fix: expose friend online status 2026-09-08 10:35:42 +08:00
richjohnrambo b295170eee Accept numeric favorite sequence 2026-09-08 10:26:30 +08:00
richjohnrambo 402dfb0d5a feat: 增加群聊消息查询条件 2026-09-07 21:52:55 +08:00
richjohnrambo 8ff9160113 Customize forward bundle titles 2026-09-07 19:57:36 +08:00
richjohnrambo 8a9e202b56 Preserve uploaded file extensions 2026-09-07 16:13:20 +08:00
richjohnrambo 35da3025a3 fix: 恢复IM内部接口默认启动配置 2026-09-07 16:02:56 +08:00
richjohnrambo 654cd93836 Fix meeting note detail payload 2026-09-07 15:13:44 +08:00
richjohnrambo 7e8fc8237b fix im p0 backend issues 2026-09-07 15:12:14 +08:00
richjohnrambo b92154bece ci: stop overriding Kafka deploy environment 2026-09-07 13:53:50 +08:00
richjohnrambo c68c5ac038 fix: 补齐IM治理功能启动配置 2026-09-07 12:06:46 +08:00
richjohnrambo 6b20a633ce feat: 接入后台消息治理与登录审计 2026-09-07 11:53:24 +08:00
richjohnrambo 90185b6c88 Support meeting note upload registration 2026-09-07 11:41:01 +08:00
richjohnrambo 160d33c759 Reduce Kafka publish latency for messages 2026-09-07 11:33:50 +08:00
richjohnrambo a56efd4454 Add sender summaries to forward bundles 2026-09-07 10:39:42 +08:00
richjohnrambo 6330c592cf Fix media URL extension for m4a uploads 2026-09-07 09:57:16 +08:00
richjohnrambo 7a1647bf17 Fix bootstrap meeting unread counts 2026-09-05 18:31:13 +08:00
richjohnrambo baf15848d3 feat: add meeting notes backend 2026-09-05 17:55:27 +08:00
richjohnrambo 9e290b76b3 feat: add jpush push handler 2026-09-05 17:06:59 +08:00
richjohnrambo efbfb7556a Fix P2P message duplicate seq retry 2026-09-04 20:15:25 +08:00
richjohnrambo b4d3fbe359 Clear stale unanswered calls before new invite 2026-09-04 18:16:26 +08:00
richjohnrambo 4ad6ca00b4 Prevent Redis message seq regression 2026-09-04 16:39:32 +08:00
richjohnrambo e6f2cbf36e Fix user lookup on unscoped tenant database 2026-09-04 16:34:11 +08:00
richjohnrambo 50ccdf5f89 Fix profile display backfill startup 2026-09-04 16:20:28 +08:00
richjohnrambo 7fb1ea70bc Sync profile source and message topic seq 2026-09-04 16:06:57 +08:00
richjohnrambo 47649d33e2 Fix friends display name fallback 2026-09-04 13:45:18 +08:00
richjohnrambo 0f1c1f9255 fix: enable local kafka docker integration 2026-09-04 11:57:38 +08:00
richjohnrambo a191ca0c93 Add group announcement edit permissions 2026-09-04 11:09:29 +08:00
richjohnrambo 62af772260 Read user profile display from profile table 2026-09-04 10:49:33 +08:00
richjohnrambo 3173313ab9 Refresh presence on websocket pong 2026-09-04 10:49:33 +08:00
Your NameandClaude Sonnet 5 9da072e6f9 ci: 部署时透传 admin_internal_api / hotgo secret
commit 7193ba2 把 config.template 里的 admin_internal_api.base_url/secret
和 internal_api hotgo-admin secret 从硬编码改成了环境变量占位符,但
deploy workflow 没有跟着传这几个 secret,导致 Fatal 退出、容器反复重启
(表现为 im-server-green 起不来)。这几个 secret 已经在 Gitea 里配好。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:36:44 +08:00
richjohnrambo c1bfa503cf feat: add client call feature switches 2026-09-03 21:05:33 +08:00
richjohnrambo d6de2a0349 Notify P2P participants after HTTP forwarding 2026-09-03 20:04:18 +08:00
richjohnrambo e36decab28 Fix topic read actions for user topics 2026-09-03 19:55:44 +08:00
richjohnrambo 7193ba2316 fix: 完善后台组织内部查询 2026-09-03 17:11:36 +08:00
richjohnrambo db00e994d5 fix: keep group avatar update for legacy groups 2026-09-03 14:25:10 +08:00
richjohnrambo a0bd0b0649 Fix Kafka message seq idempotency 2026-09-03 13:21:26 +08:00
richjohnrambo 256f768d15 fix: allow group avatar uploads 2026-09-03 11:52:06 +08:00
richjohnrambo 871e0ba735 Fix conversation context topic fallback 2026-09-03 11:47:45 +08:00
richjohnrambo 2e4e1f0df3 Fix HTTP message forward delivery 2026-09-03 11:11:48 +08:00
richjohnrambo 54e58763ff Improve message forward compatibility 2026-09-03 10:37:42 +08:00
richjohnrambo 1eee0179f1 fix: return saved group nickname profile 2026-09-03 10:01:24 +08:00
richjohnrambo 8193491686 fix: persist group nickname for legacy groups 2026-09-03 09:58:18 +08:00
richjohnrambo a94f60c254 Add youth mode restriction labels 2026-09-02 13:47:12 +08:00
richjohnrambo 79947a2c6f Improve youth mode action token flow 2026-09-02 11:25:25 +08:00
richjohnrambo 7f6995d8fe Disable sensitive challenge rate limit for test 2026-09-02 10:51:15 +08:00
richjohnrambo ac45223e39 Fix IM report and group permission regressions 2026-09-02 10:01:38 +08:00
richjohnrambo 15a4174ed1 fix: support agora rtc join delivery flow 2026-09-02 01:42:19 +08:00
richjohnrambo dc5e09f2ae feat: add agora rtc token join endpoint 2026-09-02 01:05:15 +08:00
richjohnrambo 318ce04f7d Refine Kafka persistence topic boundaries 2026-09-01 23:23:08 +08:00
richjohnrambo f8a286cec2 fix: reuse active security challenge before rate limit 2026-09-01 23:00:14 +08:00
richjohnrambo 929f847003 Refactor Kafka publishing without DB outbox 2026-09-01 23:00:14 +08:00
richjohnrambo 18237cf3ec docs: add agora call integration flow 2026-09-01 19:01:37 +08:00
richjohnrambo edced2675b feat: add conversation system action messages 2026-09-01 13:12:24 +08:00
richjohnrambo 39e7e019c7 fix: scan attendance appeal idempotency key 2026-08-31 14:36:30 +08:00
richjohnrambo 6c6aa9f4c1 fix: scan attendance shifts from mysql 2026-08-31 14:10:34 +08:00
richjohnrambo 44ad626f63 chore: log attendance API failures 2026-08-31 13:54:32 +08:00
richjohnrambo 61ab313f35 fix: ensure attendance schema for tenant routing 2026-08-31 13:44:47 +08:00
richjohnrambo 79630c26f5 fix: compile attendance appeal insert 2026-08-31 13:30:44 +08:00
richjohnrambo cb7dc36cad feat: add attendance APIs 2026-08-31 13:27:12 +08:00
richjohnrambo 9f647de4d0 feat: switch client API branding to MeetingIM 2026-08-31 01:22:53 +08:00
richjohnrambo c6a243ee59 Add meeting display fields 2026-08-29 19:28:10 +08:00
richjohnrambo 866222cb39 Fix missing risk challenge response 2026-08-29 10:59:50 +08:00
richjohnrambo 42916b85aa Fix meeting list and media search issues 2026-08-29 09:46:50 +08:00
richjohnrambo 78e6308174 fix im business event delivery 2026-08-28 16:24:57 +08:00
richjohnrambo 9a46fdf43c feat: handle password reset admin command 2026-08-28 14:18:27 +08:00
richjohnrambo d756aeb4c2 fix: serialize internal user ids as strings 2026-08-28 13:07:17 +08:00
richjohnrambo 5987f2d87a fix: return not found for missing internal user 2026-08-28 13:05:26 +08:00
richjohnrambo 08a4f138e3 fix: skip nonce replay check for internal reads 2026-08-28 11:28:28 +08:00
richjohnrambo 4ea828fb09 fix: tolerate retried internal api nonce 2026-08-28 10:43:01 +08:00
richjohnrambo ba06ee3472 chore: use internal admin service url 2026-08-28 01:08:51 +08:00
richjohnrambo a54ddfa548 chore: use public admin internal api 2026-08-28 00:48:42 +08:00
richjohnrambo 94552a84b4 chore: configure internal api auth 2026-08-28 00:40:40 +08:00
richjohnrambo 84439d63cb chore: configure test kafka 2026-08-28 00:35:10 +08:00
richjohnrambo ca734a48ab feat: support kafka scram auth 2026-08-28 00:31:08 +08:00
richjohnrambo 5cc6eebc1a feat: add kafka admin integration 2026-08-28 00:05:54 +08:00
richjohnrambo b2e5097708 feat: add daily report list preview 2026-08-27 10:08:03 +08:00
richjohnrambo a0fc553c6f fix: improve conversation and report stability 2026-08-26 14:12:58 +08:00
richjohnrambo dc80aa5104 fix: stabilize mysql message reads 2026-08-26 00:03:16 +08:00
richjohnrambo 58cce46dee fix: restore tenant id on cached store objects 2026-08-25 23:20:19 +08:00
richjohnrambo eac2bc2a47 fix: load verification challenges from db 2026-08-25 22:35:09 +08:00
richjohnrambo c020171e52 fix: restore mysql docker build 2026-08-25 22:17:59 +08:00
richjohnrambo f900a08a01 feat: add redis cache and fanout support 2026-08-25 20:54:03 +08:00
richjohnrambo 07376c70e3 fix: stabilize im server startup and p2p topic scans 2026-08-24 16:20:02 +08:00
richjohnrambo e61058ec03 feat: add tenant entry registration APIs 2026-08-24 15:08:47 +08:00
richjohnrambo ed9413d923 feat: add file safety config integration 2026-08-23 23:50:21 +08:00
richjohnrambo 7b942da975 feat: add aliyun oss media handler 2026-08-23 23:20:33 +08:00
richjohnrambo a578acebf7 fix: handle nullable recalled_by in mysql messages 2026-08-23 23:08:56 +08:00
richjohnrambo 641697e755 docs: update IM integration contract examples 2026-08-23 23:05:45 +08:00
richjohnrambo d14942b1a1 fix: preserve group member user ids 2026-08-23 22:29:22 +08:00
richjohnrambo c89c466387 feat: implement first batch im enhancements 2026-08-21 11:42:45 +08:00
richjohnrambo 14abe58b88 test: add websocket protocol audit 2026-08-21 00:42:54 +08:00
richjohnrambo ff616b9c5d fix: align favorite batch endpoint 2026-08-20 23:29:10 +08:00
richjohnrambo 2b3480fa5f fix: stabilize test environment IM endpoints 2026-08-20 23:06:54 +08:00
richjohnrambo 6c6d2f6d3b fix: stabilize im audit endpoints 2026-08-20 22:34:36 +08:00
richjohnrambo 3e1ce5df26 feat: allow viewing user profile QR codes 2026-08-20 17:54:12 +08:00
richjohnrambo 6db2938672 feat: add bootstrap entry badges 2026-08-20 15:59:16 +08:00
richjohnrambo d467323ecb docs: clarify test registration code 2026-08-20 14:10:05 +08:00
richjohnrambo c09ea2ac53 fix: stabilize container validator startup 2026-08-20 13:28:28 +08:00
richjohnrambo 43e8af68d6 fix: adjust phone registration username flow 2026-08-20 12:08:55 +08:00
richjohnrambo 73ddb2d9fa fix: improve IM user account search 2026-08-19 17:23:36 +08:00
richjohnrambo b3d18f9d8d Merge remote-tracking branch 'origin/dev' into dev 2026-08-19 17:02:34 +08:00
richjohnrambo 405dad2eef fix: use fixed code for registration verification 2026-08-19 16:52:20 +08:00
richjohnrambo 4e75cfc65e fix: restore verification target user ids 2026-08-19 01:17:09 +08:00
richjohnrambo 219ecadf4c feat: complete registration invitation and auth redesign 2026-08-19 00:30:11 +08:00
richjohnrambo e063e7879e fix: canonicalize phone numbers as E.164 2026-08-18 01:23:18 +08:00
richjohnrambo bf562c7d56 feat: migrate IM identity and snowflake primary keys 2026-08-18 01:15:21 +08:00
richjohnrambo db322d80dd docs: sync IM integration guide with current behavior 2026-08-17 15:28:00 +08:00
richjohnrambo b116adc6a1 docs: clarify IM registration account payload 2026-08-17 15:23:13 +08:00
richjohnrambo c60ed2e9ed fix: page friend list by peer id 2026-08-17 15:20:02 +08:00
richjohnrambo d10c05b030 fix: decode bearer tokens for IM HTTP 2026-08-17 15:08:32 +08:00
richjohnrambo b7a5d5cb17 fix: align message revision schema with tenant databases 2026-08-17 14:54:50 +08:00
richjohnrambo e14dec9af7 fix: keep routed tenants on scoped mysql adapter 2026-08-17 14:49:23 +08:00
richjohnrambo 47968e39fa fix: use tenant database auth schema 2026-08-17 14:43:17 +08:00
richjohnrambo 4ec84f961b fix: allow phone number as basic login 2026-08-17 14:28:24 +08:00
richjohnrambo cf590a1c78 chore: point tinode db config to remote mysql 2026-08-17 13:58:09 +08:00
richjohnrambo f2c229135b feat: complete IM P0 and P1 reliability work 2026-08-17 13:43:27 +08:00
richjohnrambo ddf0f7a55e feat: persist group business state in tables 2026-08-17 09:50:31 +08:00
richjohnrambo d8d1639d5b fix mysql tenant database schema cleanup 2026-08-15 15:48:17 +08:00
richjohnrambo 672fc68cfa Merge remote-tracking branch 'origin/dev' into dev 2026-08-15 15:07:04 +08:00
richjohnrambo 7888c87634 im 服务端 2026-08-15 15:06:06 +08:00
Your Name a7c5bc9bbd 修复登录500: auth/credentials表缺少tenant_id列
登录时报 Error 1054: Unknown column 'tenant_id' in 'where clause'。

根因:CreateDb里auth/credentials的建表语句从未定义过tenant_id列——这不是
版本跳过导致的迁移遗漏,而是即便全新安装也会触发的建表语句本身的缺陷,
因为查询代码(AuthAddRecord/AuthGetUniqueRecord等)一直在无条件地按
tenant_id过滤/写入这两张表。

同时migration脚本里的dropTenantIDColumns此前会把auth/credentials的
tenant_id也当成"新增多租户特性表的遗留列"一并删掉,跟前面加的列直接打架。

修复:
- adapter.go: CreateDb的auth/credentials建表语句补上tenant_id列,
  修正未来全新安装的根源问题
- apply_hotgo_tenant_db.go: 补上ALTER TABLE ADD COLUMN(现有数据回填到
  默认租户id=1);dropTenantIDColumns排除auth/credentials,不再误删
2026-08-15 14:35:02 +08:00
Your Name 76ca6147ec 修复{hi}握手拒绝未携带tenant字段的客户端
多租户改造给{hi}握手消息加了必填的tenant字段,但目前的im_webapp(以及
现有iOS/Android客户端)都是标准Tinode SDK,不知道要发这个新字段,导致
每次连接握手都被判定为malformed(400),前端表现为反复断线重连。

改为tenant为空时回退到默认租户"default"(对应迁移脚本里seed的那条记录),
不再直接拒绝;显式携带tenant code的客户端行为不受影响,仅长度校验保留。
2026-08-15 14:25:59 +08:00
Your Name b01ed29751 修复迁移脚本: 补全CreateDb版本跳过导致缺失的表
CreateDb的多个建表函数(createTenantRegistry/createIMSecurityTables/
createIMNotificationTables/createIMRelationTables)只在全新库的建表路径里
调用;UpgradeDb对已存在且版本号已达标的库直接判定"无需升级"直接跳过,
不会补建任何表。我们这个tinode库版本号刚好已经等于目标版本,所以这几个
函数从未真正执行过。

补上迁移脚本里缺失的表(与adapter.go里的建表语句保持一致):
tinode.im_tenant / im_tenant_config (原脚本假设已存在,直接ALTER导致报错)
tinode.im_realname_verifications / im_action_tokens
tinode.im_system_notifications
tinode.im_friend_requests / im_friendships / im_user_blocks / im_favorites
2026-08-15 14:17:53 +08:00
Your Name 57274778b5 接入Gitea Actions蓝绿部署:本地源码构建替代release下载,接入租户注册表迁移
- 新增docker/tinode/Dockerfile.build:从本仓库源码编译server/tinode-db/
  apply_hotgo_tenant_db,替代原Dockerfile下载GitHub release的方式(否则
  workbench/组织架构/会议等改造代码永远不会真正跑起来)
- config.template补上连接池设置(max_open_conns等,此前im_server踩过注册卡死
  的坑),并启用tenant_db_routing+tenant_registry_db=hotgo,对应本仓库自带
  tinode.conf里的默认约定,单租户场景下会透明落回同一个tinode库
- entrypoint.sh在init-db之后追加执行apply-hotgo-tenant-db,幂等地建好/
  升级hotgo租户注册表及tinode库里的workbench/组织架构/会议/日报/信箱等新表
- 新增.gitea/workflows/deploy.yml,复用服务器上已有的蓝绿部署脚本
2026-08-15 14:08:07 +08:00
richjohnrambo 5af396c5f5 im 服务端 2026-08-15 01:33:23 +08:00
richjohnrambo 7c360167b3 单元测试 2026-08-14 23:46:28 +08:00
richjohnrambo 04c7a148e1 单元测试 2026-08-14 23:37:31 +08:00
richjohnrambo 170bbdca76 feat: add safety reporting APIs 2026-08-14 00:01:54 +08:00
richjohnrambo fa75c0f0f9 feat: add workbench and client settings APIs 2026-08-13 23:43:24 +08:00
richjohnrambo dfcccbb761 feat: add chapter eight call records 2026-08-13 16:14:57 +08:00
richjohnrambo 92d0066a02 feat: support tenant database routing 2026-08-13 15:07:06 +08:00
richjohnrambo 31f82d9c41 feat: add im tenant db routing and chapter seven flows 2026-08-13 11:30:48 +08:00
richjohnrambo 774a448c12 feat: add im group mention flows 2026-08-12 15:33:56 +08:00
richjohnrambo 943ebe1397 feat: complete im p2p relationship onboarding 2026-08-12 15:20:15 +08:00
richjohnrambo d02041659e feat: add im relationship flows 2026-08-12 11:31:16 +08:00
richjohnrambo 5bd35f97a6 feat: add im home and message action flows 2026-08-12 10:36:41 +08:00
richjohnrambo d239e37f56 feat: add im auth verification flows 2026-08-12 00:20:27 +08:00
richjohnramboandCursor 1ffcb85bee docs: move IM-WS-API-CN to obsolete, remove IM-WS-TECH-DESIGN-CN
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:08:07 +08:00
richjohnrambo c2076721fc 多租户更改 2026-08-11 15:59:57 +08:00
richjohnrambo 6c800de67d im 服务端 2026-08-11 10:34:06 +08:00
richjohnrambo 8bda22246c chore: update dependency checksums 2026-08-11 10:29:55 +08:00
richjohnrambo 5aa7733515 chore: sync project files 2026-08-11 10:29:38 +08:00
richjohnrambo c9d6a6767f chore: 添加 gitignore(Go + IDE) 2026-08-11 10:10:16 +08:00
richjohnrambo 0f019b331a Initial commit 2026-08-11 01:46:53 +00:00
960 changed files with 214220 additions and 23 deletions
View File
+118
View File
@@ -0,0 +1,118 @@
name: Deploy im-server (k3s test cluster)
on:
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
jobs:
deploy:
runs-on: deploy-host
steps:
- uses: actions/checkout@v4
- name: 确定镜像tag
id: vars
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 构建并推送镜像(从本仓库源码编译,不是官方预编译包)
run: |
docker build -f docker/tinode/Dockerfile.build \
-t git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }}
- name: 部署到k3s测试集群
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl set resources statefulset/im-server -n im-server-system -c im-server \
--requests=cpu=2,memory=512Mi --limits=cpu=2,memory=1Gi
kubectl set env statefulset/im-server -n im-server-system -c im-server \
CLUSTER_PROXY_TO_MASTER_BUFFER=4096 \
CLUSTER_PROXY_TO_MASTER_WORKERS=16 \
CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS=300 \
CLUSTER_ROUTE_RETRIES=3 \
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25 \
MYSQL_MAX_OPEN_CONNS=24 \
MYSQL_MAX_IDLE_CONNS=8 \
MYSQL_REGISTRY_MAX_OPEN_CONNS=4 \
MYSQL_REGISTRY_MAX_IDLE_CONNS=2 \
MYSQL_CONN_MAX_LIFETIME_SECONDS=1800
kubectl patch statefulset/im-server -n im-server-system --type=strategic -p \
'{"spec":{"template":{"spec":{"containers":[{"name":"im-server","readinessProbe":{"httpGet":{"path":"/healthz","port":6060},"timeoutSeconds":3,"periodSeconds":10,"failureThreshold":3},"livenessProbe":{"httpGet":{"path":"/livez","port":6060},"timeoutSeconds":3,"periodSeconds":20,"failureThreshold":3}}]}}}}'
if kubectl auth can-i get services -n im-server-system >/dev/null 2>&1 && \
kubectl auth can-i patch services -n im-server-system >/dev/null 2>&1; then
kubectl patch service/im-server -n im-server-system --type=merge -p \
'{"spec":{"sessionAffinity":"None"}}'
else
echo "::warning::ci-deployer cannot get/patch Service; skipped sessionAffinity update"
fi
if kubectl auth can-i get ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
kubectl auth can-i patch ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
kubectl get ingress/im-server -n im-server-system >/dev/null 2>&1; then
kubectl annotate ingress/im-server -n im-server-system --overwrite \
nginx.ingress.kubernetes.io/load-balance=least_conn
else
echo "::warning::ci-deployer cannot manage Ingress/im-server or it does not exist; skipped least_conn update"
fi
kubectl rollout restart statefulset/im-server -n im-server-system
if ! kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s; then
echo "::error::im-server rollout did not become ready within 300 seconds"
kubectl get statefulset/im-server -n im-server-system -o wide || true
kubectl get pods -n im-server-system -l app=im-server -o wide || true
kubectl describe statefulset/im-server -n im-server-system || true
for pod in $(kubectl get pods -n im-server-system -l app=im-server -o name 2>/dev/null); do
echo "===== ${pod} describe ====="
kubectl describe -n im-server-system "${pod}" || true
echo "===== ${pod} current log ====="
kubectl logs -n im-server-system "${pod}" -c im-server --tail=200 || true
echo "===== ${pod} previous log ====="
kubectl logs -n im-server-system "${pod}" -c im-server --previous --tail=200 || true
done
if kubectl auth can-i list events -n im-server-system >/dev/null 2>&1; then
echo "===== recent events ====="
kubectl get events -n im-server-system --sort-by=.lastTimestamp | tail -100 || true
fi
exit 1
fi
echo "Waiting for ArgoCD reconciliation before validating effective settings"
sleep 60
request_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.cpu}')"
request_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.memory}')"
limit_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.cpu}')"
limit_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.memory}')"
liveness="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].livenessProbe.httpGet.path}')"
mysql_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_OPEN_CONNS")].value}')"
mysql_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_IDLE_CONNS")].value}')"
mysql_registry_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_OPEN_CONNS")].value}')"
mysql_registry_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_IDLE_CONNS")].value}')"
mysql_conn_max_lifetime="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_CONN_MAX_LIFETIME_SECONDS")].value}')"
echo "effective requests=${request_cpu}/${request_memory} limits=${limit_cpu}/${limit_memory} liveness=${liveness} mysql=${mysql_max_open}/${mysql_max_idle} registry=${mysql_registry_max_open}/${mysql_registry_max_idle} lifetime=${mysql_conn_max_lifetime}"
if [ "${request_cpu}" != "2" ] || [ "${request_memory}" != "512Mi" ] || \
[ "${limit_cpu}" != "2" ] || [ "${limit_memory}" != "1Gi" ] || \
[ "${liveness}" != "/livez" ] || [ "${mysql_max_open}" != "24" ] || \
[ "${mysql_max_idle}" != "8" ] || [ "${mysql_registry_max_open}" != "4" ] || \
[ "${mysql_registry_max_idle}" != "2" ] || [ "${mysql_conn_max_lifetime}" != "1800" ]; then
echo "::error::ArgoCD reverted StatefulSet resources/probes/MySQL pools. Apply deploy/argocd/im-server-test/statefulset-performance-patch.yaml to the im-server-test source."
exit 1
fi
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+17 -22
View File
@@ -1,27 +1,22 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
/.github/
/templ/
/templ/
# Test binary, built with `go test -c`
*.test
# macOS metadata.
.DS_Store
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# IDE workspace files.
.idea/
# Dependency directories (remove the comment below to include it)
# vendor/
# Trae workspace: AI test scripts, temp files, docs.
/.trae/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Local Go build output.
/server/server
/.codex-gocache/
/.github/
/.github/
/docs/third/三方服务依赖信息.md
/docs/test/im_third_party_file_safety_test_plan.md
docs/*.zip
@@ -0,0 +1,183 @@
# IM 前后端对接文档 1-11 章单元测试用例
本文基于 `IM-前后端对接文档.md` 与当前实现代码整理,默认按 `httptest` + mock store + fake auth/media/push store 的方式落单元测试。
## 0. 验收准备
### 0.1 基础环境
| [ ] 项目 | 准备项 | 说明 |
|---|---|---|
| [ ] ENV-01 | 测试租户 A/B | A、B 都是 `active`,且准备一个 `suspended` 租户用于负向验证 |
| [ ] ENV-02 | 统一 API Key | 使用测试 API Key,覆盖 WS/HTTP 两类入口 |
| [ ] ENV-03 | 固定时间 | 对 Token、验证码、重置票据、过期时间使用可冻结时钟 |
| [ ] ENV-04 | Mock 存储 | `store.Users`、`store.Tenants`、`store.Verifications`、`store.Security`、`store.Files`、`store.Store` 可注入 mock/fake |
| [ ] ENV-05 | 文件沙箱 | 文件下载/上传路径使用临时目录,避免污染真实对象存储 |
| [ ] ENV-06 | 多设备上下文 | 至少准备同一用户的两个 session,验证退出/踢出/读回执聚合 |
| [ ] ENV-07 | 负向数据 | 同名 `alice`、同手机号、同群名、同 tag、同 file id 的 A/B 对照数据 |
### 0.2 章节映射
| [ ] 章 | 主要入口/能力 | 代码证据 | 现有测试证据 |
|---|---|---|---|
| [ ] MAP-01 | 第1章 账号注册、登录与身份安全 | [server/main.go](/Users/superysh/im/im_server/server/main.go:780), [server/session.go](/Users/superysh/im/im_server/server/session.go:755), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19) | [server/session_test.go](/Users/superysh/im/im_server/server/session_test.go:38), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19), [server/auth/token/auth_token_test.go](/Users/superysh/im/im_server/server/auth/token/auth_token_test.go:1) |
| [ ] MAP-02 | 第2章 消息首页、全局导航与系统通知 | [server/main.go](/Users/superysh/im/im_server/server/main.go:791), [server/http.go](/Users/superysh/im/im_server/server/http.go:1) | 以 bootstrap / system notification handler 单测补齐 |
| [ ] MAP-03 | 第3章 单聊会话、会话建立与消息状态 | [server/main.go](/Users/superysh/im/im_server/server/main.go:821), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10), [server/topic_test.go](/Users/superysh/im/im_server/server/topic_test.go:1) |
| [ ] MAP-04 | 第4章 消息输入、媒体内容与消息操作 | [server/main.go](/Users/superysh/im/im_server/server/main.go:827), [server/datamodel.go](/Users/superysh/im/im_server/server/datamodel.go:1) | 需补 `pub/get/set/del/note` 行为单测 |
| [ ] MAP-05 | 第5章 通讯录、好友关系与黑名单 | [server/main.go](/Users/superysh/im/im_server/server/main.go:833), [server/main.go](/Users/superysh/im/im_server/server/main.go:835) | 需补 friend/block/qrcode 单测 |
| [ ] MAP-06 | 第6章 群聊创建、资料维护与成员治理 | [server/main.go](/Users/superysh/im/im_server/server/main.go:829), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10) |
| [ ] MAP-07 | 第7章 文件下载、收藏管理与聊天记录检索 | [server/main.go](/Users/superysh/im/im_server/server/main.go:824), [server/hdl_files.go](/Users/superysh/im/im_server/server/hdl_files.go:38) | [server/hdl_files_test.go](/Users/superysh/im/im_server/server/hdl_files_test.go:11), [server/media/fs/filesys_tenant_test.go](/Users/superysh/im/im_server/server/media/fs/filesys_tenant_test.go:11) |
| [ ] MAP-08 | 第8章 实时语音与视频通话 | [server/main.go](/Users/superysh/im/im_server/server/main.go:826), [server/calls.go](/Users/superysh/im/im_server/server/calls.go:1) | 需补 call 信令状态机单测 |
| [ ] MAP-09 | 第9章 企业工作台与办公协同 | [server/main.go](/Users/superysh/im/im_server/server/main.go:792), [server/main.go](/Users/superysh/im/im_server/server/main.go:796) | 需补 workbench/org/meeting/report/mailbox 单测 |
| [ ] MAP-10 | 第10章 个人资料、通用设置、隐私与青少年模式 | [server/main.go](/Users/superysh/im/im_server/server/main.go:812), [server/main.go](/Users/superysh/im/im_server/server/main.go:815) | [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:104) |
| [ ] MAP-11 | 第11章 反诈安全、举报与意见反馈 | [server/main.go](/Users/superysh/im/im_server/server/main.go:785), [server/main.go](/Users/superysh/im/im_server/server/main.go:798) | 需补 risk/abuse/feedback 单测 |
### 0.3 现有护栏证据
| [ ] 证据 | 已覆盖的护栏 | 说明 |
|---|---|---|
| [ ] EVD-01 | Tenant 0 直接拒绝 | `store.ForTenant(0)` 已有测试 |
| [ ] EVD-02 | 同名 topic 按 tenant 隔离 | Hub tenant 测试已覆盖同名 `sys` 分离 |
| [ ] EVD-03 | 文件路径按 tenant 命名空间化 | tenant 文件路径测试已覆盖 |
| [ ] EVD-04 | Push channel 按 tenant 命名空间化 | tenant channel 测试已覆盖 |
| [ ] EVD-05 | 文件 HTTP 认证返回 Principal | 文件认证测试已覆盖 tenant/uid 绑定 |
| [ ] EVD-06 | Verification / reset 流程按 tenant 处理 | `user_verification_test.go` 已覆盖验证与重置主流程 |
## 1. 第1章 账号注册、登录与身份安全
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T01-01 | 企业码握手成功 | `hi.tenant=acme`,租户为 `active` | Session 只绑定一次 tenant;返回公开 tenant 信息,不暴露内部 `tenant_id` |
| [ ] T01-02 | 企业码缺失或非法 | `hi` 不带 tenant / 空白 / 超长 | 返回 400 类错误,Session 不绑定 tenant |
| [ ] T01-03 | 已绑定 Session 再发另一个 tenant | 先绑定 A,再发 B | 返回冲突类错误,tenant 不变化 |
| [ ] T01-04 | basic 登录同名跨租户 | A/B 都有 `alice`,分别用各自密码登录 | 只命中当前 tenant 的 auth 记录,token 携带当前 tenant |
| [ ] T01-05 | token 登录 tenant 不匹配 | A token 放进 B session | 认证失败,不写入 uid/authLvl |
| [ ] T01-06 | 注册验证码 tenant 隔离 | A、B 同手机号注册 | 只消费当前 tenant 的验证码记录,不串租户 |
| [ ] T01-07 | 找回密码 tenant 隔离 | A、B 同手机号触发重置 | 仅更新当前 tenant 用户,auth version 按当前 tenant 递增 |
| [ ] T01-08 | 实名/青少年/独立密码入口 | 无 tenant 或 tenant 不活跃 | 统一拒绝,不进入后续敏感流程 |
## 2. 第2章 消息首页、全局导航与系统通知
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C02-01 | bootstrap 只返回本租户菜单 | 调用 `bootstrap` | 功能开关、未读数、模块列表只来自当前 tenant |
| [ ] C02-02 | system notification 列表 | 查询/翻页已读未读 | 只返回当前 tenant 通知,分页游标稳定 |
| [ ] C02-03 | system notification read | 标记单条已读 | 只改当前 tenant 当前用户的 read state |
| [ ] C02-04 | conversation search | A 搜索 B 的联系人/会话 | 返回空或通用 NotFound,不泄露 B 的存在性 |
| [ ] C02-05 | 无网络/失效 token 恢复 | 模拟 token 失效后重连 | 进入统一错误分支,不误切 tenant |
## 3. 第3章 单聊会话、会话建立与消息状态
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T03-01 | P2P context 正常返回 | 查询同租户双方会话上下文 | `relationship`、`can_send`、`topic` 与当前 tenant 一致 |
| [ ] T03-02 | 非好友/黑名单发消息 | 在 `can_send=false` 下调用 pub | 服务端拒绝,不产生消息 seq |
| [ ] T03-03 | read/recv 回执推进 | 连续提交 `note read/recv` | 只允许单调推进,不回退读游标 |
| [ ] T03-04 | 删除好友后历史保留 | 删除好友后再查历史消息 | 历史还在,但后续发送被拒绝 |
| [ ] T03-05 | 跨租户 topic 访问 | 已知 B topic 订阅/读/写 | 统一 NotFound/Denied,不进入 Hub 缓存 |
| [ ] T03-06 | 主题状态切换 | attach/leave/reattach | session/topic 状态机不被错误 tenant 影响 |
## 4. 第4章 消息输入、媒体内容与消息操作
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C04-01 | 文本消息 schema 校验 | `x-im-schema=1`、`x-im-type=text`、`x-im-client-mid` | 通过并保持 client-mid 幂等 |
| [ ] C04-02 | 媒体消息字段校验 | image/video/voice/file 的 `content` | 必填字段缺失或超长时拒绝 |
| [ ] C04-03 | @成员与 @all | 指定群成员 / `mention_all` | 成员过滤正确,越权 `@all` 被拒绝 |
| [ ] C04-04 | 编辑/撤回/删除 | 对同一消息进行修改或撤回 | 只允许符合时限和角色的操作 |
| [ ] C04-05 | 合并转发结构 | bundle/逐条转发 | 快照字段稳定,源消息与目标消息分离 |
| [ ] C04-06 | 媒体消息重复提交 | 重试同一 `client_mid` | 返回首次写入结果,不重复落库 |
## 5. 第5章 通讯录、好友关系与黑名单
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C05-01 | 用户搜索 tenant 隔离 | A 搜索 B 仅有的账号/tag | 返回空结果,不提示跨租户命中 |
| [ ] C05-02 | 二维码解析 | 用 add_friend token 解析 | 只返回当前 tenant 可见的公开资料 |
| [ ] C05-03 | 好友申请幂等 | 同一请求重复提交 | 返回原申请记录,不重复创建 |
| [ ] C05-04 | 好友同意/拒绝 | 审批 inbound request | 双方关系和 P2P topic 同步更新 |
| [ ] C05-05 | 拉黑/解除拉黑 | `PUT/DELETE /blocks/{user_id}` | 只影响当前 tenant 当前用户关系 |
| [ ] C05-06 | 删除好友 | `DELETE /friends/{user_id}` | 历史消息不删,后续会话发送被拒绝 |
## 6. 第6章 群聊创建、资料维护与成员治理
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C06-01 | 创建群 | 创建新 group topic | topic、group settings、owner、subscription 同 tenant 落库 |
| [ ] C06-02 | 邀请/审批入群 | 邀请 B tenant 用户进 A 群 | 统一拒绝,不产生部分写入 |
| [ ] C06-03 | 群管理员变更 | 提升/降级成员角色 | 仅允许本群本 tenant 成员,ACL 一致 |
| [ ] C06-04 | 群主转让 | 指定新 owner | 事务内同步业务真源和 Topic Owner |
| [ ] C06-05 | 群禁言/全员禁言 | muted member 发消息 | 服务端拒绝,其他成员不可见 |
| [ ] C06-06 | 群文件/成员列表 | 查询群成员、群文件 | 只返回当前 tenant 当前群成员 |
## 7. 第7章 文件下载、收藏管理与聊天记录检索
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C07-01 | 文件上传 principal | HTTP/gRPC 文件上传 | `RequestPrincipal` 必须带 tenant/uid,落库 tenant 正确 |
| [ ] C07-02 | 文件下载授权 | 用 A 用户访问 B file id | 返回 403/404,不暴露 location |
| [ ] C07-03 | 收藏去重 | 同一 `topic+seq` 重复收藏 | 只新增一条,返回 existing 计数 |
| [ ] C07-04 | 收藏转发 | 对已收藏消息做转发 | 保持 source snapshot,不越权扩散 |
| [ ] C07-05 | 会话/消息搜索 | 搜索关键字、类型、文件 | 只返回当前 tenant 且有读权限的结果 |
| [ ] C07-06 | 垃圾清理 | 过期未使用文件清理 | 只清当前 tenant,不误删其他 tenant 文件 |
## 8. 第8章 实时语音与视频通话
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T08-01 | 发起通话 | `call` invite / note started | 生成稳定 `callId`,同 tenant 双方可见 |
| [ ] T08-02 | 接听/拒绝/挂断 | accept / reject / hang-up | 状态转换正确,结束原因稳定 |
| [ ] T08-03 | 超时结束 | 不接听直到超时 | 服务端替换为 missed/timeout 结果 |
| [ ] T08-04 | 多设备回放 | 同用户多个 session | 只向合法 session 广播,不串 tenant |
| [ ] T08-05 | 跨租户呼叫 | A 呼叫 B tenant 用户 | 统一拒绝,不创建有效通话记录 |
## 9. 第9章 企业工作台与办公协同
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B09-01 | workbench 模块列表 | 查询工作台 | 模块、权限、未读数只取当前 tenant |
| [ ] B09-02 | 组织树查询 | 查部门根节点/子节点/成员 | 只返回当前 tenant 组织结构 |
| [ ] B09-03 | 会议创建/变更/取消 | 创建会议并更新参会人 | 幂等、通知、审计都在当前 tenant |
| [ ] B09-04 | 日报/月报草稿与统计 | 创建、提交、统计、提醒 | 统计范围受 tenant 与管理权限约束 |
| [ ] B09-05 | 总经理信箱/工作台入口 | 查询配置与提交 | 匿名/必填字段/附件策略按 tenant 配置 |
## 10. 第10章 个人资料、通用设置、隐私与青少年模式
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T10-01 | me/profile 读取与更新 | 查询并 patch 个人资料 | 只允许当前用户当前 tenant 数据更新 |
| [ ] T10-02 | privacy 更新 | 修改隐私项 | 版本控制正确,其他设备能收到变更 |
| [ ] T10-03 | youth mode 开关 | 启用/关闭青少年模式 | 需要 action token / 独立密码,tenant 一致 |
| [ ] T10-04 | independent password | 设置/验证独立密码 | 明文不落库,不跨租户复用 |
| [ ] T10-05 | push token 绑定 | 更新设备 push token | `(tenant_id, uid, device_id)` 维度唯一 |
| [ ] T10-06 | app release / settings menu | 查询版本与设置菜单 | 返回当前 tenant 配置,不混入别的企业开关 |
## 11. 第11章 反诈安全、举报与意见反馈
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B11-01 | URL 风险检查 | 检查外链 | 返回 allow/warn/block,带租户上下文 |
| [ ] B11-02 | 风险挑战确认 | `continue/cancel` | 只消费当前租户当前 challenge |
| [ ] B11-03 | 举报原因与提交 | 读取原因并提交举报 | reason、证据、目标对象按 tenant 存档 |
| [ ] B11-04 | 反馈配置与提交 | 查询反馈配置并提交反馈 | 附件、联系方式、诊断信息按 tenant 策略处理 |
| [ ] B11-05 | 系统通知联动 | 风险/举报结果产生通知 | 只推当前 tenant 相关用户,不跨租户扩散 |
## 12. 高风险迁移确认点
| [ ] ID | 确认点 | 为什么要测 | 预期 |
|---|---|---|---|
| [ ] RISK-01 | Token / Session tenant 一致性 | 这是全链路身份根 | 任意篡改都拒绝 |
| [ ] RISK-02 | 文件认证 Principal | 文件链路最容易只剩 UID | 没 tenant 直接失败 |
| [ ] RISK-03 | 同名 `sys` / Topic 隔离 | 最容易发生缓存串租户 | 不同 tenant 命中不同对象 |
| [ ] RISK-04 | 事务内幂等 | 验证码、好友、群审批、会议、举报都依赖 | 重试不重复写 |
| [ ] RISK-05 | 搜索二次 ACL | 索引命中不代表可见 | 返回前再做权限过滤 |
| [ ] RISK-06 | Push / 回调 / 外部服务 | 外部 side-effect 最易丢 tenant | 出站上下文保持 tenant |
| [ ] RISK-07 | 旧请求重放 | 防止用旧参数跨 tenant 复用 | 统一按当前 tenant 拒绝 |
| [ ] RISK-08 | 失败回滚 | 群主转让、入群、文件关联等 | 只要一处失败就全回滚 |
## 13. 建议补测顺序
1. 先补第 1 章和第 7 章,因为它们是登录态与文件安全的根。
2. 再补第 3、5、6 章,把联系人、群和消息主链路闭环。
3. 然后补第 8、9、10、11 章,覆盖通话、工作台、设置和安全合规模块。
4. 最后做跨章回归:同名数据双租户、同 token 重放、同 file id 访问、同 topic 搜索。
+25
View File
@@ -0,0 +1,25 @@
# Contributing
We're happy you want to contribute! You can help us in different ways:
- [Open an issue](https://github.com/tinode/chat/issues) with suggestions for improvements
- Fork this repository and submit a pull request
- Improve the documentation
To submit a pull request, fork the [repository](https://github.com/tinode/chat) and then clone your fork:
git clone git@github.com:<your-repo-name>/chat.git
Make your suggested changes, `git push` and then [submit a pull request](https://github.com/tinode/chat/compare/). Note that before we can accept your pull requests, you need to sign our [Contributor License Agreement](docs/CLA.md).
## Why is the Contributor License Agreement necessary?
We very much appreciate your wanting to contribute to Tinode Chat, but we need to add you to the contributors list first. Note that the [agreement](docs/CLA.md) is not a transfer of copyright ownership, this simply is a license agreement for contributions. You also do not change your rights to use your own contributions for any other purpose. For some background on why contributor license agreements are necessary, you can read FAQs from many other open source projects:
- Django's [CLA FAQ](https://www.djangoproject.com/foundation/cla/faq/)
- A [chapter](http://producingoss.com/en/copyright-assignment.html) from Karl Fogel's _Producing Open Source Software_ on CLAs
- The [Wikipedia article on CLAs](http://en.wikipedia.org/wiki/Contributor_license_agreement)
This is part of the legal framework of the open-source ecosystem that adds some red tape, but protects both the contributor and the company / foundation behind the project. It also gives us the option to relicense the code with a more permissive license in the future.
+227
View File
@@ -0,0 +1,227 @@
# Installing Tinode
The config file [`tinode.conf`](./server/tinode.conf) contains extensive instructions on configuring the server.
## Installing from Binaries
1. Visit the [Releases page](https://github.com/tinode/chat/releases/), choose the latest or otherwise the most suitable release. From the list of binaries download the one for your database (supported: MySQL, PostgreSQL, MongoDB, RethinkDB) and platform (Linux ARM or Intel, Windows, Mac ARM or Intel). Once the binary is downloaded, unpack it to a directory of your choosing, `cd` to that directory.
2. Make sure your database is running. Make sure it's configured to accept connections from `localhost`. In case of MySQL, Tinode will try to connect as `root` without the password. In case of PostgreSQL, Tinode will try connect as `postgres` with the password `postgres`. See notes below (_Building from Source_, section 4) on how to configure Tinode to use a different user or a password. MySQL 5.7 or above is required (use InnoDB, not MyISAM storage engine). MySQL 5.6 or below **will not work**, use of MyISAM **will cause problems**. PostgreSQL 13 or above is required. PostgreSQL 12 or below **will not work**. MongoDB 4.4 or above is required. MongoDB 4.2 and below **will not work**.
3. Run the database initializer `init-db` (or `init-db.exe` on Windows):
```
./init-db -data=data.json
```
4. Run the `tinode` (or `tinode.exe` on Windows) server. It will work without any parameters.
```
./tinode
```
5. Test your installation by pointing your browser to http://localhost:6060/
## Docker
See [instructions](./docker/README.md)
## Building from Source
1. Install [Go environment](https://golang.org/doc/install). The installation instructions below are for Go 1.18 and newer. Building with the latest Go environment is recommended.
2. OPTIONAL only if you intend to modify the code: Install [protobuf](https://developers.google.com/protocol-buffers/) and [gRPC](https://grpc.io/docs/languages/go/quickstart/) including [code generator](https://developers.google.com/protocol-buffers/docs/reference/go-generated) for Go.
3. Make sure one of the following databases is installed and running:
* MySQL 5.7 or above configured with `InnoDB` engine (8.x preferred). MySQL 5.6 or below **will not work**.
* PostgreSQL 13 or above. PostgreSQL 12 or below **will not work**.
* MongoDB 4.4 or above (8.x preferred). MongoDB 4.2 and below **will not work**.
* RethinkDB (deprecated, support will be dropped in 2027 unless RethinkDB team resumes development).
4. Fetch, build Tinode server and tinode-db database initializer:
- **MySQL**:
```
go install -tags mysql github.com/tinode/chat/server@latest
go install -tags mysql github.com/tinode/chat/tinode-db@latest
```
- **PostgreSQL**:
```
go install -tags postgres github.com/tinode/chat/server@latest
go install -tags postgres github.com/tinode/chat/tinode-db@latest
```
- **MongoDB**:
```
go install -tags mongodb github.com/tinode/chat/server@latest
go install -tags mongodb github.com/tinode/chat/tinode-db@latest
```
- **RethinkDb**:
```
go install -tags rethinkdb github.com/tinode/chat/server@latest
go install -tags rethinkdb github.com/tinode/chat/tinode-db@latest
```
- **All** (bundle all of the above DB adapters):
```
go install -tags "mysql rethinkdb mongodb postgres" github.com/tinode/chat/server@latest
go install -tags "mysql rethinkdb mongodb postgres" github.com/tinode/chat/tinode-db@latest
```
The steps above install Tinode binaries at `$GOPATH/bin/`, sorces and supporting files are located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/` where `X.XX.X` is the version you installed, such as `0.19.1`.
Note the required **`-tags rethinkdb`**, **`-tags mysql`**, **`-tags mongodb`** or **`-tags postgres`** build option.
You may also optionally define `main.buildstamp` for the server by adding a build option, for instance, with a timestamp:
```
go install -tags mysql -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" github.com/tinode/chat/server@latest
```
The value of `buildstamp` will be sent by the server to the clients.
Building with Go 1.17 or below **will fail**!
5. Open `tinode.conf` (located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/server/`). Check that the database connection parameters are correct for your database. If you are using MySQL make sure [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) in `"mysql"` section is appropriate for your MySQL installation. Option `parseTime=true` is required.
```js
"mysql": {
"dsn": "root@tcp(localhost)/meeting_im?parseTime=true",
"database": "meeting_im"
},
```
6. Make sure you specify the adapter name in your `tinode.conf`. E.g. you want to run Tinode with MySQL:
```js
"store_config": {
...
"use_adapter": "mysql",
...
},
```
7. Now that you have built the binaries, follow instructions in the _Running a Standalone Server_ section.
## Running a Standalone Server
If you followed instructions in the previous section then the Tinode binaries are installed in `$GOPATH/bin/`, the sources and supporting files are located in `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/`, where `X.XX.X` is the version you installed, for example `0.19.1`.
Switch to sources directory (replace `X.XX.X` with your actual version, such as `0.19.1`):
```
cd $GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X
```
1. Make sure your database is running:
- **MySQL**: https://dev.mysql.com/doc/mysql-startstop-excerpt/5.7/en/mysql-server.html
```
mysql.server start
```
- **PostgreSQL**: https://www.postgresql.org/docs/current/app-pg-ctl.html
```
pg_ctl start
```
- **MongoDB**: https://docs.mongodb.com/manual/administration/install-community/
MongoDB should run as single node replicaset. See https://docs.mongodb.com/manual/administration/replica-set-deployment/
```
mongod
```
- **RethinkDB**: https://www.rethinkdb.com/docs/start-a-server/
```
rethinkdb --bind all --daemon
```
2. Run DB initializer
```
$GOPATH/bin/tinode-db -config=./tinode-db/tinode.conf
```
add `-data=./tinode-db/data.json` flag if you want sample data to be loaded:
```
$GOPATH/bin/tinode-db -config=./tinode-db/tinode.conf -data=./tinode-db/data.json
```
DB initializer needs to be run only once per installation. See [instructions](tinode-db/README.md) for more options.
3. Unpack JS client to a directory, for instance `$HOME/tinode/webapp/` by unzipping `https://github.com/tinode/webapp/archive/master.zip` and `https://github.com/tinode/tinode-js/archive/master.zip` to the same directory.
4. Copy or symlink template directory `./server/templ` to `$GOPATH/bin/templ`
```
ln -s ./server/templ $GOPATH/bin
```
5. Run the server
```
$GOPATH/bin/server -config=./server/tinode.conf -static_data=$HOME/tinode/webapp/
```
6. Test your installation by pointing your browser to [http://localhost:6060/](http://localhost:6060/). The static files from the `-static_data` path are served at web root `/`. You can change this by editing the line `static_mount` in the config file.
**Important!** If you are running Tinode alongside another webserver, such as Apache or nginx, keep in mind that you need to launch the webapp from the URL served by Tinode. Otherwise it won't work.
## Running a Cluster
- Install and run the database, run DB initializer, unpack JS files, and link or copy template directory as described in the previous section. Both MySQL and RethinkDB supports [cluster](https://www.mysql.com/products/cluster/) [mode](https://www.rethinkdb.com/docs/start-a-server/#a-rethinkdb-cluster-using-multiple-machines). You may consider it for added resiliency.
- Cluster expects at least two nodes. A minimum of three nodes is recommended.
- The following section configures the cluster.
```
"cluster_config": {
// Name of the current node.
"self": "",
// List of all cluster nodes, including the current one.
"nodes": [
{"name": "one", "addr":"localhost:12001"},
{"name": "two", "addr":"localhost:12002"},
{"name": "three", "addr":"localhost:12003"}
],
// Configuration of failover feature. Don't change.
"failover": {
"enabled": true,
"heartbeat": 100,
"vote_after": 8,
"node_fail_after": 16
}
}
```
* `self` is the name of the current node. Generally it's more convenient to specify the name of the current node at the command line using `cluster_self` option. Command line value overrides the config file value. If the value is not provided either in the config file or through the command line, the clustering is disabled.
* `nodes` defines individual cluster nodes. The sample defines three nodes named `one`, `two`, and `tree` running at the localhost at the specified cluster communication ports. Cluster addresses don't need to be exposed to the outside world.
* `failover` is an experimental feature which migrates topics from failed cluster nodes keeping them accessible:
* `enabled` turns on failover mode; failover mode requires at least three nodes in the cluster.
* `heartbeat` interval in milliseconds between heartbeats sent by the leader node to follower nodes to ensure they are accessible.
* `vote_after` number of failed heartbeats before a new leader node is elected.
* `node_fail_after` number of heartbeats that a follower node misses before it's considered to be down.
If you are testing the cluster with all nodes running on the same host, you also must override the `listen` and `grpc_listen` ports. Here is an example for launching two cluster nodes from the same host using the same config file:
```
$GOPATH/bin/tinode -config=./server/tinode.conf -static_data=./server/webapp/ -listen=:6060 -grpc_listen=:6080 -cluster_self=one &
$GOPATH/bin/tinode -config=./server/tinode.conf -static_data=./server/webapp/ -listen=:6061 -grpc_listen=:6081 -cluster_self=two &
```
A bash script [run-cluster.sh](./server/run-cluster.sh) may be found useful.
### Enabling Push Notifications
Follow [instructions](./docs/faq.md#q-how-to-setup-push-notifications-with-google-fcm).
### Enabling Video Calls
Video calls use [WebRTC](https://en.wikipedia.org/wiki/WebRTC). WebRTC is a peer to peer protocol: once the call is established, the client applications exchange data directly. Direct data exchange is efficient but creates a problem when the parties are not accessible from the internet. WebRTC solves it by means of [ICE](https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment) servers which implement protocols [TURN(S)](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) and [STUN](https://en.wikipedia.org/wiki/STUN) as fallback.
Tinode does not provide ICE servers out of the box. You must install and configure (or purchase) your own servers otherwise video and voice calling will not be available.
Once you obtain the ICE TURN/STUN configuration from your service provider, add it to `tinode.conf` section `"webrtc"` - `"ice_servers"` (or `"ice_servers_file"`). Also change `"webrtc"` - `"enabled"` to `true`. An example configuration is provided in the `tinode.conf` for illustration only. IT WILL NOT FUNCTION because it uses dummy values instead of actual server addresses.
You may find this information useful for choosing the servers: https://gist.github.com/yetithefoot/7592580
### Note on Running the Server in Background
There is [no clean way](https://github.com/golang/go/issues/227) to daemonize a Go process internally. One must use external tools such as shell `&` operator, `systemd`, `launchd`, `SMF`, `daemon tools`, `runit`, etc. to run the process in the background.
Specific note for [nohup](https://en.wikipedia.org/wiki/Nohup) users: an `exit` must be issued immediately after `nohup` call to close the foreground session cleanly:
```
nohup $GOPATH/bin/server -config=./server/tinode.conf -static_data=$HOME/tinode/webapp/ &
exit
```
Otherwise `SIGHUP` may be received by the server if the shell connection is broken before the ssh session has terminated (indicated by `Connection to XXX.XXX.XXX.XXX port 22: Broken pipe`). In such a case the server will shutdown because `SIGHUP` is intercepted by the server and interpreted as a shutdown request.
For more details see https://github.com/tinode/chat/issues/25.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+223 -1
View File
@@ -1,2 +1,224 @@
# im_server
# Tinode Instant Messaging Server
<img src="docs/logo.svg" align="left" width=128 height=128> Instant messaging full stack. Backend in pure [Go](http://golang.org) (license [GPL 3.0](http://www.gnu.org/licenses/gpl-3.0.en.html)), clients for Android (Java), iOS (Swift), and web (ReactJS), as well as [gRPC](https://grpc.io/) client support for C++, C#, Go, Java, Node, PHP, Python, Ruby, Objective-C, etc (all clients licensed under [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)). Wire transport is JSON over websocket (long polling is also available) or [protobuf](https://developers.google.com/protocol-buffers/) with gRPC.
This is beta-quality software: feature-complete and stable but probably with a few bugs or missing features. Follow [instructions](INSTALL.md) to install and run or use one of the cloud services below. Read [API documentation](docs/API.md).
Tinode is *not* XMPP/Jabber. It is *not* compatible with XMPP. It's meant as a replacement for XMPP. On the surface, it's a lot like open source WhatsApp or Telegram.
<a href="https://apps.apple.com/us/app/tinode/id1483763538"><img src="docs/app-store.svg" height=36></a> <a href="https://play.google.com/store/apps/details?id=co.tinode.tindroidx"><img src="docs/play-store.svg" height=36></a> <a href="https://web.tinode.co/"><img src="docs/web-app.svg" height=36></a>
## Why?
The promise of [XMPP](http://xmpp.org/) was to deliver federated instant messaging: anyone would be able to spin up an IM server capable of exchanging messages with any other XMPP server in the world. Unfortunately, XMPP never delivered on this promise. Instant messengers are still a bunch of incompatible walled gardens, similar to what AoL of the late 1990s was to the open Internet.
The goal of this project is to deliver on XMPP's original vision: create a modern open platform for federated instant messaging with an emphasis on mobile communication. A secondary goal is to create a decentralized IM platform that is much harder to track and block by the governments.
An explicit NON-goal: we are not building yet another Slack replacement.
## Installing and running
See [general instructions](./INSTALL.md) or [docker-specific instructions](./docker/README.md).
## Getting support
* Read [API documentation](docs/API.md) and [FAQ](docs/faq.md). Read configuration instructions contained in the [`tinode.conf`](./server/tinode.conf) file.
* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
* For bugs and feature requests [open an issue](https://github.com/tinode/chat/issues/new/choose).
* Use https://tinode.co/contact for commercial inquiries.
## Helping out
* If you appreciate our work, please help spread the word! Sharing on Reddit, HN, and other communities helps more than you think.
* Consider buying paid support: https://tinode.co/support.html
* If you are a software developer, send us your pull requests with bug fixes and new features.
* If you use the app and discover bugs or missing features, let us know by filing bug reports and feature requests. Vote for existing [feature requests](https://github.com/tinode/chat/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22feature+request%22) you find most valuable.
* If you speak a language other than English, [translate](docs/translations.md) the apps into your language. You may also review and improve existing translations.
* If you are a UI/UX expert, help us polish the app UI.
* Use it: install it for your colleagues or friends at work or at home.
## Public service
A [public Tinode service](https://web.tinode.co/) is available. You can use it just like any other instant messenger. Keep in mind that demo accounts present in [sandbox](https://sandbox.tinode.co/) are not available in the public service. You must register an account using valid email in order to use the service.
### Web
TinodeWeb, a single page web app, is available at https://web.tinode.co/ ([source](https://github.com/tinode/webapp/)). See screenshots below.
### Android
[Tinode for Android](https://play.google.com/store/apps/details?id=co.tinode.tindroidx) a.k.a Tindroid is stable and functional ([source](https://github.com/tinode/tindroid)). See the screenshots below. A [debug APK](https://github.com/tinode/tindroid/releases/latest) is also provided for convenience.
### iOS
[Tinode for iOS](https://apps.apple.com/us/app/tinode/id1483763538) a.k.a. Tinodios is stable and functional ([source](https://github.com/tinode/ios)). See the screenshots below.
## Demo/Sandbox
A sandboxed demo service is available at https://sandbox.tinode.co/.
Log in as one of `alice`, `bob`, `carol`, `dave`, `frank`. Password is `<login>123`, e.g. login for `alice` is `alice123`. You can discover other users by email or phone by prefixing them with `email:` or `tel:` respectively. Emails are `<login>@example.com`, e.g. `alice@example.com`, phones are `+17025550001` through `+17025550009`.
When you register a new account you are asked for an email address to send validation code to. For demo purposes you may use `123456` as a universal validation code. The code you get in the email is also valid.
### Sandbox Notes
* The sandbox server is reset (all data wiped) every night at 3:15am Pacific time. An error message `User not found or offline` means the server was reset while you were connected. If you see it on the web, reload and relogin. On Android log out and re-login. If the database was changed, delete the app then reinstall.
* Sandbox user `Tino` is a [basic chatbot](./chatbot) which responds with a [random quote](http://fortunes.cat-v.org/) to any message.
* As generally accepted, when you register a new account you are asked for an email address. The server will send an email with a verification code to that address and you can use it to validate the account. To make things easier for testing, the server will also accept `123456` as a verification code. Remove line `"debug_response": "123456"` from `tinode.conf` to disable this option.
* The sandbox server is configured to use [ACME](https://letsencrypt.org/) TLS [implementation](https://godoc.org/golang.org/x/crypto/acme) with hard-coded requirement for [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication). If you are unable to connect then the most likely reason is your TLS client's missing support for SNI. Use a different client.
* The default web app loads a single minified javascript bundle and minified CSS. The un-minified version is also available at https://sandbox.tinode.co/index-dev.html
* [Docker images](https://hub.docker.com/u/tinode/) with the same demo are available.
* You are welcome to test your client software against the sandbox, hack it, etc. No DDoS-ing though please.
## Features
### Supported
* Multiple native platforms:
* [Android](https://github.com/tinode/tindroid/) (Java)
* [iOS](https://github.com/tinode/ios) (Swift)
* [Web](https://github.com/tinode/webapp/) (React.js)
* Scriptable [command line](tn-cli/) (Python)
* User features:
* One-on-one and group messaging.
* Video and voice calls. Voice messages.
* Channels with unlimited number of read-only subscribers.
* All chats are synchronized across all devices.
* Granular access control with permissions for various actions.
* User search/discovery.
* Rich formatting of messages markdown-style: \*style\* &rarr; **style**, with inline images, videos, file attachments.
* Forms and templated responses suitable for chatbots.
* Verified/staff/untrusted account markers.
* Leave notes to self, bookmark (save) messages.
* Message status notifications: message delivery to server; received and read notifications; typing notifications.
* Most recent message preview in contact list.
* Server-generated presence notifications for people, group chats.
* Forwarding and replying to messages.
* Editing sent messages.
* Pinned chats and messages.
* Customizable message backgrounds (wallpapers).
* Light/dark/system UI themes.
* Administration:
* Granular access control with permissions for various actions.
* Support for custom authentication backends.
* Ability to block unwanted communication server-side.
* Anonymous users (important for use cases related to tech support over chat).
* Plugins to extend functionality, for example, to support moderation or chatbots.
* Scriptable [command-line tool](tn-cli/) for server administration.
* Performance, reliability and development:
* Sharded clustering with failover.
* Storage and out of band transfer of large objects like images or document files using local file system or Amazon S3 (other storage systems can be supported with [media handlers](https://github.com/tinode/chat/blob/master/server/media/media.go#L21)).
* JSON or [protobuf version 3](https://developers.google.com/protocol-buffers/) wire protocols.
* Bindings for various programming languages:
* Javascript with no external dependencies.
* Java with dependencies on [Jackson](https://github.com/FasterXML/jackson), [Java-Websocket](https://github.com/TooTallNate/Java-WebSocket), [ICU4J](https://github.com/unicode-org/icu). Suitable for Android but with no Android SDK dependencies.
* Swift with no external dependencies.
* C/C++, C#, Go, Python, PHP, Ruby and many other languages using [gRPC](https://grpc.io/docs/languages/).
* Choice of a database backend. Other databases can be added by writing [adapters](server/db/adapter.go).
* MySQL (and MariaDB, Percona as long as they remain SQL and wire protocol compatible)
* PostgreSQL
* MongoDB
* [RethinkDB](http://rethinkdb.com/). Support is deprecated and will be dropped in 2027 because RethinkDB is no longer being developed (unless its development resumes).
### Planned
* [Federation](https://en.wikipedia.org/wiki/Federation_(information_technology)).
* Location and contacts sharing.
* Previews of attached documents, links.
* Recording video messages.
* Video/audio broadcasting.
* Group video/audio calls.
* Attaching music/audio other than voice messages.
* Different levels of message persistence (from strict persistence to "store until delivered" to purely ephemeral messaging).
* Message encryption at rest.
* End to end encryption with [OTR](https://en.wikipedia.org/wiki/Off-the-Record_Messaging) for one-on-one messaging and undecided method for group messaging.
* Full text search in messages.
### Translations
All client software has support for [internationalization](docs/translations.md). The following translations are provided:
| Language | Server | Webapp | Android | iOS |
| --- | :---: | :---: | :---: | :---: |
| English | &check; | &check; | &check; | &check; |
| Arabic | | &check; | | |
| Chinese simplified | &check; | &check; | &check; | &check; |
| Chinese traditional | &check; | &check; | &check; | &check; |
| French | &check; | &check; | &check; | |
| German | | &check; | &check; | |
| Hindi | | | &check; | |
| Italian | | &check; | &check; | &check; |
| Korean | | &check; | &check; | |
| Portuguese | &check; | | &check; | |
| Romanian | | &check; | &check; | |
| Russian | &check; | &check; | &check; | &check; |
| Spanish | &check; | &check; | &check; | &check; |
| Thai | | &check; | | |
| Ukrainian | &check; | &check; | &check; | &check; |
| Vietnamese | &check; | &check; | | |
More translations are [welcome](docs/translations.md). In addition to languages listed above, particularly interested in Bengali, Indonesian, Urdu, Japanese, Turkish, Persian.
## Third-Party
### Projects
* [Arango DB adapter](https://github.com/gfxlabs/chat/tree/master/server/db/arango) (outdated)
* [DynamoDB adapter](https://github.com/riandyrn/chat/tree/master/server/db/dynamodb) (outdated)
### Licenses
* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/) and https://pixabay.com/ under their [license](https://pixabay.com/service/license/).
* Web and Android background patterns are from http://subtlepatterns.com/ under [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) license.
* Android icons are from https://material.io/tools/icons/ under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) license.
## Screenshots
### [Android](https://github.com/tinode/tindroid/)
<p align="center">
<img src="docs/android-contacts.png" alt="Android screenshot: list of chats" width=250 />
<img src="docs/android-chat.png" alt="Android screenshot: one conversation" width=250 />
<img src="docs/android-video-call.png" alt="Android screenshot: video call" width=250 />
</p>
### [iOS](https://github.com/tinode/ios)
<p align="center">
<img src="docs/ios-contacts.png" alt="iOS screenshot: list of chats" width=250 /> <img src="docs/ios-chat.png" alt="iOS screenshot: one conversation" width=250 /> <img src="docs/ios-video-call.png" alt="iOS screenshot: video call" width="250" />
</p>
### [Desktop Web](https://github.com/tinode/webapp/)
<p align="center">
<img src="docs/web-desktop.jpg" alt="Desktop web: full app" width=810 />
</p>
### [Mobile Web](https://github.com/tinode/webapp/)
<p align="center">
<img src="docs/web-mob-contacts.png" alt="Mobile web: contacts" width=250 /> <img src="docs/web-mob-chat.png" alt="Mobile web: chat" width=250 /> <img src="docs/web-mob-video-call.png" alt="Mobile web: topic info" width=250 />
</p>
#### SEO Strings
Words 'chat' and 'instant messaging' in Chinese, Russian, Persian and a few other languages.
* 聊天室 即時通訊
* чат мессенджер
* インスタントメッセージ
* 인스턴트 메신저
* پیام رسان فوری
* تراسل فوري
* فوری پیغام رسانی
* Nhắn tin tức thời
* anlık mesajlaşma sohbet
* mensageiro instantâneo
* pesan instan
* mensajería instantánea
* চ্যাট ইন্সট্যান্ট মেসেজিং
* चैट त्वरित संदेश
* তাৎক্ষণিক বার্তা আদান প্রদান
+160
View File
@@ -0,0 +1,160 @@
# Tinode 인스턴트 메시징 서버
## This document is outdated. For up to date info use [README.md](./README.md)
<img src="docs/logo.svg" align="left" width=128 height=128> 인스턴트 메시징 서버. C++, C#, [Go](http://golang.org), Java, Node, PHP, Python, Ruby, Objective-C 등에 대한 [gRPC](https://grpc.io/) 클라이언트 지원은 물론 순수Go(라이선스 [GPL 3.0](http://www.gnu.org/licenses/gpl-3.0.en.html))의 백엔드와 Java,Javasript 및 Swift의 클라이언트 측 바인딩(라이선스 [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)). 와이어 전송은 사용자 정의 바인딩을 위해 웹소켓을 통한 JSON(롱 폴링도 가능) 즉, gRPC와 [protobuf](https://developers.google.com/protocol-buffers/). 영구 저장소 [RethinkDB](http://rethinkdb.com/), MySQL 및 MongoDB(실험적). 지원되지 않는 타사 [DynamoDB adapter](https://github.com/riandyrn/chat/tree/master/server/db/dynamodb) adapter도 있습니다. 사용자 정의 어댑터를 작성하여 다른 데이터베이스를 지원할 수 있습니다.
Tinode는 XMPP/ Jabber 가 아닙니다. Tinode는 XMPP와 호환되지 않습니다. XMPP를 대체하기 위한 것입니다. 표면적으로는 오픈소스 WhatsApp 또는 Telegram과 매우 유사합니다.
버전 0.16은 베타 급 소프트웨어입니다. 기능은 완전하지만 몇 가지 버그가 있습니다. 아래 클라우드 서비스 중 하나를 설치 및 실행하거나 사용하려면 [지시사항](INSTALL.md)을 따르십시오. [API 설명서](docs/API.md)를 읽으십시오.
<a href="https://apps.apple.com/us/app/tinode/id1483763538"><img src="docs/app-store.svg" height=36></a> <a href="https://play.google.com/store/apps/details?id=co.tinode.tindroidx"><img src="docs/play-store.svg" height=36></a> <a href="https://web.tinode.co/"><img src="docs/web-app.svg" height=36></a>
## Why?
[XMPP](http://xmpp.org/)의 약속은 연합된 인스턴스 메시징을 제공하는 것입니다. 누구나 전세계의 다른 XMPP서버와 메시지를 교환할 수 있는 IM 서버를 가동할 수 있습니다. 불행하게도, XMPP는 이 약속을 이행하지 않았습니다. 인스턴트 메신저들은 1990년대 후반의 AoL공개 인터넷과 비슷한, 양립할 수 없는 벽으로 둘러싸인 정원의 무리들입니다.
이 프로젝트의 목표는 XMPP의 원래 비전인 모바일 통신을 강조하여 연합 인스턴트 메시징을 위한 현대적인 개방형 플랫폼을 만드는 것입니다. 두 번째 목표는 정부가 추적하고 차단하기 훨씬 어려운 분산형 IM플랫폼을 만드는 것입니다.
XMPP: XML에 기반한 메시지 지향 통신 프로토콜
IM: Instant Messenger
## 설치 및 실행
[일반 지침](./INSTALL.md) 또는 [도커별 지침](./docker/README.md)을 참조하십시오.
## 지원받기
* [API 설명서](docs/API.md) 및 [FAQ](docs/faq.md)를 읽으십시오.
* 지원, 일반적인 질문, 토로은[https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).에 게시하십시오.
* 버그 및 기능 요청에 대해서는 [issue](https://github.com/tinode/chat/issues/new)를 여십시오.
## 공공서비스
[Tinode 공공 서비스](https://web.tinode.co/)는 지금 바로 사용할 수 있습니다. 다른 메신저들처럼 사용하면 됩니다. [샌드박스](https://sandbox.tinode.co/)에 있는 데모 계정은 공공 서비스에서 사용할 수 없습니다. 서비스를 이용하려면 유효한 이메일을 사용하여 계정을 등록해야 합니다.
### 웹
Tinode웹은 단일 페이지의 웹으로 https://web.tinode.co/ ([원본](https://github.com/tinode/webapp/))에서 이용이 가능합니다. . 아래에 있는 스크린 샷을 참고하세요. 현재 영어, 중국어 간체, 러시아어를 지원합니다. 더 많은 번역을 환영합니다.
### 안드로이드
Tindroid라고 불리는 [안드로이드 버전의 Tinode](https://play.google.com/store/apps/details?id=co.tinode.tindroidx) 는 안정적으로 가동됩니다. ([원본](https://github.com/tinode/tindroid)). 아래에 있는 스크린 샷을 참고하세요. 편의를 위해 [디버그 APK](https://github.com/tinode/tindroid/releases/latest)도 제공합니다. 현재 영어, 중국어 간체, 러시아어를 지원합니다. 더 많은 번역을 환영합니다.
### iOS
Tinodios라고 불리는 [iOS 버전의 Tinode](https://apps.apple.com/app/reference-to-tinodios-here/id123) 안정적으로 가동됩니다.([원본](https://github.com/tinode/ios)). 아래에 있는 스크린샷을 참고하세요. 현재 영어와 중국어 간체를 지원합니다. 더 많은 번역을 환영합니다.
## 데모/샌드박스
샌드박스 데모 버전은 https://sandbox.tinode.co/ 에서 이용 가능합니다.
alice, bob, carol, dave, frank 중 하나로 로그인할 수 있습니다. 비밀번호는 <이름>123으로 예를 들어, alice의 비밀번호는 alice123입니다. 사용자 이름을 맨 앞에쓴 <이름>@example.com 형식의 이메일이나 +17025550001 부터 +17025550009의 전화번호를 이용해서 다른 사용자들을 찾을 수 있습니다.
새로운 계정을 등록하면 유효성 검사 코드를 보낼 이메일 주소를 묻는 메시지가 나타납니다. 데모의 목적으로 123456을 범용 유효성 검사 코드로 사용할 수 있습니다. 실제 이메일로 받은 코드도 유효합니다.
### 샌드박스 노트
* 샌드박스 서버는 태평양 표준시 기준 매일 오전 3시 15분에 초기화됩니다(모든 데이터가 지워짐). 사용자를 찾을 수 없습니다 또는 오프라인 같은 오류 메시지는 서버에 연결하는 동안 서버가 초기화 되었음을 의미합니다. 만약 해당 오류 메시지가 표시되면 새로고침 후 다시 로그인 하세요. 안드로이드에서 로그아웃 후 다시 로그인 하세요. 만약 데이터베이스가 변경된 경우에는 앱을 삭제했다가 다시 설치하면 됩니다.
* 샌드박스 유저 Tino는 [기본적인 챗봇](./chatbot)으로 모든 메시지에 [임의의 인용구](http://fortunes.cat-v.org/)로 응답합니다.
* 일반적으로 새로운 계정을 등록하면 이메일 주소를 묻는 메시지가 표시됩니다. 서버는 유효성 검사 코드가 포함된 메일을 보내며 이를 사용하여 계정을 검증하는 데 사용할 수 있습니다. 테스트를 보다 쉽게 할 수 있도록 서버는 유효성 검사 코드로 123456을 또한 허용합니다. Tinode.conf에서 ”debug_response”: “123456”행을 제거하여 이 옵션을 비활성화 시킬 수 있습니다.
* 샌드박스 서버는 [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication)에 대한 하드 코딩된 요구사항과 함께 [ACME](https://letsencrypt.org/) TLS [구현](https://godoc.org/golang.org/x/crypto/acme)을 사용하도록 구성되었습니다. 만약 연결할 수 없을 경우 TLS 클라이언트의 SNI 지원 누락일 가능성이 높습니다. 그 경우 다른 클라이언트를 사용하세요.
* 기본 웹 앱은 하나의 축소된 자바스크립트 번들과 축소된 CSS를 가져옵니다. 축소되지 않은 버전은 https://sandbox.tinode.co/index-dev.html 에서도 제공됩니다.
* 데모가 같은 [도커 이미지](https://hub.docker.com/u/tinode/)도 사용 가능합니다.
* 샌드박스에 대해 소프트웨어를 테스트하고 해킹하는 작업, 기타 작업들을 수행할 수 있습니다. DDos는 절대 사용하지 마세요.
## 특징
### 지원 기능
* [Android](https://github.com/tinode/tindroid/), [iOS](https://github.com/tinode/ios), [web](https://github.com/tinode/webapp/), 그리고 [command line](tn-cli/) 클라이언트.
* 1대1 메시징.
* 모든 구성원의 접근 권한을 가진 그룹 메시징을 개별적으로 관리한다. 최대 구성원 수는 설정할 수 있다(기본적으로 128명).
* 다양한 작업에 대한 권한을 가진 항목 액세스 제어
* 서버에서 생성한 사용자 및 주제에 대한 존재 알림.
* 맞춤형 인증 지원
* failover를 통한 Sharded clustering
* 영구 메시지 저장소, 페이지가 지정된 메시지 기록
* 외부 의존성이 없는 javascript 바인딩.
* Android SDK dependencies.Java 바인딩(의존성: [Jackson](https://github.com/FasterXML/jackson), [Java-Websocket](https://github.com/TooTallNate/Java-WebSocket)). Android에 적합하지만 Android SDK 종속성이 없음.
* TCP 또는 Unix 소켓을 통한 Webocket, long polling, 및 [gRPC](https://grpc.io/).
* JSON 또는[protobuf 버전 3](https://developers.google.com/protocol-buffers/) 와이어 프로토콜.
* [암호화](https://letsencrypt.org/) 또는 기존 인증서를 내장한 [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) 옵션
* 사용자 검색/발견.
* 풍부한 메시지 형식, 마크다운 스타일: \*style\* &rarr; **style**.
* 인라인 이미지 및 첨부 파일.
* 챗봇에 적합한 양식 및 템플리트 응답.
* 메시지 상태 알림: 서버로 메시지 전달; 수신 및 읽기 알림; 입력 알림.
* 클라이언트 측 데이터 캐싱 지원.
* 원하지 않는 통신 서버를 차단하는 기능.
* 익명 사용자(대화 중 기술 지원 관련 사용 사례에 중요성).
* [FCM](https://firebase.google.com/docs/cloud-messaging/) 또는 [TNPG](server/push/tnpg/)를 사용하여 알림을 푸시.
* 로컬 파일 시스템 또는 Amazon S3를 사용하여 비디오 파일과 같은 대형 오브젝트의 저장 및 대역 외 전송.
* 챗봇을 활성화하기 위해 기능을 확장하는 플러그인.
### 계획
* [연방(연합,연맹)](https://en.wikipedia.org/wiki/Federation_(information_technology)).
* 일대일 메시징을 위한 [OTR](https://en.wikipedia.org/wiki/Off-the-Record_Messaging)과 그룹 메시징을 위한 미확정 방법으로 End to end 암호화.
* bearer token 액세스 제어를 가진 무제한 회원(또는 수십만 명)의 그룹 메시징.
* 자동 예비 시스템.
* 메시지 지속성 다른 수준(엄격한 지속성부터 "전달될 때까지 저장"까지, 완전히 짧은 메시징까지).
### 번역
모든 클라이언트 소프트웨어는 국제화를 지원한다. 번역은 영어, 중국어 간체, 러시아어(iOS 제외)에 제공된다. 더 많은 번역을 환영한다. 특히 스페인어, 아랍어, 독일어, 페르시아어, 인도네시아어, 포르투갈어, 힌디어, 벵골어에 관심이 많다.
## 타사 라이선스
* 데모 아바타와 일부 다른 그래픽은 [CC0](https://www.pexels.com/photo-license/) 라이센스에 따라 https://www.pexels.com/에서 제공된다.
* 웹 및 안드로이드 배경 패턴은 [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) 라이센스에 따라 http://subtlepatterns.com/ 에서 제공된다.
* Android 아이콘은 [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) 라이센스의 https://material.io/tools/icons/ 에서 제공된다.
* 일부 iOS 아이콘은 [CC BY-ND 3.0](https://icons8.com/license) 라이센스에 따라 https://icons8.com/ 에서 제공된다.
## 스크린샷
### [안드로이드](https://github.com/tinode/tindroid/)
<p align="center">
<img src="docs/android-contacts.png" alt="Android screenshot: list of chats" width=270 />
<img src="docs/android-chat.png" alt="Android screenshot: one conversation" width=270 />
<img src="docs/android-account.png" alt="Android screenshot: account settings" width=270 />
</p>
### [iOS](https://github.com/tinode/ios)
<p align="center">
<img src="docs/ios-contacts.png" alt="iOS screenshot: list of chats" width=207 /> <img src="docs/ios-chat.png" alt="iOS screenshot: one conversation" width=207 /> <img src="docs/ios-acc-personal.png" alt="iOS screenshot: account settings" width="207" />
</p>
### [데스크탑 웹](https://github.com/tinode/webapp/)
<p align="center">
<img src="docs/web-desktop-2.png" alt="Desktop web: full app" width=810 />
</p>
### [모바일 웹](https://github.com/tinode/webapp/)
<p align="center">
<img src="docs/web-mob-contacts-1.png" alt="Mobile web: contacts" width=250 /> <img src="docs/web-mob-chat-1.png" alt="Mobile web: chat" width=250 /> <img src="docs/web-mob-info-1.png" alt="Mobile web: topic info" width=250 />
</p>
#### SEO 문자열
중국어, 러시아어, 페르시아어 및 다른 몇 가지 언어로 '챗'과 '인스턴트 메시징'을 표시한다.
* 聊天室 即時通訊
* чат мессенджер
* インスタントメッセージ
* 인스턴트 메신저
* پیام‌رسانی فوری گپ
* تراسل فوري
* Nhắn tin tức thời
* anlık mesajlaşma sohbet
* mensageiro instantâneo
* pesan instan
* mensajería instantánea
+13
View File
@@ -0,0 +1,13 @@
# Security Policy
## Reporting a Vulnerability
Please report a vulnerability to `security@tinode.co`.
## Do NOT to report:
* Firebase initialization tokens. The Firebase tokens are really public: they must be distributed with the client applications and consequently are not private by design.
* Exposed `/pprof` and/or `/expvar`. We know they are exposed. It's intentional and harmless.
* Exposed Prometheus metrics `/metrics`. Like above, it's intentional and harmless.
* DMARC policy is not enabled `p=none`. We know and that's the way we like it for now.
* Weak cipher suites (TLS 1.0) at `*.tinode.co`. Yes, we know. Does not look serious/important.
View File
View File
Executable
+185
View File
@@ -0,0 +1,185 @@
#!/bin/bash
# This script builds and archives binaries and supporting files for mac, linux, and windows.
# If directory ./server/static exists, it's asumed to contain TinodeWeb and then it's also
# copied and archived.
# Supported OSs: mac (darwin), windows, linux.
goplat=( darwin darwin windows linux linux )
# CPUs architectures: amd64 and arm64. The same order as OSs.
goarc=( amd64 arm64 amd64 amd64 arm64 )
# Number of platform+architectures.
buildCount=${#goplat[@]}
# Supported database tags
dbadapters=( mysql mongodb rethinkdb postgres )
dbtags=( ${dbadapters[@]} alldbs )
for line in $@; do
eval "$line"
done
version=${tag#?}
if [ -z "$version" ]; then
# Get last git tag as release version. Tag looks like 'v.1.2.3', so strip 'v'.
version=`git describe --tags`
version=${version#?}
fi
echo "Releasing $version"
GOSRC=..
pushd ${GOSRC}/chat > /dev/null
# Prepare directory for the new release
rm -fR ./releases/${version}
mkdir ./releases/${version}
# Tar on Mac is inflexible about directories. Let's just copy release files to
# one directory.
rm -fR ./releases/tmp
mkdir -p ./releases/tmp/templ
# Copy templates and database initialization files
cp ./server/meetingim.conf ./releases/tmp
cp ./server/templ/*.templ ./releases/tmp/templ
cp ./tinode-db/data.json ./releases/tmp
cp ./tinode-db/*.jpg ./releases/tmp
cp ./tinode-db/credentials.sh ./releases/tmp
# Create directories for and copy TinodeWeb files.
if [[ -d ./server/static ]]
then
mkdir -p ./releases/tmp/static/img
mkdir ./releases/tmp/static/img/bkg
mkdir ./releases/tmp/static/css
mkdir ./releases/tmp/static/audio
mkdir ./releases/tmp/static/src
mkdir ./releases/tmp/static/umd
cp ./server/static/img/*.png ./releases/tmp/static/img
cp ./server/static/img/*.svg ./releases/tmp/static/img
cp ./server/static/img/*.jpeg ./releases/tmp/static/img
cp ./server/static/img/bkg/*.png ./releases/tmp/static/img/bkg
cp ./server/static/img/bkg/*.jpg ./releases/tmp/static/img/bkg
cp ./server/static/img/bkg/*.json ./releases/tmp/static/img/bkg
cp ./server/static/audio/*.m4a ./releases/tmp/static/audio
cp ./server/static/css/*.css ./releases/tmp/static/css
cp ./server/static/index.html ./releases/tmp/static
cp ./server/static/index-dev.html ./releases/tmp/static
cp ./server/static/version.js ./releases/tmp/static
cp ./server/static/umd/*.js ./releases/tmp/static/umd
cp ./server/static/manifest.json ./releases/tmp/static
cp ./server/static/service-worker.js ./releases/tmp/static
# Create empty FCM client-side config.
echo 'const FIREBASE_INIT = {};' > ./releases/tmp/static/firebase-init.js
else
echo "TinodeWeb not found, skipping"
fi
for (( i=0; i<${buildCount}; i++ ));
do
plat="${goplat[$i]}"
arc="${goarc[$i]}"
# Use .exe file extension for binaries on Windows.
ext=""
if [ "$plat" = "windows" ]; then
ext=".exe"
fi
# Remove possibly existing keygen from previous build.
rm -f ./releases/tmp/keygen
rm -f ./releases/tmp/keygen.exe
# Keygen is database-independent
env GOOS="${plat}" GOARCH="${arc}" go build -ldflags "-s -w" -o ./releases/tmp/keygen${ext} ./keygen > /dev/null
for dbtag in "${dbtags[@]}"
do
echo "Building ${dbtag}-${plat}/${arc}..."
# Remove possibly existing binaries from previous build.
rm -f ./releases/tmp/tinode
rm -f ./releases/tmp/tinode.exe
rm -f ./releases/tmp/init-db
rm -f ./releases/tmp/init-db.exe
# Build tinode server and database initializer for RethinkDb and MySQL.
# For 'alldbs' tag, we compile in all available DB adapters.
if [ "$dbtag" = "alldbs" ]; then
buildtag="${dbadapters[@]}"
else
buildtag=$dbtag
fi
env GOOS="${plat}" GOARCH="${arc}" go build \
-ldflags "-s -w -X main.buildstamp=`git describe --tags`" -tags "${buildtag}" \
-o ./releases/tmp/tinode${ext} ./server > /dev/null
env GOOS="${plat}" GOARCH="${arc}" go build \
-ldflags "-s -w" -tags "${buildtag}" -o ./releases/tmp/init-db${ext} ./tinode-db > /dev/null
# Build archive. All platforms but Windows use tar for archiving. Windows uses zip.
if [ "$plat" = "windows" ]; then
# Remove possibly existing archive.
rm -f ./releases/${version}/tinode-${dbtag}."${plat}-${arc}".zip
# Generate a new one
pushd ./releases/tmp > /dev/null
zip -q -r ../${version}/tinode-${dbtag}."${plat}-${arc}".zip ./*
popd > /dev/null
else
plat2=$plat
# Rename 'darwin' tp 'mac'
if [ "$plat" = "darwin" ]; then
plat2=mac
fi
# Remove possibly existing archive.
rm -f ./releases/${version}/tinode-${dbtag}."${plat2}-${arc}".tar.gz
# Generate a new one
tar -C ./releases/tmp -zcf ./releases/${version}/tinode-${dbtag}."${plat2}-${arc}".tar.gz .
fi
done
done
# Build chatbot release
echo "Building python code..."
./build-py-grpc.sh
# Release chatbot
echo "Packaging chatbot.py..."
rm -fR ./releases/tmp
mkdir -p ./releases/tmp
cp ${GOSRC}/chat/chatbot/python/chatbot.py ./releases/tmp
cp ${GOSRC}/chat/chatbot/python/quotes.txt ./releases/tmp
cp ${GOSRC}/chat/chatbot/python/requirements.txt ./releases/tmp
tar -C ${GOSRC}/chat/releases/tmp -zcf ./releases/${version}/py-chatbot.tar.gz .
pushd ./releases/tmp > /dev/null
zip -q -r ../${version}/py-chatbot.zip ./*
popd > /dev/null
# Release tn-cli
echo "Packaging tn-cli..."
rm -fR ./releases/tmp
mkdir -p ./releases/tmp
cp ${GOSRC}/chat/tn-cli/*.py ./releases/tmp
cp ${GOSRC}/chat/tn-cli/*.txt ./releases/tmp
tar -C ${GOSRC}/chat/releases/tmp -zcf ./releases/${version}/tn-cli.tar.gz .
pushd ./releases/tmp > /dev/null
zip -q -r ../${version}/tn-cli.zip ./*
popd > /dev/null
# Clean up temporary files
rm -fR ./releases/tmp
popd > /dev/null
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
echo "Packaging python tinode-grpc..."
pushd ./pbx > /dev/null
# Generate grpc bindings from the proto file.
./py-generate.sh v=3
pushd ../py_grpc > /dev/null
# Generate version file from git tags
python3 version.py
# Generate tinode-grpc package
python3 -m build > /dev/null
popd > /dev/null
popd > /dev/null
+2
View File
@@ -0,0 +1,2 @@
The code in this folder and nested folders is licensed under Apache 2.0
http://www.apache.org/licenses/LICENSE-2.0
+5
View File
@@ -0,0 +1,5 @@
# Tinode ChatBot Examples
* [Python chatbot](python/)
* [Karuha](https://github.com/Visecy/Karuha) - third party chatbot framework.
* [C# .Net/.NetCore chatbot](https://github.com/tinode/csharpbot)
+3
View File
@@ -0,0 +1,3 @@
# Tinode Chatbot Example for .Net or .NetCore
Moved to a separate repo: https://github.com/tinode/csharpbot
+1
View File
@@ -0,0 +1 @@
.tn-cookie
+119
View File
@@ -0,0 +1,119 @@
# Tinode Chatbot
This is a simple chatbot for Tinode using [gRPC API](../../pbx/). It's written in Python as a demonstration
that the API is language-independent.
The chat bot subscribes to events stream using Plugin API and logs in to Tinode server as a regular user over gRPC interface (see `grpc_listen` in [tinode.conf](../../server/tinode.conf) file). The event stream API is used to listen for creation of new accounts. When a new account is created, the bot initiates a p2p topic with the new user. Then it listens for messages sent to the topic and responds to each with a random quote from `quotes.txt` file.
Generated files are provided for convenience in a [separate folder](../../py_grpc/tinode_grpc). You may re-generate them if needed:
```
python -m pip install grpcio-tools
python -m grpc_tools.protoc -../../pbx --python_out=. --grpc_python_out=. ../../pbx/model.proto
```
Chatbot expects gRPC binding to be provided as `tinode-grpc`. If you want to use them locally, first copy `model_pb2.py` and `model_pb2_grpc.py` to the same folder as `chatbot.py` then find the lines
```
from tinode_grpc import pb
from tinode_grpc import pbx
```
in `chatbot.py` and replace them with
```
import model_pb2 as pb
import model_pb2_grpc as pbx
```
## Installing and running
### Using PIP
#### Prerequisites
[gRPC](https://grpc.io/) requires [python](https://www.python.org/) 2.7 or 3.4 or higher.
Make sure [pip](https://pip.pypa.io/en/stable/installing/) 9.0.1 or higher is installed.
```
$ python -m pip install --upgrade pip
```
If you cannot upgrade pip due to a system-owned installation, you can run install it in a `virtualenv`:
```
$ python -m pip install virtualenv
$ virtualenv venv
$ source venv/bin/activate
$ python -m pip install --upgrade pip
```
#### Install dependencies:
```
$ python -m pip install -r requirements.txt
```
On El Capitan OSX, you may get the following error:
```
$ OSError: [Errno 1] Operation not permitted: '/tmp/pip-qwTLbI-uninstall/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/six-1.4.1-py2.7.egg-info'
```
You can work around this using:
```
$ python -m pip install tinode_grpc --ignore-installed
```
### Run the chatbot
Start the [tinode server](../../INSTALL.md) first. Then start the chatbot with credentials of the user you want to be your bot, `alice` in this example:
```
python chatbot.py --login-basic=alice:alice123
```
If you want to run the bot in the background, start it as
```
nohup python chatbot.py --login-basic=alice:alice123 &
```
Run `python chatbot.py -h` for more options.
If you are using python 2, keep in mind that `condition.wait()` [is forever buggy](https://bugs.python.org/issue8844). As a consequence of this bug the bot cannot be terminated with a SIGINT. It has to be stopped with a SIGKILL.
You can use cookie file to store credentials. Sample cookie files are provided as `basic-cookie.sample` and `token-cookie.sample`. Once authenticated the bot will store the token in the cookie file, `.tn-cookie` by default. If you have a cookie file with the desired credentials, you can run the bot with no parameters:
```
python chatbot.py
```
If the server is configured to use TLS, i.e. running as `httpS://my-server.example.com/`, the gRPC endpoint also uses the same SSL certificate. In that case add the `--ssl` option when starting the chatbot. If you want the chatbot to connect to the secure server over a local network or under a different name rather than the `my-server.example.com`, for instance as `localhost`, you must specify the SSL domain name to use, otherwise the server will not be able to find the right SSL certificate:
```
python chatbot.py --host=localhost:16060 --ssl --ssl-host=my-server.example.com
```
Quotes are read from `quotes.txt` by default. The file is plain text with one quote per line.
### Using Docker
**Warning!** Although the chatbot itself is less than 11KB, the chatbot Docker image is 175MB: the `:slim` Python 3 image is about 140MB, gRPC adds another ~30MB.
1. Follow [instructions](../../docker/README.md) to build and run dockerized Tinode chat server up to and including _step 3_.
2. In _step 4_ run the server adding `--env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true` and `--volume botdata:/botdata` to the command line:
1. **RethinkDB**:
```
$ docker run -p 6060:18080 -d --name tinode-srv --env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true --volume botdata:/botdata --network tinode-net tinode/tinode-rethink:latest
```
2. **MySQL**:
```
$ docker run -p 6060:18080 -d --name tinode-srv --env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true --volume botdata:/botdata --network tinode-net tinode/tinode-mysql:latest
```
3. **MongoDB**:
```
$ docker run -p 6060:18080 -d --name tinode-srv --env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true --volume botdata:/botdata --network tinode-net tinode/tinode-mongodb:latest
```
3. Run the chatbot
```
$ docker run -d --name tino-chatbot --network tinode-net --volume botdata:/botdata tinode/chatbot:latest
```
4. Test that the bot is functional by pointing your browser to http://localhost:6060/, login and talk to user `Tino`. The user should respond to every message with a random quote.
You may replace the `:latest` with a different tag. See all available tags here:
* [Tinode-MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/)
* [Tinode-RethinkDB tags](https://hub.docker.com/r/tinode/tinode-rethink/tags/)
* [Tinode-MongoDB tags](https://hub.docker.com/r/tinode/tinode-mongodb/tags/)
* [Chatbot tags](https://hub.docker.com/r/tinode/chatbot/tags/)
In general try to use docker images all with the same tag.
+1
View File
@@ -0,0 +1 @@
{"schema": "basic", "secret": "alice:alice123"}
+419
View File
@@ -0,0 +1,419 @@
"""Python implementation of a Tinode chatbot."""
# For compatibility between python 2 and 3
from __future__ import print_function
import argparse
import base64
from concurrent import futures
from datetime import datetime
import json
import os
try:
from importlib.metadata import version
except ImportError:
# Fallback for Python < 3.8
from importlib_metadata import version
import platform
try:
import Queue as queue
except ImportError:
import queue
import random
import signal
import sys
import time
import grpc
from google.protobuf.json_format import MessageToDict
# Import generated grpc modules
from tinode_grpc import pb
from tinode_grpc import pbx
# For compatibility with python2
if sys.version_info[0] >= 3:
unicode = str
APP_NAME = "Tino-chatbot"
APP_VERSION = "1.2.3"
LIB_VERSION = version("tinode_grpc")
# Maximum length of string to log. Shorten longer strings.
MAX_LOG_LEN = 64
# User ID of the current user
botUID = None
# Dictionary wich contains lambdas to be executed when server response is received
onCompletion = {}
# This is needed for gRPC ssl to work correctly.
os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
def log(*args):
print(datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], *args)
# Add bundle for future execution
def add_future(tid, bundle):
onCompletion[tid] = bundle
# Shorten long strings for logging.
def clip_long_string(obj):
if isinstance(obj, unicode) or isinstance(obj, str):
if len(obj) > MAX_LOG_LEN:
return '<' + str(len(obj)) + ' bytes: ' + obj[:12] + '...' + obj[-12:] + '>'
return obj
elif isinstance(obj, (list, tuple)):
return [clip_long_string(item) for item in obj]
elif isinstance(obj, dict):
return dict((key, clip_long_string(val)) for key, val in obj.items())
else:
return obj
def to_json(msg):
return json.dumps(clip_long_string(MessageToDict(msg)))
# Resolve or reject the future
def exec_future(tid, code, text, params):
bundle = onCompletion.get(tid)
if bundle != None:
del onCompletion[tid]
try:
if code >= 200 and code < 400:
arg = bundle.get('arg')
bundle.get('onsuccess')(arg, params)
else:
log("Error: {} {} ({})".format(code, text, tid))
onerror = bundle.get('onerror')
if onerror:
onerror(bundle.get('arg'), {'code': code, 'text': text})
except Exception as err:
log("Error handling server response", err)
# List of active subscriptions
subscriptions = {}
def add_subscription(topic):
subscriptions[topic] = True
def del_subscription(topic):
subscriptions.pop(topic, None)
def subscription_failed(topic, errcode):
if topic == 'me':
# Failed 'me' subscription means the bot is disfunctional.
if errcode.get('code') == 502:
# Cluster unreachable. Break the loop and retry in a few seconds.
client_post(None)
else:
exit(1)
def login_error(unused, errcode):
# Check for 409 "already authenticated".
if errcode.get('code') != 409:
exit(1)
def server_version(params):
if params == None:
return
log("Server:", params['build'].decode('ascii'), params['ver'].decode('ascii'))
def next_id():
next_id.tid += 1
return str(next_id.tid)
next_id.tid = 100
# Quotes from the fortune cookie file
quotes = []
def next_quote():
idx = random.randrange(0, len(quotes))
# Make sure quotes are not repeated
while idx == next_quote.idx:
idx = random.randrange(0, len(quotes))
next_quote.idx = idx
return quotes[idx]
next_quote.idx = 0
# This is the class for the server-side gRPC endpoints
class Plugin(pbx.PluginServicer):
def Account(self, acc_event, context):
action = None
if acc_event.action == pb.CREATE:
action = "created"
# TODO: subscribe to the new user.
elif acc_event.action == pb.UPDATE:
action = "updated"
elif acc_event.action == pb.DELETE:
action = "deleted"
else:
action = "unknown"
log("Account", action, ":", acc_event.user_id, acc_event.public)
return pb.Unused()
queue_out = queue.Queue()
def client_generate():
while True:
msg = queue_out.get()
if msg == None:
return
log("out:", to_json(msg))
yield msg
def client_post(msg):
queue_out.put(msg)
def client_reset():
# Drain the queue
try:
while queue_out.get(False) != None:
pass
except queue.Empty:
pass
def hello():
tid = next_id()
add_future(tid, {
'onsuccess': lambda unused, params: server_version(params),
})
return pb.ClientMsg(hi=pb.ClientHi(id=tid, user_agent=APP_NAME + "/" + APP_VERSION + " (" +
platform.system() + "/" + platform.release() + "); gRPC-python/" + LIB_VERSION,
ver=LIB_VERSION, lang="EN"))
def login(cookie_file_name, scheme, secret):
tid = next_id()
add_future(tid, {
'arg': cookie_file_name,
'onsuccess': lambda fname, params: on_login(fname, params),
'onerror': lambda unused, errcode: login_error(unused, errcode),
})
return pb.ClientMsg(login=pb.ClientLogin(id=tid, scheme=scheme, secret=secret))
def subscribe(topic):
tid = next_id()
add_future(tid, {
'arg': topic,
'onsuccess': lambda topicName, unused: add_subscription(topicName),
'onerror': lambda topicName, errcode: subscription_failed(topicName, errcode),
})
return pb.ClientMsg(sub=pb.ClientSub(id=tid, topic=topic))
def leave(topic):
tid = next_id()
add_future(tid, {
'arg': topic,
'onsuccess': lambda topicName, unused: del_subscription(topicName)
})
return pb.ClientMsg(leave=pb.ClientLeave(id=tid, topic=topic))
def publish(topic, text):
tid = next_id()
return pb.ClientMsg(pub=pb.ClientPub(id=tid, topic=topic, no_echo=True,
head={"auto": json.dumps(True).encode('utf-8')}, content=json.dumps(text).encode('utf-8')))
def note_read(topic, seq):
return pb.ClientMsg(note=pb.ClientNote(topic=topic, what=pb.READ, seq_id=seq))
def init_server(listen):
# Launch plugin server: accept connection(s) from the Tinode server.
server = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
pbx.add_PluginServicer_to_server(Plugin(), server)
server.add_insecure_port(listen)
server.start()
log("Plugin server running at '"+listen+"'")
return server
def init_client(addr, schema, secret, cookie_file_name, secure, ssl_host):
log("Connecting to", "secure" if secure else "", "server at", addr,
"SNI="+ssl_host if ssl_host else "")
channel = None
if secure:
opts = (('grpc.ssl_target_name_override', ssl_host),) if ssl_host else None
channel = grpc.secure_channel(addr, grpc.ssl_channel_credentials(), opts)
else:
channel = grpc.insecure_channel(addr)
# Call the server
stream = pbx.NodeStub(channel).MessageLoop(client_generate())
# Session initialization sequence: {hi}, {login}, {sub topic='me'}
client_post(hello())
client_post(login(cookie_file_name, schema, secret))
return stream
def client_message_loop(stream):
try:
# Read server responses
for msg in stream:
log(datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], "in:", to_json(msg))
if msg.HasField("ctrl"):
# Run code on command completion
exec_future(msg.ctrl.id, msg.ctrl.code, msg.ctrl.text, msg.ctrl.params)
elif msg.HasField("data"):
# log("message from:", msg.data.from_user_id)
# Protection against the bot talking to self from another session.
if msg.data.from_user_id != botUID:
# Respond to message.
# Mark received message as read
client_post(note_read(msg.data.topic, msg.data.seq_id))
# Insert a small delay to prevent accidental DoS self-attack.
time.sleep(0.1)
# Respond with a witty quote
client_post(publish(msg.data.topic, next_quote()))
elif msg.HasField("pres"):
# log("presence:", msg.pres.topic, msg.pres.what)
# Wait for peers to appear online and subscribe to their topics
if msg.pres.topic == 'me':
if (msg.pres.what == pb.ServerPres.ON or msg.pres.what == pb.ServerPres.MSG) \
and subscriptions.get(msg.pres.src) == None:
client_post(subscribe(msg.pres.src))
elif msg.pres.what == pb.ServerPres.OFF and subscriptions.get(msg.pres.src) != None:
client_post(leave(msg.pres.src))
else:
# Ignore everything else
pass
except grpc._channel._Rendezvous as err:
log("Disconnected:", err)
def read_auth_cookie(cookie_file_name):
"""Read authentication token from a file"""
cookie = open(cookie_file_name, 'r')
params = json.load(cookie)
cookie.close()
schema = params.get("schema")
secret = None
if schema == None:
return None, None
if schema == 'token':
secret = base64.b64decode(params.get('secret').encode('utf-8'))
else:
secret = params.get('secret').encode('utf-8')
return schema, secret
def on_login(cookie_file_name, params):
global botUID
client_post(subscribe('me'))
"""Save authentication token to file"""
if params == None or cookie_file_name == None:
return
if 'user' in params:
botUID = params['user'].decode("ascii")[1:-1]
# Protobuf map 'params' is not a python object or dictionary. Convert it.
nice = {'schema': 'token'}
for key_in in params:
if key_in == 'token':
key_out = 'secret'
else:
key_out = key_in
nice[key_out] = json.loads(params[key_in].decode('utf-8'))
try:
cookie = open(cookie_file_name, 'w')
json.dump(nice, cookie)
cookie.close()
except Exception as err:
log("Failed to save authentication cookie", err)
def load_quotes(file_name):
with open(file_name) as f:
for line in f:
quotes.append(line.strip())
return len(quotes)
def run(args):
schema = None
secret = None
if args.login_token:
"""Use token to login"""
schema = 'token'
secret = args.login_token.encode('ascii')
log("Logging in with token", args.login_token)
elif args.login_basic:
"""Use username:password"""
schema = 'basic'
secret = args.login_basic.encode('utf-8')
log("Logging in with login:password", args.login_basic)
else:
"""Try reading the cookie file"""
try:
schema, secret = read_auth_cookie(args.login_cookie)
log("Logging in with cookie file", args.login_cookie)
except Exception as err:
log("Failed to read authentication cookie", err)
if schema:
# Load random quotes from file
log("Loaded {} quotes".format(load_quotes(args.quotes)))
# Start Plugin server
server = init_server(args.listen)
# Initialize and launch client
client = init_client(args.host, schema, secret, args.login_cookie, args.ssl, args.ssl_host)
# Setup closure for graceful termination
def exit_gracefully(signo, stack_frame):
log("Terminated with signal", signo)
server.stop(0)
client.cancel()
sys.exit(0)
# Add signal handlers
signal.signal(signal.SIGINT, exit_gracefully)
signal.signal(signal.SIGTERM, exit_gracefully)
# Run blocking message loop in a cycle to handle
# server being down.
while True:
client_message_loop(client)
time.sleep(3)
client_reset()
client = init_client(args.host, schema, secret, args.login_cookie, args.ssl, args.ssl_host)
# Close connections gracefully before exiting
server.stop(None)
client.cancel()
else:
log("Error: authentication scheme not defined")
if __name__ == '__main__':
"""Parse command-line arguments. Extract server host name, listen address, authentication scheme"""
random.seed()
purpose = "Tino, Tinode's chatbot."
log(purpose)
parser = argparse.ArgumentParser(description=purpose)
parser.add_argument('--host', default='localhost:16060', help='address of Tinode server gRPC endpoint')
parser.add_argument('--ssl', action='store_true', help='use SSL to connect to the server')
parser.add_argument('--ssl-host', help='SSL host name to use instead of default (useful for connecting to localhost)')
parser.add_argument('--listen', default='0.0.0.0:40051', help='address to listen on for incoming Plugin API calls')
parser.add_argument('--login-basic', help='login using basic authentication username:password')
parser.add_argument('--login-token', help='login using token authentication')
parser.add_argument('--login-cookie', default='.tn-cookie', help='read credentials from the provided cookie file')
parser.add_argument('--quotes', default='quotes.txt', help='file with messages for the chatbot to use, one message per line')
args = parser.parse_args()
run(args)
+322
View File
@@ -0,0 +1,322 @@
login: 
$3,000,000
1 bulls, 3 cows
A Cray is the best machine for simulating the performance of a Cray.
A consistent indentation style is the hobgoblin of little minds.
A gentleman is one who is never rude unintentionally. -Noel Coward
A man must destroy himself before others can destroy him. -Mong Tse
A philosopher does not need a torch to gather glow-worms by at mid-day. --Earnest Bramah
A song in time is worth a dime.
A woman is only a woman, but a good cigar is a smoke. -Rudyard Kipling
Admiration is our polite recognition of another's resemblance to ourselves.
All the good ones are taken.
An atheist is a man with no invisible means of support.
Any country with "democratic" in the title isn't.
Are we not men?
Attend winter sheep meetings. Learning never ends!
Be the sea, and see me be.
Beware: the light at the end of the tunnel may be New Jersey.
Bubble bubble, toil and trouble; cast that float into a double.
C'est dommage, mais c'est vrai.
Caution: Do not view laser light with remaining eye.
Cogito cogito ergo cogito sum.
Crazee Edeee, his prices are INSANE!!!
Death to all fanatics!
Disk crisis, please clean up!
Do not meddle in the mouth.
Don't be overly suspicious where it's not warranted.
Don't let your thoughts get in a rut. The knife which spreads may also cut.
Don't worry if it doesn't work right; if everything did, you'd be out of a job.
E Pluribus Unix.
Either we are alone or we are not. Either way is mind-boggling.
Even these days, it's not as easy to go crazy as you think.
Everybody should believe in something -- I believe I'll have another drink.
Exercise is the Yuppie version of bulemia.
Far too noisy, my dear Mozart. Far too many notes. -Emperor Ferdinand.
First things first. Why not send for the Nazis right now.
Fortran est; non potest legi.
Generalizations are useful. The work contained in them can be reckoned as labor
God does not play dice.
Good day to avoid cops. Crawl to work.
Great shot, kid. That was one in a million.
Have you done your Christmas chopping yet? -anon. White House Advisor 12/24/81
He was al coltissh, ful of ragerye,/And ful of jargon as a flekke pye. -Chaucer
He who listens last is the last one listening.
History is a race between education and catastrophe. -H. G. Wells
How do I love thee? Hand me my calculator...
I am a high-pressure guy, and I didn't take this job to conduct a going-out-of-business sale. - A.A. Penzias
I don't even know what street Canada is on. - Al Capone
I have the most perfect confidence in your indiscretion.
I must have slipped a disk; my pack hurts.
I think that I shall never see a billboard as lovely as a tree. -Ogden Nash
I'd rather have my mail delivered by Lockheed than ride in a plane built by the Post Office.
IOT trap -- core dumped
If I had to choose between System V and 4.2, I'd resign. - Peter Honeyman
If butterflies had teeth like tigers they would never make it out of the hangar.
If it's not broken, don't fix it.
If the shoe fits, buy the other one, too.
If you don't care where you are, then you ain't lost.
If you take the last cup, make a new pot.
If your experiment needs statistics, you ought to have done a better experiment. - E. Rutherford
In challenging a kzin, a simple scream of rage is sufficient.
In this world, truth can wait; she's used to it.
It is better to have loved and lost than just to have lost.
It is useless to put on your brakes when you're upside down. -Paul Newman
It's a small world, but I'd hate to have to paint it.
It's hard to love someone who looks down on you because your hands get bloody protecting him.
It's not the time between the takes that takes the time - it's changing your mind between the takes that takes the time. -Stage Dept.
Join me and I will complete your training.
Lando's not a system, he's a man. He's a gambler, scoundrel. You'd like him.
Let sleeping wraiths lie.
Like winter snow on summer lawn, time past is time gone.
Lose a few, lose a few.
Macro context switch under way, please do not log out!
Man is in doubt to deem himself a god or beast. -Alexander Pope
May you live all the days of your life.
Mind your own business, Spock. I'm sick of your halfbreed interference.
Multilevel standards are like onions. They're smelly and make you cry a lot. -Ron Natalie
Never attribute to malice what can be found in scientific american, under computer recreations.
New career ideas are worth pursuing.
No man's life, liberty or property are safe while the legislature is in session.
Non serviam.
Nothing of interest ever happened on this day.
Oh, so there you are!
One scythe fits all.
Otto's too so-so to toss soot, too sot to toot SOSs. So?
People get it into their heads that this is a democracy. Well it isn't. -gwl
Personality is a flimsy thing on which to build an art. -John Cage
Populus vult decipi.
Promptness is its own reward, if one lives by the clock instead of the sword.
Real programmers can't say `lint' without adding `hbaxcu' -Wm Leler
Remember the Unknown Buffalo.
Rog-O-Matic callidus est.
Say "no" to long-sleeved shirts!! Support your right to bare arms!
Señor, if you hurry from here, you will wait longer there. -Mexico taxi driver
Smoked carp is terrible unless you're out of smoked salmon.
Some men are discovered; others are found out.
Specialization is for insects. -Robt. A. Heinlein
Stop searching. Happiness is right next to you.
System going down for 5 minutes -- back up in barrels.
Technological unemployment is total today -- 300 years ago we were all farmers.
That's the nice thing about standards -- there's so many to choose from. -trb
The Luddites always lose. Always.
The average legislator is somewhere nearly all the time. -Herb Nore
The dawning of the Information Age is bringing about dramatic changes in the fundamental fabric of our civilization. - AA Penzias
The first piece of luggage out of the chute doesn't belong to anyone, ever.
The hippo has no sting, but the wise man would rather be sat upon by the bee.
The meek shall inherit the earth -- they are pronounced "o".
The one interesting fact about the Diplodocus is that the accent is on the second syllable.
The plural of spouse is spice.
The skeletons in the cupboard will all come out in the wash.
The universe is laughing behind your back.
Them that dishes it out need not fall over every time someone blows hard.
There is no fear in love; but perfect love casteth out fear.
There'll always be an England - if not it would be necessary to invent one.
These widows, sir, are the most perverse creatures in the world. - Joseph Addison.
Things will be bright in P.M. A cop will shine a light in your face.
This space available. Call 686-7600 for details.
Those who in quarrels interpose must often wipe a bloody nose.
To be is to be related.
To play billiards well is a sign of an ill-spent youth.
Toto, I've a feeling we're not in any immediate danger of having just committed suicide!
Try a new system or a different approach.
Uneasy lies the head that wears a crown.
Veni, vidi, maeni, mo, cacha tigrem baedas to, iffi hollers, ledem go, veni, vidi, maeni, mo.
Warning: this fortune may change your life.
We don't know half of what we know.
We retard what we cannot repel, we palliate what we cannot cure. -Johnson
We're too close to System Test.
What garlic is to salad, insanity is to art.
When all else fails, read the instructions.
When in trouble or in doubt, run in circles; scream and shout.
Whenever I see his fingernails, I thank God I don't have to look at his feet.
Whom the gods must destroy they first must drive insane.
Without alkaloids, life itself would be impossible.
Yes, the red switch.
You can tell a man by the company that keeps him.
You cannot buy beer; you can only rent it.
You have bills.
You look strong enough to pull the ears off a Gundark!
You should go home.
You will never find a more wretched hive of scum and villainy.
Your computer account is overdrawn. Please reauthorize.
Zero is greater than minus zero, but don't ask by how much. -6600 ref. manual
`is false when preceded by its quotation' is false when preceded by its quotation.
fortune: not found
pic: 5 X 58008 picture shrunk to 0.000603365 X 7
uuxqt cmd (rnews ) status (ucsfcgl!uucp 256)
In days of yore, the crab and the crayfish lived in the forest.
* Method As described above, see details below.
Whatever happens, happens because it must.
Boost, don't knock
pass 2 error:(file ) more than 100 args?
Nepal premier won't resign.
init: /dev/console: getty failing, sleeping
Sentence without verb.
That's the way I got promoted, by eating everything. -pjw
Pittsburgh has become a kind of knowledge aircraft carrier, its "top-guns" scattered regularly around the planet.
When the music stops, the house of cards collapses and the emperor is found to be wearing no clothes.
Never put snow on a frostbitten part.
Put a smoke detector in your vacation cottage.
Draw up a family fire-escape plan.
A mathematician is a machine for turning coffee into theorems. -Paul Erdös
I'm TRYING to be a back end! - A Hume
There are only 26 calls and most of them are trivial.
162 is unimplemented
Incest more common than thought in United States
The product classroom is marked pass-fail.
ISDN is real and implementable.
To dissimulate is to feign not to have what one has. - J Baudrillard
Vacuums are nothings. We only mention them to let them know we know they're there.
Two wrongs don't make a right; three lefts do.
?12 Machine check during machine check.
The downside of having an architecture is wart-for-wart compatibility. - Bob Willard, DEC
It doesn't matter if you don't know how your program works, so long as it's parallel -R. O'Keefe
sendmail[94] AA00493: SYSERR: net hang reading from coma: Connection timed out during greeting wait with coma
Performance doesn't matter if your product is sufficiently feature-rich. --SF system engineer
PLEASE LOG IN TO 3B20'S AT 4800 BAUD.
SQUASH, do not crush (seen on a vegetable crate)
If you get to meet sufficently important people, it's ok to debase yourself. -pjw
The system is ready.
A watermelon will not ripen in your armpit.
Contrary to English and other similar languages, Turkish can be hyphenated with a simple 4 state finite-state machine.
nop...session...attach...clone...walk...open...
Spare me your sorrow's tears.
He is one inch good, one foot evil.
Heaven cannot use two suns or a house two masters.
To give ground is sometimes the best victory.
No word can cut kindness.
Let wisdom and virtue be the two wheels of your cart.
Willow branches never snap under the weight of snow.
Only a monkey tries to catch the full moon in the pond.
Don't lug dirt to a hilltop.
Don't paint on water or carve on ice.
The nail that raises its head is hammered down.
Who can tell the he-crow from his mate?
He is wise who knows what is enough.
His hand was bitten by his own dog.
To kill a general first shoot his horse.
What is left unsaid is rich as flowers.
If you are in a hurry go round-about.
Better be ignorant than mistaught.
You can't judge widows or horses without handling them.
Don't use the ox-cleaver to kill a hen.
Two hearts: and only one body.
Bread is better than blossoms.
Good medicine has often a bitter smack.
First among blossoms the cherry: among men the warrior.
You can't wrap up the wind or tie down the shadow.
You cannot live in the same world with your father's murderer.
Even a starving hawk won't lower himself to eat corn.
Keep your mouth shut, your eyes open.
Some ride in palanquins, some bear palanquins: some weave sandals for palanquin-bearers.
Tuning filesystem for rot 0...
Two things will make you lose your earrings, and one of them's dancing. -Bonnie Raitt.
When the humans are away, the monkeys enter the hut, eat up the maize, and rearrange the furniture.
The problem is not getting ksh to execute any particular command, the problem is recognizing that there might be a problem.
diff: usage diff [whatever] etc.
Intense opportunities for reorganization
Stringent ideas in the upper echelon mind
Leveraged brainpower at the labs
>>>>>>> REMOVE ALL YOUR FILES AND DIRECTORIES NOW! <<<<<<<
UX:lp: ERROR: Can't establish contact with the LP print service.
Awk is one of the world's greatest collections of surprises. -Doug McIlroy
If you think awk is the perfect programming language for the problem, you don't understand the problem yet. -Rob Pike
``Workers of the World, forgive us!'' (a banner in a Moscow counter-rally, Oct 8, 1989)
The first step is to determine what the remaining steps are. -Mark Horton
If you do something stupid on UNIX you generally get strange behavior. -Doug Gwyn
I'd still like you to explain that worm to me - Judge Munson to Robert T. Morris
Like raisins in a bread pudding, the moments lie within the body of Henry.
A real gentleman never takes bases unless he really has to.
There are two proteins involved in DNA synthesis, they are called DNAsynthase 1 and DNAsynthase 3.
Drawing on my fine command of the English language, I said nothing. -Robert Benchley
Hay, be seedy! He-effigy, hate-shy jaky yellow man, oh peek, you are rusty, you've edible, you ex-wise he!
The FSF is not overly concerned about security. - FSF
Make: Don't know how your program works
OSIfy, v.: To make code impenetrable.
Rule 3: If the character is comprised of a container without another radical, then
An economic reality of our time: computerized job deskilling. - a book review in Science
Estne ebriamen de furfure avenaceo factum?
The isomer with the higher dipole moment has the higher physical constants, regardless of the heat content. Van Arkel Rule
Other factors being equal, the metal which is most susceptible to failure is that with the lowest boiling point. Mogro-Campero Rule
The solid particle erosion rate of annealed face-centered cubic metals is inversely related to their hardness. Finnie-Wolak-Kabil Rule
In dichroic crystals, the faster ray is less absorbed. Babinet Rule
If you can't stand the heat, get a pool.
A rolling stone is a singing rock group.
UX:mail: INFO: No mail.
Real software has its own 800 support line. - Stu Feldman
Marine math: 2 beers times 39 Marines is 49 cases.
When times are bad, people feel compelled to overeat.
Of the physical pages in use, 3436738592 pages are permanently allocated to VMS.
If you carry on, head-to-head, on `solution' you don't get anywhere
This is like ignoring both the speed limit and the odometer in your car. It won't get you far. -Kenneth P. Birman
/bin/ls: exec header invalid
My pile of equipment is bigger than your pile of equipment -- philw
Connected to 192.65.218.43.
as1: Error: ../bpvvv.c, line 1324: Too many float literals--compile with "-Wb,-nopool"
The helpful thought for which you look/Is written somewhere in a book.
Is the tool broadly supported or maintained?
I'm pulling *something* here. - Dom Marotta
I'm drawing a line under the sand. - John Major
They bit the wrong chicken's head off with their own teeth and got blood all over their shirt - nls
I just want a bare-boned, straight EMACS. - Rae McLellan
*** Message content is not printable: delete, write or save it to a file ***
1181258 SAVECORE SEGMENTATION FAULT WHILE TRYING TO SAVE CORE
Everything that can ever be invented has been invented -Charles H. Duell,
Dan Quayle addressing the United Negro College Fund
Here in Nuremberg, information hiding is much more popular in the organization than it is in the software...
License Error : The license for this product(SPARCompiler C) has expired
My favorite thing about the Internet is that you get to go into the private world of real creeps without having to smell them. - Penn Jillette
Music is the pleasure the human soul experiences from counting without being aware that it is counting. -Leibnitz
Millenium parties/with loud music, lights and joy;/then, how cold!
warning: Hit heuristic-fence-post without finding enclosing function for address 0xfa2e470
War in the Clinton era is just P.R. by other means. - Michael Hirschorn
Application Developer's Architecture Guide
! TeX capacity exceeded, sorry [main memory size=263001].
His hand was bitten by his own side in an offhand way. - Mark V. Shaney
Sperm bank sued for tossing samples - Headline in Star-Ledger
boot: nop...cfs...session...no physical memory
Computers come in putty-colored boxes and have AUTOEXEC.BAT files and run screen-savers with flying toasters, and brains do not. - Steven Pinker
We have discovered a pervasive nonstationarity.
verb = a > b ? 'a': c > d ? 'd': 'c';
It's not only stupid it's wrong!
Lucky Numbers 12, 14, 19, 24, 36, 43
The goal of the experimental trials with the artificial heart is to "double the life span of these patients" to 60 days, Lederman said.
canlock: corrupted 0xcafebabe
Devil Duckie, when you float, it's like I'm bathing in a flaming moat!
He lost both Ali-pilaf and Vali-pilaf.
He is a man who gives bread.
We cut salt and bread together.
You can't make stew with cheap meat.
Absquotilate it in style, you old skunk,..and show the gentlemen what you can do.
Cookie import from '/usr/dhog/lib/cookies' failed: fil
Fortunately, Mac OS X supports *that* feature! - Brendan Connell
The full documentation for cat is maintained as a Texinfo manual. If the info and cat programs are properly installed at your site, the command "info cat" should give you access to the complete manual.
That's nothing.. RMS sent me a .doc file the other day...
Notegroups! They kill you!
Welcome, rqzgc_mfuv8
Isn't it funny how people say they'll never grow up to be their parents, then one day they look in the mirror and they're moving aircraft carriers into the Gulf region? - the Onion
Where would Christianity be if Jesus got eight to fifteen years, with time off for good behavior? -- New York Senator James H. Donovan on capital punishment.
Few, few the bird make her nest.
To become of bishop miller.
The dress don't make the monk.
He is mad to bind.
He turns as a weath turcocl.
After the paunch comes the dance.
Linux programmer's mind: don't invalidate the D-cache unless it wasn't enabled
Practically noiseless and impossible to explode. - ad for the 1897 Oldsmobile
The essence of XML is this: the problem it solves is not hard, and it does not solve the problem well. - Phil Wadler, POPL 2003
If you are idle for more than 1000 hours, the system will log you out. Please save reviews frequently.
Setting up your SIP account will allow you to call both other SIP users as well as pstn phones. - Wim Sweldens
What's grey? A melted penguin.
Linux: the world's best text adventure game.
i know what jmk did. he added reentrancy for threads. - boyd, about uintptr
I recently visited plan9.bell-labs.com/wiki/plan9 and I would like to offer my web design services. I can help you with your Plan 9 website. - spam
gcc is the holy cow of compilers, not the holy grail. - forsyth
we live in a world of dogmas, probably trying to fight it with a stronger dogma is a bad idea, but trying to fight it with preumpted objectivism clearly doesn't work. - uriel
Lucent is the best place 4 me to work. (Please keep confidential)
Subject: There's More to Nevada Than You Think
/* keep the code below somewhat more readonable; not used elsewhere */
swapon: /dev/disk/by-uuid/928aaabd-5744-4f0a-b9ef-aa101f286514: Invalid argument - Linux
I guess the idea is that ... even though the structures are all different.
rusty: kernel pseudo files are not the place for chit-chat
+4
View File
@@ -0,0 +1,4 @@
futures>=3.2.0; python_version<'3'
grpcio>=1.40.0
tinode-grpc>=0.20.0b3
importlib-metadata>=1.0; python_version<'3.8'
+34
View File
@@ -0,0 +1,34 @@
import setuptools
from subprocess import Popen, PIPE
with open('README.md', 'r') as fh:
long_description = fh.read()
setuptools.setup(
name="tinode-chatbot",
version=git_version(),
author="Tinode Authors",
author_email="info@tinode.co",
description="Tinode demo chatbot.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/tinode/chat",
packages=setuptools.find_packages(),
install_requires=['grpcio>=1.40.0'],
classifiers=(
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache 2.0",
"Operating System :: OS Independent",
),
)
def git_version():
try:
p = Popen(['git', 'describe', '--tags'],
stdout=PIPE, stderr=PIPE)
p.stderr.close()
line = p.stdout.readlines()[0]
return line.strip()
except:
return None
+1
View File
@@ -0,0 +1 @@
{"schema": "token", "secret": "mtXWlt9ERZCKsw9aFAABAFGGCnxinE8ruLE21t6SQfck4uBKCIy44kerjmOh4h1+", "expires": "2017-11-18T04:14:02Z"}
+214
View File
@@ -0,0 +1,214 @@
这些图片中的内容是关于IM即时通讯的软件,你识别图片中的内容,整理为需求文档,包含图片,功能说明
上面是一张图片,下面就是说明,功能的标题就是图片的名称,说明的时候将功能相关的放一起,我希望是这样的
1.相关功能图片的总体功能(需要你总结一个标题)
1.1 图片名称
图片
功能说明:
图片内容的描述
写成一个md文档,要在md文档中可以看到图片
密码aes 加解密 密码能够加解密
聊天内容加密
聊天内容的监控
用户的类别
后台生成的有标识
登录设备做标识 移动
登基日志 连续N天设备登录
多通过配置的方式处理 前端不要写死
一定要考虑好群聊的推送问题
离线和在线状态存到redis里面 这个是一个难点
离线的我们的服务不推 走极光的推送
在线的直接ws推
多用开关
校验营业执照
第三方
短信
kyc
营业执照
声网 Agora 音视频服务
极光推送 离线 Push 服务
oss对象存储
文件安全扫描 / 内容安全服务
admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
记录先放到Kafka、 再异步落库。用考虑重复落库的可能
1. 有 IM 即时通讯项目开发经验优先;
2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理;
3. 熟悉群消息扩散策略,如读扩散、写扩散;
4. 理解多端同步、消息漫游、消息推送等 IM 核心机制;
5. 有 OpenIM 或类似 IM 项目经验优先。
1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行
2.验证码验证作为开关 关闭的情况下的话后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码
3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册
这个是现在的情况
后台开关在系统设置 字典管理 增加一个系统枚举 系统开关的 ID = 171
用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段
看看对接文档1.4的注册页面 增加逻辑 1.注册邀请码 (非必填 )不填邀请码可以正常注册。用真实手机号注册的,且后台发送注册验证码的开关打开的情况下,可以收到验证码的流程也能正常注册
2.发送注册验证码的开关关闭的情况下,后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码,此时填了邀请码,且邀请码校验合格的话,没有验证码仍然可以注册
我看了很多users相关的表 你总结一下分别是什么作用
你看看手机号是如何存的
im_users的 tags 有啥用 存的啥
昵称用中文实现
对一样的,因为给好友改了备注,所以上面就会显示,但是昵称是好友,本身自己起的网名,如果对方没有起,系统会默认给一个
用户名和手机号注册的话 如果验证码开关是开的 以验证码为准 如果没开 就以邀请码流程 用户名不能忽略 要保存
前后端对接文档 补充 请求和响应的json
文档的覆盖测试
发的网站功能的复制
web地址:https://web.xcwykk.top/
管理后台地址:https://admin.adfahi.cn
账号:heiyue
密码:Heiyue123
测试名额:100
群操作
文件操作
redis的接入 性能提升
kafka的接入 异步操作 幂等性
弱网
QUIC传输技术
日志框架的接入
重中之重。加密 + 接口鉴权 防止被扫 日志要记录下来 每个小时告警
法相 (三方IM 资金电话确认), [Aug 27, 2026 at 22:13:24]:
....进群的时候拿群成员列表信息 这个怎么做成缓存 然后怎么更新缓存
2.多标签要独立的登录状态,可以用不同域名来解决这个问题,但是要token过期时间要限制一下 7天或者10天 每次登录都重置这个时间
3.转发消息 涉及到转发群和转发个人 大批量转发 消息会不会串
4.我在第一个标签转发了100个群,然后直接切换到第二个标签,你第一个标签还没转发完 你该怎么处理 会不会串到第二个标签页
群成员信息这个是很耗性能的 一个群几千个人 你要考虑是进一次就拿一次群成员信息 还是拿一次
网页上一个tab一个用户
我说的进不是给你拉进群 是你点击群聊这个会话框
敏感词 开关
组织机构树的处理逻辑 我现在处理逻辑
1,手机号注册,或者上架用的测试账号 可以看到好友的组织结构,
2,通过邀请进来的 只能看到自己
对了,强调一点,移动端所有加好友的入口都是需要后端控制的,管理后台需要开关控制能不能加好友。
然后还要增加宵禁功能,宵禁功能是单聊私聊都不能发消息。这个也是管理后台控制的。
tinode 移除
监控接口
制定联调计划
多租户的测试
不能全存
图片只存oss地址 给前端的是oss地址 文件也是oss地址
本地只存100条 历史分页50条拉
群聊要跨月建表
聊天的菜单元素是前端写死的么
群成员变动发消息 然后拉取 群成员 要考虑何时全部 何时只是一个
只有加入的群管理消息都可见 其他的比如移除啥的 只有管理可见
离线用户的推送 未读消息数的处理 每个用户的不一样 群成员每个消息的推送
前端返回一个现有序号 后端跟最新序号比对 然后一减 就是未读消息
全局禁止加好友 比如下班时间到了 不能加 从群成员去看 不能显示个人信息 个人名片也不能看 二维码也不能加
阿里云的oss放一个域名清单 要加密 apikey动态更换
设备类型 ip 全部采集入库
离线时间 和上线时间的采集
检查一下多处存同一个信息的。尤其是用户的
音视频要加上开关 控制前端的显示 已经处理
拆成工作台 + IM
文案部分全部拆出来 做配置
个人注册的企业码是默认system 不需要企业码的位置
先是企业合作,填写信息,审核之后得到企业码
企业注册的要带上企业码
kafka批量消费 批量落库
1.进群的时候拿群成员列表信息 这个怎么做成缓存 然后怎么更新缓存
2.多标签要独立的登录状态,可以用不同域名来解决这个问题,但是要token过期时间要限制一下 7天或者10天 每次登录都重置这个时间
3.转发消息 涉及到转发群和转发个人 大批量转发 消息会不会串
4.我在第一个标签转发了100个群,然后直接切换到第二个标签,你第一个标签还没转发完 你该怎么处理 会不会串到第二个标签页
新增图片的功能 ??
拉一下git提交记录 已经处理
多实例 断线重连 其他ws的坑
### 1. Fitness-Food(GitHub,MIT 协议|中文友好!重点推荐)
🔗 [https://github.com/saudademjj/fitness-food](https://github.com/saudademjj/fitness-food)
- 定位:**中文智能饮食管理系统**,完全匹配你的健身 APP 饮食模块
- 自带接口能力:
- 输入中文食物名称,返回全套营养素(热量 / 蛋白 / 碳水 / 脂肪、微量元素)
- 三餐记录、每日营养汇总、目标对比(减脂 / 增肌)
- PostgreSQL 内置中文食物库
- 技术栈:Next.js + Postgres,可以单独拆出后端 API 给你的 APP 调用
- 协议:MIT,可商用,二次开发无压力
我打算弄一个饮食健康的产品,主要是想记录三餐,目标对比,返回食物热量
默认system企业
内容审核要根据企业来分。做成开关 新建企业的时候加上
用户要和商户绑定 进去只能看某一个商户的数据 这个要改造 要从多商户的角度思考系统还有哪些问题
调研:
阿里云上传apk包 下载下来就装不上
各个厂商push的申请
新加一个状态,消息免打扰后,对方发消息,红点这么显示
Bundle ID:com.ysl.IM
KEY ID:J4ZUVHW59G
Team ID:2DN6KF37CH
单号登录 多设备顶号 已经实现
多标签登录的消息到达率
建一个千人大群
telegram报警功能
加上ai翻译的功能 ai笔记
后端要做一下 redis 的 sentinel适配, 不然重新选举之后可能会写错节点报错
https://api.118im.com 简单弄一个官网,https://api.118im.com/legal/privacy-policy需要把名字改对,TestFlight的beta版需要审核,这两个可能会是卡点  
+724
View File
@@ -0,0 +1,724 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/gorilla/websocket"
)
type imResp struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
Data map[string]any `json:"data"`
RequestID string `json:"request_id"`
}
type result struct {
Chapter string
ID string
Name string
Method string
Path string
Status int
Code int
Reason string
Pass bool
Note string
Body string
}
type audit struct {
baseURL string
wsURL string
apiKey string
tenantCode string
client *http.Client
results []result
tokenA string
userA string
usernameA string
phoneA string
passA string
tokenB string
userB string
usernameB string
phoneB string
passB string
tokenC string
userC string
usernameC string
phoneC string
passC string
groupTopic string
groupVer int
groupQR string
friendReq string
p2pTopic string
meQR string
meQRPurpose string
meetingID string
meetingVer int
dailyID string
dailyVer int
monthlyID string
monthlyVer int
mailboxNoticeVersion string
actionTok string
abuseReason string
profileVer int
privacyVer int
feedbackCat string
}
func main() {
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "MeetingIM API key")
tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path")
flag.Parse()
a := &audit{
baseURL: strings.TrimRight(*baseURL, "/"),
wsURL: *wsURL,
apiKey: *apiKey,
tenantCode: *tenant,
client: &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
},
},
}
a.run()
if err := os.WriteFile(*out, []byte(a.report()), 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", *out)
}
func (a *audit) run() {
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123"
a.usernameB, a.phoneB, a.passB = "b"+suffix, "156"+suffix, "AuditPass123"
a.usernameC, a.phoneC, a.passC = "c"+suffix, "159"+suffix, "AuditPass123"
a.testHTTP("1", "1.1", "企业码解析", "POST", "/v1/im/tenants/resolve", map[string]any{"tenant_code": a.tenantCode, "platform": "ios", "app_version": "1.0.0"}, "", true, []int{200}, func(r imResp) string {
return fmt.Sprintf("tenant=%v endpoints=%v", dig(r.Data, "tenant", "code"), dig(r.Data, "endpoints", "api"))
})
a.register("1", "1.2A", a.usernameA, a.phoneA, a.passA, true)
a.register("1", "1.2B", a.usernameB, a.phoneB, a.passB, false)
a.testHTTP("1", "1.6a", "忘记密码发码", "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "reset_password", "phone": a.phoneA}, "", true, []int{200}, func(r imResp) string {
challenge := asString(r.Data["challenge_id"])
if challenge == "" {
return "未返回 challenge_id"
}
a.testHTTP("1", "1.6b", "忘记密码验证码校验", "POST", "/v1/im/auth/password-reset/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(rr imResp) string {
return "reset_token=" + mask(asString(rr.Data["reset_token"]))
})
return "challenge_id=" + mask(challenge)
})
a.testHTTP("1", "1.7a", "二次身份验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": "close_youth_mode", "channel": "sms"}, a.tokenA, true, []int{200}, func(r imResp) string {
challenge := asString(r.Data["challenge_id"])
if challenge == "" {
challenge = asString(r.Data["id"])
}
if challenge != "" {
a.testHTTP("1", "1.7b", "二次身份验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, a.tokenA, true, []int{200}, func(rr imResp) string {
a.actionTok = asString(rr.Data["action_token"])
return "action_token=" + mask(a.actionTok)
})
}
return "challenge_id=" + mask(challenge)
})
a.testHTTP("1", "1.8", "实名状态查询", "GET", "/v1/im/security/realname", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("1", "1.8-submit", "实名提交", "POST", "/v1/im/security/realname/submissions", map[string]any{"name": "张三", "id_type": "cn_id", "id_number": "110101199001011234"}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("1", "1.11", "设置独立密码", "POST", "/v1/im/settings/independent-password", map[string]any{"password": "1234", "purpose": "youth_mode", "action_token": a.actionTok}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("1", "1.12", "应用版本查询", "GET", "/v1/im/app/releases/latest?platform=ios&current_version=1.0.0&channel=stable", nil, "", false, []int{200}, nil)
a.testHTTP("2", "2.1", "Bootstrap", "GET", "/v1/im/bootstrap", nil, a.tokenA, false, []int{200}, func(r imResp) string {
return fmt.Sprintf("entries=%v limits=%v", r.Data["entries"] != nil, r.Data["limits"] != nil)
})
a.testHTTP("2", "2.search", "会话搜索", "GET", "/v1/im/search/conversations?q=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("2", "2.sys", "系统通知列表", "GET", "/v1/im/system-notifications?limit=20", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("2", "2.sys-read", "系统通知已读不存在 ID", "POST", "/v1/im/system-notifications/nonexistent/read", map[string]any{"read_at": time.Now().UTC().Format(time.RFC3339)}, a.tokenA, true, []int{200, 404}, nil)
a.testHTTP("5", "5.search", "用户搜索 B", "GET", "/v1/im/users/search?q="+url.QueryEscape(a.usernameB)+"&type=account&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("5", "5.profile", "查看 B 资料", "GET", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/profile", nil, a.tokenA, false, []int{200, 403, 404}, nil)
a.testHTTP("5", "5.user-qr", "查看 B 加好友二维码", "POST", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200, 403, 404}, nil)
a.testHTTP("5", "5.friend-create", "创建好友申请 A->B", "POST", "/v1/im/friend-requests", map[string]any{"target_user_id": a.userB, "message": "联调好友申请", "source": "search"}, a.tokenA, true, []int{200}, func(r imResp) string {
a.friendReq = asString(r.Data["request_id"])
return "request_id=" + a.friendReq
})
if a.friendReq != "" {
a.testHTTP("5", "5.friend-accept", "B 同意好友申请", "POST", "/v1/im/friend-requests/"+url.PathEscape(a.friendReq)+"/accept", map[string]any{"version": 1}, a.tokenB, true, []int{200, 409}, func(r imResp) string {
a.p2pTopic = asString(r.Data["topic"])
return "topic=" + a.p2pTopic
})
}
a.testHTTP("3", "3.context", "P2P 会话上下文", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/context", nil, a.tokenA, false, []int{200, 400, 404}, nil)
a.testHTTP("3", "3.p2p-create", "创建 P2P 会话目标接口", "POST", "/v1/im/conversations/p2p", map[string]any{"peer_user_id": a.userB}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("3", "3.conv-settings", "会话设置查询", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/settings", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("3", "3.conv-messages", "会话消息列表", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/messages?since=1&limit=20", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.forward-targets", "转发目标列表", "GET", "/v1/im/forward-targets?query=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("4", "4.forward", "消息逐条转发", "POST", "/v1/im/messages/forward", map[string]any{"mode": "separate", "sources": []map[string]any{{"topic": a.p2pTopic, "seq": 1}}, "target_topics": []string{"slf"}}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.bundle", "合并转发详情", "GET", "/v1/im/forward-bundles/fbundle_missing?cursor=", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.recall", "消息撤回不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/recall", map[string]any{"version": 1, "reason": "audit"}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 403, 404, 409}, nil)
a.testHTTP("4", "4.receipts", "消息回执查询", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/receipts?cursor=", nil, a.tokenA, false, []int{200, 400, 404, 403}, nil)
a.testHTTP("5", "5.friends", "好友列表", "GET", "/v1/im/friends?query=&initial=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("5", "5.requests", "好友申请列表", "GET", "/v1/im/friend-requests?direction=inbound&status=all&cursor=", nil, a.tokenB, false, []int{200}, nil)
a.testHTTP("5", "5.blocks", "黑名单列表", "GET", "/v1/im/blocks?cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("5", "5.block-put", "拉黑 B", "PUT", "/v1/im/blocks/"+url.PathEscape(a.userB), map[string]any{}, a.tokenA, true, []int{200}, nil)
a.testHTTP("5", "5.block-del", "解除拉黑 B", "DELETE", "/v1/im/blocks/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
a.testHTTP("5", "5.me-qr", "我的二维码", "POST", "/v1/im/me/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200}, func(r imResp) string {
a.meQR = asString(r.Data["qr_token"])
a.meQRPurpose = "add_friend"
return "qr_token=" + mask(a.meQR)
})
if a.meQR != "" {
a.testHTTP("5", "5.qr-resolve", "二维码解析", "POST", "/v1/im/qrcodes/resolve", map[string]any{"token": a.meQR, "purpose": a.meQRPurpose}, a.tokenB, true, []int{200}, nil)
}
a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "auditgroup" + suffix, "description": "audit group", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string {
a.groupTopic = asString(r.Data["topic"])
a.groupVer = asInt(r.Data["version"])
return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer)
})
groupPath := "/v1/im/groups/" + url.PathEscape(a.groupTopic)
a.testHTTP("6", "6.list", "群列表", "GET", "/v1/im/groups?category=owned&query=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("6", "6.detail", "群详情", "GET", groupPath, nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.patch", "更新群资料", "PATCH", groupPath, map[string]any{"description": "自动化测试群已更新", "version": a.groupVer}, a.tokenA, true, []int{200, 409, 404}, func(r imResp) string {
if asInt(r.Data["version"]) > 0 {
a.groupVer = asInt(r.Data["version"])
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.settings", "群设置", "GET", groupPath+"/settings", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.member-settings", "群个人设置", "PATCH", groupPath+"/member-settings", map[string]any{"pinned": true, "mute_notifications": false}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.my-profile", "群内昵称", "PUT", groupPath+"/members/me/profile", map[string]any{"nickname": "联调昵称", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.members", "群成员", "GET", groupPath+"/members?query=&cursor=&mentionable=true", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.member-profile", "群成员资料", "GET", groupPath+"/members/"+url.PathEscape(a.userB)+"/profile", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.qrcode", "群二维码", "POST", groupPath+"/qrcodes", map[string]any{"expires_in_seconds": 86400}, a.tokenA, true, []int{200, 404}, func(r imResp) string {
a.groupQR = asString(r.Data["qr_token"])
return "qr_token=" + mask(a.groupQR)
})
a.testHTTP("6", "6.invite", "群邀请", "POST", groupPath+"/invitations", map[string]any{"user_ids": []string{a.userB}, "message": "邀请入群"}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.join-requests", "入群申请列表", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.summary", "群概要", "GET", groupPath+"/summary", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.ann-latest", "群公告最新", "GET", groupPath+"/announcements/latest", nil, a.tokenA, false, []int{200, 404}, nil)
announcementID := ""
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200}, func(r imResp) string {
announcementID = asString(r.Data["announcement_id"])
return "announcement_id=" + announcementID
})
if announcementID != "" {
a.testHTTP("6", "6.ann-detail", "群公告详情", "GET", groupPath+"/announcements/"+url.PathEscape(announcementID), nil, a.tokenB, false, []int{200}, nil)
a.testHTTP("6", "6.ann-read", "群公告已读", "POST", groupPath+"/announcements/"+url.PathEscape(announcementID)+"/read", map[string]any{}, a.tokenB, true, []int{200}, nil)
}
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.governance-member-forbidden", "普通成员更新群治理被拒绝", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "version": a.groupVer}, a.tokenB, true, []int{403}, nil)
a.testHTTP("6", "6.governance-patch", "更新群治理配置成功", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "member_upload_files": true, "version": a.groupVer}, a.tokenA, true, []int{200}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.role-limits", "管理员数量限制", "GET", groupPath+"/role-limits", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.role-limits-member-forbidden", "普通成员读取管理员限制被拒绝", "GET", groupPath+"/role-limits", nil, a.tokenB, false, []int{403}, nil)
a.testHTTP("6", "6.mute-member-forbidden", "普通成员禁言他人被拒绝", "POST", groupPath+"/mutes/batch", map[string]any{"user_ids": []string{a.userA}, "reason": "越权测试"}, a.tokenB, true, []int{403}, nil)
a.testHTTP("6", "6.mute-member", "群主禁言成员", "POST", groupPath+"/mutes/batch", map[string]any{"user_ids": []string{a.userB}, "reason": "联调禁言"}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.mute-self-view", "成员查看自身禁言状态", "GET", groupPath+"/mutes?cursor=", nil, a.tokenB, false, []int{200}, func(r imResp) string {
return fmt.Sprintf("items=%v", r.Data["items"])
})
a.testHTTP("6", "6.unmute-member", "群主解除成员禁言", "DELETE", groupPath+"/mutes/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.admin-add", "群主设置管理员", "POST", groupPath+"/admins/batch", map[string]any{"user_ids": []string{a.userB}, "version": a.groupVer}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.admin-governance", "管理员更新群治理", "PATCH", groupPath+"/governance", map[string]any{"member_private_chat": true, "version": a.groupVer}, a.tokenB, true, []int{200, 409}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.admin-remove", "群主取消管理员", "DELETE", groupPath+"/admins/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
a.registerThird("6", "6.join-user", a.usernameC, a.phoneC, a.passC)
joinRequestID, joinRequestVersion := "", 0
a.testHTTP("6", "6.join-create", "非成员提交入群申请", "POST", groupPath+"/join-requests", map[string]any{"message": "申请加入联调群", "source": "manual"}, a.tokenC, true, []int{200}, func(r imResp) string {
joinRequestID = asString(r.Data["request_id"])
joinRequestVersion = asInt(r.Data["version"])
return fmt.Sprintf("request_id=%s version=%d", joinRequestID, joinRequestVersion)
})
a.testHTTP("6", "6.join-list-member-forbidden", "普通成员读取入群申请被拒绝", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenB, false, []int{403}, nil)
a.testHTTP("6", "6.join-list-owner", "群主读取待审批申请", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200}, nil)
if joinRequestID != "" {
a.testHTTP("6", "6.join-accept", "群主通过入群申请", "POST", groupPath+"/join-requests/"+url.PathEscape(joinRequestID)+"/accept", map[string]any{"version": joinRequestVersion}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.join-detail", "审批后新成员读取群详情", "GET", groupPath, nil, a.tokenC, false, []int{200}, nil)
a.testHTTP("6", "6.join-leave", "新成员主动退群", "POST", groupPath+"/leave", map[string]any{}, a.tokenC, true, []int{200}, nil)
}
a.testHTTP("6", "6.admins", "管理员列表", "GET", groupPath+"/members?role=admin&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.mutes", "禁言列表", "GET", groupPath+"/mutes?cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil)
a.testHTTP("7", "7.favorites", "收藏列表", "GET", "/v1/im/favorites?type=all&q=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("7", "7.favorite-batch", "批量收藏不存在消息", "POST", "/v1/im/favorites/batch", map[string]any{"messages": []map[string]any{{"topic": a.groupTopic, "seq": 1}}}, a.tokenA, true, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.message-search", "聊天记录搜索", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q=%E8%81%94%E8%B0%83&type=text&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.media", "会话媒体列表", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/media?type=image&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.msg-context", "消息上下文", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/1/context?before=20&after=20", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.file-download", "文件下载不存在文件", "GET", "/v0/file/s/nonexistent", nil, a.tokenA, false, []int{200, 400, 401, 403, 404}, nil)
a.testHTTP("8", "8.calls", "通话记录列表", "GET", "/v1/im/calls?limit=5", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("8", "8.topic-calls", "会话通话记录", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/calls?limit=5", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("9", "9.workbench", "工作台", "GET", "/v1/im/workbench", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.depts", "组织部门", "GET", "/v1/im/organization/departments?parent_id=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.org-search-empty", "组织空关键词搜索", "GET", "/v1/im/organization/search?q=", nil, a.tokenA, false, []int{200, 400}, nil)
start := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute)
end := start.Add(time.Hour)
a.testHTTP("9", "9.meeting-create", "创建会议", "POST", "/v1/im/meetings", map[string]any{"title": "联调会议", "start_at": start.Format(time.RFC3339), "end_at": end.Format(time.RFC3339), "location": "线上", "attendee_user_ids": []string{a.userB}, "reminder_minutes": []int{10}}, a.tokenA, true, []int{200}, func(r imResp) string {
a.meetingID = asString(r.Data["meeting_id"])
a.meetingVer = asInt(r.Data["version"])
return fmt.Sprintf("meeting_id=%s version=%d", a.meetingID, a.meetingVer)
})
a.testHTTP("9", "9.meeting-list", "会议列表", "GET", "/v1/im/meetings?limit=5", nil, a.tokenA, false, []int{200, 405}, nil)
if a.meetingID != "" {
a.testHTTP("9", "9.meeting-get", "会议详情", "GET", "/v1/im/meetings/"+url.PathEscape(a.meetingID), nil, a.tokenA, false, []int{200, 404}, nil)
}
today := time.Now().UTC().Format("2006-01-02")
a.testHTTP("9", "9.daily-list", "日报列表", "GET", "/v1/im/reports/daily?limit=5", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.daily-create", "创建日报草稿", "POST", "/v1/im/reports/daily", map[string]any{"report_date": today}, a.tokenA, true, []int{200, 409}, func(r imResp) string {
a.dailyID = asString(r.Data["report_id"])
a.dailyVer = asInt(r.Data["version"])
return fmt.Sprintf("report_id=%s version=%d", a.dailyID, a.dailyVer)
})
a.testHTTP("9", "9.daily-stats", "日报统计", "GET", "/v1/im/reports/daily/statistics?date="+today, nil, a.tokenA, false, []int{200}, nil)
month := time.Now().UTC().Format("2006-01")
a.testHTTP("9", "9.monthly-list", "月报列表", "GET", "/v1/im/reports/monthly?limit=5", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.monthly-create", "创建月报草稿", "POST", "/v1/im/reports/monthly", map[string]any{"month": month}, a.tokenA, true, []int{200, 409}, func(r imResp) string {
a.monthlyID = asString(r.Data["report_id"])
a.monthlyVer = asInt(r.Data["version"])
return fmt.Sprintf("report_id=%s version=%d", a.monthlyID, a.monthlyVer)
})
a.testHTTP("9", "9.monthly-stats", "月报统计", "GET", "/v1/im/reports/monthly/statistics?month="+month, nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.mailbox-config", "总经理信箱配置", "GET", "/v1/im/executive-mailbox/config", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.mailboxNoticeVersion = asString(dig(r.Data, "notice", "version"))
return "notice_version=" + a.mailboxNoticeVersion
})
a.testHTTP("9", "9.mailbox-submit", "总经理信箱提交", "POST", "/v1/im/executive-mailbox/submissions", map[string]any{"subject": "联调建议", "content": "自动化测试提交", "anonymous": false, "attachment_file_ids": []string{}, "notice_version": a.mailboxNoticeVersion}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("10", "10.me", "我的账号", "GET", "/v1/im/me", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("10", "10.profile", "个人资料", "GET", "/v1/im/me/profile", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.profileVer = asInt(r.Data["version"])
return fmt.Sprintf("version=%d", a.profileVer)
})
a.testHTTP("10", "10.profile-patch", "更新个人资料", "PATCH", "/v1/im/me/profile", map[string]any{"nickname": "联调用户A", "signature": "audit", "version": a.profileVer}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("10", "10.privacy", "隐私设置", "GET", "/v1/im/me/privacy", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.privacyVer = asInt(r.Data["version"])
return fmt.Sprintf("version=%d", a.privacyVer)
})
a.testHTTP("10", "10.privacy-patch", "更新隐私设置", "PATCH", "/v1/im/me/privacy", map[string]any{"add_me_methods": map[string]any{"account": true, "phone": true, "qr": true, "group": true}, "profile_visibility": "everyone", "last_seen_visibility": "friends", "read_receipts": true, "version": a.privacyVer}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("10", "10.youth", "青少年模式", "GET", "/v1/im/settings/youth-mode", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("10", "10.independent-verify", "独立密码验证", "POST", "/v1/im/settings/independent-password/verify", map[string]any{"password": "1234", "purpose": "youth_mode"}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("10", "10.push", "设备 Push Token", "PUT", "/v1/im/devices/audit-device-"+suffix+"/push-token", map[string]any{"provider": "apns", "token": "audit-token-" + suffix, "platform": "ios", "app_version": "1.0.0"}, a.tokenA, true, []int{200}, nil)
a.testHTTP("11", "11.url", "URL 风险检查", "POST", "/v1/im/security/urls/check", map[string]any{"url": "https://example.com"}, a.tokenA, true, []int{200}, nil)
a.testHTTP("11", "11.abuse-reasons", "举报原因", "GET", "/v1/im/reports/abuse/reasons?target_type=user", nil, a.tokenA, false, []int{200}, func(r imResp) string {
if items, ok := r.Data["items"].([]any); ok && len(items) > 0 {
if first, ok := items[0].(map[string]any); ok {
a.abuseReason = asString(first["reason_code"])
if a.abuseReason == "" {
a.abuseReason = asString(first["code"])
}
}
}
return "reason=" + a.abuseReason
})
a.testHTTP("11", "11.abuse-submit", "提交举报", "POST", "/v1/im/reports/abuse", map[string]any{"target_type": "user", "target_id": a.userB, "reason_code": a.abuseReason, "description": "联调举报", "evidence": []map[string]any{}}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("11", "11.feedback-config", "反馈配置", "GET", "/v1/im/feedback/config", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.feedbackCat = "bug"
if items, ok := r.Data["categories"].([]any); ok && len(items) > 0 {
if s := asString(items[0]); s != "" {
a.feedbackCat = s
}
}
return "category=" + a.feedbackCat
})
a.testHTTP("11", "11.feedback", "提交反馈", "POST", "/v1/im/feedback", map[string]any{"category": a.feedbackCat, "content": "自动化联调反馈", "contact": "", "attachment_file_ids": []string{}, "client_context": map[string]any{"source": "imtestaudit"}, "include_diagnostics": true}, a.tokenA, true, []int{200}, nil)
a.testHTTP("6", "6.owner-version", "转让前读取群版本", "GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/governance", nil, a.tokenA, false, []int{200}, func(r imResp) string {
if version := asInt(r.Data["version"]); version > 0 {
a.groupVer = version
}
return fmt.Sprintf("version=%d", a.groupVer)
})
transferToken := a.securityActionToken("6", "6.owner-transfer", "group_owner_transfer", a.tokenA)
a.testHTTPHeaders("6", "6.owner-transfer-action", "群主转让", "POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/owner-transfer", map[string]any{"target_user_id": a.userB, "version": a.groupVer}, a.tokenA, true, []int{200}, map[string]string{"X-Action-Token": transferToken}, nil)
dissolveToken := a.securityActionToken("6", "6.dissolve", "group_dissolve", a.tokenB)
a.testHTTPHeaders("6", "6.dissolve-action", "新群主解散群", "DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic), map[string]any{"reason": "自动化验收完成"}, a.tokenB, true, []int{200}, map[string]string{"X-Action-Token": dissolveToken}, nil)
a.testHTTP("6", "6.dissolved-detail", "解散后群详情返回 Gone", "GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic), nil, a.tokenA, false, []int{410}, nil)
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
}
func (a *audit) register(chapter, id, username, phone, password string, primary bool) {
var challenge, verification string
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "", true, []int{200}, func(r imResp) string {
challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge)
})
if challenge != "" {
a.testHTTP(chapter, id+"-verify", "注册验证码校验 "+username, "POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(r imResp) string {
verification = asString(r.Data["verification_token"])
return "verification_token=" + mask(verification)
})
}
token, userID, note, err := a.wsRegister(username, phone, password, challenge, verification)
res := result{Chapter: chapter, ID: id + "-ws-acc", Name: "WebSocket hi+acc 注册 " + username, Method: "WS", Path: "/v0/channels", Status: 0, Code: 0, Pass: err == nil, Note: note}
if err != nil {
res.Reason = err.Error()
}
a.results = append(a.results, res)
if primary {
a.tokenA, a.userA = token, userID
} else {
a.tokenB, a.userB = token, userID
}
}
func (a *audit) registerThird(chapter, id, username, phone, password string) {
var challenge, verification string
a.testHTTP(chapter, id+"-code", "入群用户注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "", true, []int{200}, func(r imResp) string {
challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge)
})
if challenge != "" {
a.testHTTP(chapter, id+"-verify", "入群用户验证码校验 "+username, "POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(r imResp) string {
verification = asString(r.Data["verification_token"])
return "verification_token=" + mask(verification)
})
}
token, userID, note, err := a.wsRegister(username, phone, password, challenge, verification)
res := result{Chapter: chapter, ID: id + "-ws-acc", Name: "入群用户 WebSocket 注册 " + username, Method: "WS", Path: "/v0/channels", Pass: err == nil, Note: note}
if err != nil {
res.Reason = err.Error()
}
a.results = append(a.results, res)
a.tokenC, a.userC = token, userID
}
func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) {
u, _ := url.Parse(a.wsURL)
q := u.Query()
q.Set("apikey", a.apiKey)
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return "", "", "", err
}
defer conn.Close()
deadline := time.Now().Add(15 * time.Second)
_ = conn.SetReadDeadline(deadline)
_ = conn.SetWriteDeadline(deadline)
hiID := "hi-" + username
if err = conn.WriteJSON(map[string]any{"hi": map[string]any{"id": hiID, "ver": "0.25", "ua": "imtestaudit/1.0", "platf": "go", "lang": "zh-CN", "tenant": a.tenantCode}}); err != nil {
return "", "", "", err
}
if _, err = waitCtrl(conn, hiID); err != nil {
return "", "", "", fmt.Errorf("hi: %w", err)
}
accID := "acc-" + username
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
err = conn.WriteJSON(map[string]any{"acc": map[string]any{
"id": accID,
"user": "new",
"scheme": "basic",
"username": username,
"phone": phone,
"registration_type": 1,
"secret": secret,
"login": true,
"cred": []map[string]any{{
"meth": "tel",
"val": phone,
"resp": verification,
"params": map[string]any{"challenge_id": challenge},
}},
"desc": map[string]any{"public": map[string]any{"fn": username}},
}})
if err != nil {
return "", "", "", err
}
ctrl, err := waitCtrl(conn, accID)
if err != nil {
return "", "", "", fmt.Errorf("acc: %w", err)
}
params, _ := ctrl["params"].(map[string]any)
token := asString(params["token"])
user := asString(params["user"])
return token, user, "user=" + user + " token=" + mask(token), nil
}
func waitCtrl(conn *websocket.Conn, id string) (map[string]any, error) {
for i := 0; i < 20; i++ {
var msg map[string]any
if err := conn.ReadJSON(&msg); err != nil {
return nil, err
}
ctrl, ok := msg["ctrl"].(map[string]any)
if !ok || asString(ctrl["id"]) != id {
continue
}
code := asInt(ctrl["code"])
if code < 200 || code >= 300 {
return ctrl, fmt.Errorf("ctrl code=%d text=%s", code, asString(ctrl["text"]))
}
return ctrl, nil
}
return nil, fmt.Errorf("ctrl %s not received", id)
}
func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token string, idem bool, want []int, note func(imResp) string) {
a.testHTTPHeaders(chapter, id, name, method, path, body, token, idem, want, nil, note)
}
func (a *audit) testHTTPHeaders(chapter, id, name, method, path string, body any, token string, idem bool, want []int, headers map[string]string, note func(imResp) string) {
status, parsed, raw, err := a.callHeaders(method, path, body, token, idem, headers)
r := result{Chapter: chapter, ID: id, Name: name, Method: method, Path: path, Status: status, Code: parsed.Code, Reason: parsed.Reason, Body: compact(raw)}
if err != nil {
r.Reason = err.Error()
} else {
for _, s := range want {
if status == s {
r.Pass = true
break
}
}
if note != nil {
r.Note = note(parsed)
}
}
a.results = append(a.results, r)
}
func (a *audit) call(method, path string, body any, token string, idem bool) (int, imResp, string, error) {
return a.callHeaders(method, path, body, token, idem, nil)
}
func (a *audit) callHeaders(method, path string, body any, token string, idem bool, headers map[string]string) (int, imResp, string, error) {
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
target := path
if strings.HasPrefix(path, "/") {
target = a.baseURL + path
}
req, err := http.NewRequestWithContext(ctx, method, target, reader)
if err != nil {
return 0, imResp{}, "", err
}
req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", ""))
req.Header.Set("X-Tenant-Code", a.tenantCode)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
if strings.HasPrefix(path, "/v0/file/") {
req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
}
}
if idem {
req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano()))
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := a.client.Do(req)
if err != nil {
return 0, imResp{}, "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
var parsed imResp
_ = json.Unmarshal(b, &parsed)
return resp.StatusCode, parsed, string(b), nil
}
func (a *audit) securityActionToken(chapter, id, scene, token string) string {
challengeID, actionToken := "", ""
a.testHTTP(chapter, id+"-challenge", scene+" 二次验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": scene, "channel": "sms"}, token, true, []int{200}, func(r imResp) string {
challengeID = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challengeID)
})
if challengeID != "" {
a.testHTTP(chapter, id+"-verify", scene+" 二次验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challengeID)+"/verify", map[string]any{"code": "123456"}, token, true, []int{200}, func(r imResp) string {
actionToken = asString(r.Data["action_token"])
return "action_token=" + mask(actionToken)
})
}
return actionToken
}
func (a *audit) report() string {
var b strings.Builder
now := time.Now().Format("2006-01-02 15:04:05 -0700")
fmt.Fprintf(&b, "# IM 前后端 1-11 章测试环境接口测试结果(2026-08-20)\n\n")
fmt.Fprintf(&b, "## 1. 测试范围\n\n")
fmt.Fprintf(&b, "- 对照文档:`docs/IM-前后端对接文档.md`\n")
fmt.Fprintf(&b, "- 测试时间:`%s`\n", now)
fmt.Fprintf(&b, "- 测试环境:`%s`\n", a.baseURL)
fmt.Fprintf(&b, "- WebSocket:`%s`\n", a.wsURL)
fmt.Fprintf(&b, "- 租户:`%s`\n", a.tenantCode)
fmt.Fprintf(&b, "- 测试账号 A:`%s` / `%s` / `%s`\n", a.usernameA, a.phoneA, a.userA)
fmt.Fprintf(&b, "- 测试账号 B:`%s` / `%s` / `%s`\n", a.usernameB, a.phoneB, a.userB)
fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n")
pass, fail := 0, 0
chapters := map[string][2]int{}
for _, r := range a.results {
pair := chapters[r.Chapter]
if r.Pass {
pass++
pair[0]++
} else {
fail++
pair[1]++
}
chapters[r.Chapter] = pair
}
fmt.Fprintf(&b, "## 2. 总体结论\n\n")
fmt.Fprintf(&b, "本轮按前后端对接文档从第 1 章到第 11 章直接访问测试环境接口,共执行 `%d` 项,符合预期 `%d` 项,不符合预期 `%d` 项。符合预期不等于业务全链路完全验收;部分接口以 404/405/409/403 作为当前文档标注的未实现、无数据或权限分支记录。\n\n", len(a.results), pass, fail)
fmt.Fprintf(&b, "| 章 | 符合预期 | 不符合预期 |\n| --- | ---: | ---: |\n")
keys := make([]string, 0, len(chapters))
for k := range chapters {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return chapterOrder(keys[i]) < chapterOrder(keys[j]) })
for _, k := range keys {
pair := chapters[k]
fmt.Fprintf(&b, "| %s | %d | %d |\n", k, pair[0], pair[1])
}
fmt.Fprintf(&b, "\n## 3. 需要关注的问题\n\n")
hasFail := false
for _, r := range a.results {
if !r.Pass {
hasFail = true
fmt.Fprintf(&b, "- `%s` %s `%s`:HTTP `%d`,code `%d`,reason `%s`。%s\n", r.ID, r.Method, r.Path, r.Status, r.Code, r.Reason, r.Note)
}
}
if !hasFail {
fmt.Fprintf(&b, "- 本轮自动化黑盒测试未发现非预期失败。\n")
}
fmt.Fprintf(&b, "\n## 4. 分章明细\n\n")
for _, k := range keys {
fmt.Fprintf(&b, "### 第 %s 章\n\n", k)
fmt.Fprintf(&b, "| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |\n| --- | --- | --- | --- | ---: | --- | --- | --- |\n")
for _, r := range a.results {
if r.Chapter != k {
continue
}
status := "通过"
if !r.Pass {
status = "未通过"
}
reason := strings.TrimSpace(fmt.Sprintf("%d/%s", r.Code, r.Reason))
fmt.Fprintf(&b, "| `%s` | %s | `%s` | `%s` | %d | `%s` | %s | %s |\n", r.ID, esc(r.Name), r.Method, esc(r.Path), r.Status, esc(reason), status, esc(r.Note))
}
fmt.Fprintf(&b, "\n")
}
fmt.Fprintf(&b, "## 5. 测试产生的数据\n\n")
fmt.Fprintf(&b, "- 用户 A:`%s`,username `%s`,phone `%s`\n", a.userA, a.usernameA, a.phoneA)
fmt.Fprintf(&b, "- 用户 B:`%s`,username `%s`,phone `%s`\n", a.userB, a.usernameB, a.phoneB)
fmt.Fprintf(&b, "- 好友申请:`%s`\n", a.friendReq)
fmt.Fprintf(&b, "- P2P Topic:`%s`\n", a.p2pTopic)
fmt.Fprintf(&b, "- 群 Topic:`%s`\n", a.groupTopic)
fmt.Fprintf(&b, "- 会议:`%s`\n", a.meetingID)
fmt.Fprintf(&b, "- 日报草稿:`%s`\n", a.dailyID)
fmt.Fprintf(&b, "- 月报草稿:`%s`\n", a.monthlyID)
fmt.Fprintf(&b, "\n这些数据位于测试环境租户 `%s`,可按需清理。\n", a.tenantCode)
return b.String()
}
func asString(v any) string {
switch t := v.(type) {
case string:
return t
default:
return ""
}
}
func asInt(v any) int {
switch t := v.(type) {
case int:
return t
case float64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
default:
return 0
}
}
func dig(m map[string]any, path ...string) any {
var cur any = m
for _, p := range path {
obj, ok := cur.(map[string]any)
if !ok {
return nil
}
cur = obj[p]
}
return cur
}
func mask(s string) string {
if len(s) <= 8 {
return s
}
return s[:4] + "..." + s[len(s)-4:]
}
func compact(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len(s) > 220 {
return s[:220] + "..."
}
return s
}
func esc(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func chapterOrder(s string) int {
var n int
_, _ = fmt.Sscanf(s, "%d", &n)
return n
}
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
# im-server-test ArgoCD patch
The live StatefulSet is owned by the ArgoCD Application `im-server-test`.
Copy `statefulset-performance-patch.yaml` into that application's Git source
and reference it from the test overlay:
```yaml
patches:
- path: statefulset-performance-patch.yaml
```
Also include `backend-debug-metrics-rbac.yaml` as a resource in the test
overlay. It grants the existing `argocd/backend-debug` service account
namespaced read access to Pod metrics so the load-test collector can run
`kubectl top pods -n im-server-system`:
```yaml
resources:
- backend-debug-metrics-rbac.yaml
```
After ArgoCD sync, verify the effective configuration:
```bash
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].resources}'
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.httpGet.path}'
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].env}'
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.dnsConfig.options}'
kubectl auth can-i list pods.metrics.k8s.io \
--as=system:serviceaccount:argocd:backend-debug \
-n im-server-system
```
Expected values are requests `2 CPU / 512Mi`, limits `2 CPU / 1Gi`, and
liveness path `/livez`. The MySQL pool environment must be
`MYSQL_MAX_OPEN_CONNS=24`, `MYSQL_MAX_IDLE_CONNS=8`,
`MYSQL_REGISTRY_MAX_OPEN_CONNS=4`, and `MYSQL_REGISTRY_MAX_IDLE_CONNS=2`.
The MySQL connection lifetime must be `MYSQL_CONN_MAX_LIFETIME_SECONDS=1800`.
The WebSocket heartbeat environment must be `WS_PING_PERIOD_SECONDS=15` and
`WS_PONG_WAIT_SECONDS=90`.
The Pod DNS options must be `ndots:1`, `timeout:2`, and `attempts:2`. Keeping
`ClusterFirst` preserves short Kubernetes service-name resolution, while the
lower `ndots` value prevents fully qualified internal and external dependency
names from being expanded through every cluster search suffix first.
The application repository's deploy workflow waits for reconciliation and
fails if ArgoCD restores different values.
After the rollout, verify the generated resolver configuration on every Pod:
```bash
for pod in im-server-0 im-server-1 im-server-2; do
echo "--- ${pod}"
kubectl -n im-server-system exec "${pod}" -- cat /etc/resolv.conf
done
```
The output must retain the cluster DNS nameserver and search domains, with
`options ndots:1 timeout:2 attempts:2`. This mitigation reduces amplified DNS
traffic from the application Pods. If lookup timeouts remain under load,
CoreDNS capacity or NodeLocal DNSCache must be changed in the cluster
infrastructure repository by an administrator with `kube-system` access.
@@ -0,0 +1,27 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: backend-debug-metrics-reader
namespace: im-server-system
rules:
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: backend-debug-metrics-reader
namespace: im-server-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: backend-debug-metrics-reader
subjects:
- kind: ServiceAccount
name: backend-debug
namespace: argocd
@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: im-server
namespace: im-server-system
spec:
template:
spec:
dnsPolicy: ClusterFirst
dnsConfig:
options:
- name: ndots
value: "1"
- name: timeout
value: "2"
- name: attempts
value: "2"
containers:
- name: im-server
env:
- name: WS_PING_PERIOD_SECONDS
value: "15"
- name: WS_PONG_WAIT_SECONDS
value: "90"
- name: CLUSTER_PROXY_TO_MASTER_BUFFER
value: "4096"
- name: CLUSTER_PROXY_TO_MASTER_WORKERS
value: "16"
- name: CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS
value: "300"
- name: CLUSTER_ROUTE_RETRIES
value: "3"
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
value: "25"
- name: TOPIC_CLIENT_QUEUE_BUFFER
value: "4096"
- name: TOPIC_SERVER_QUEUE_BUFFER
value: "512"
- name: TOPIC_PUBLISH_ENQUEUE_WAIT_MS
value: "200"
- name: MYSQL_MAX_OPEN_CONNS
value: "24"
- name: MYSQL_MAX_IDLE_CONNS
value: "8"
- name: MYSQL_REGISTRY_MAX_OPEN_CONNS
value: "4"
- name: MYSQL_REGISTRY_MAX_IDLE_CONNS
value: "2"
- name: MYSQL_CONN_MAX_LIFETIME_SECONDS
value: "1800"
resources:
requests:
cpu: "2"
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet:
path: /healthz
port: 6060
timeoutSeconds: 3
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /livez
port: 6060
timeoutSeconds: 3
periodSeconds: 20
failureThreshold: 3
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# Build Tinode docker linux/amd64 images.
# You may have to install buildx https://docs.docker.com/buildx/working-with-buildx/
# if your build host and target architectures are different (e.g. building on a Mac
# with Apple silicon).
for line in $@; do
eval "$line"
done
tag=${tag#?}
if [ -z "$tag" ]; then
echo "Must provide tag as 'tag=v1.2.3'"
exit 1
fi
# Convert tag into a version
ver=( ${tag//./ } )
# if version contains a dash, it's not a full releave, i.e. v0.1.15.5-rc1
if [[ ${ver[2]} != *"-"* ]]; then
FULLRELEASE=1
fi
# Use buildx if the current platform is not x86.
buildcmd='build'
if [ `uname -m` != 'x86_64' ]; then
buildcmd='buildx build --platform=linux/amd64'
fi
# If explicit DB is specified, build just one, otherwise build all.
if [ "$db" ]; then
dbtags=( "$db" )
else
dbtags=( mysql postgres mongodb rethinkdb alldbs )
fi
# Build an images for various DB backends
for dbtag in "${dbtags[@]}"
do
if [ "$dbtag" == "alldbs" ]; then
# For alldbs, container name is tinode/tinode.
name="tinode/tinode"
else
# Otherwise, tinode/tinode-$dbtag.
name="tinode/tinode-${dbtag}"
fi
separator=
rmitags="${name}:${ver[0]}.${ver[1]}.${ver[2]}"
buildtags="--tag ${name}:${ver[0]}.${ver[1]}.${ver[2]}"
if [ -n "$FULLRELEASE" ]; then
rmitags="${rmitags} ${name}:latest ${name}:${ver[0]}.${ver[1]}"
buildtags="${buildtags} --tag ${name}:latest --tag ${name}:${ver[0]}.${ver[1]}"
fi
docker rmi ${rmitags}
docker ${buildcmd} --build-arg VERSION=$tag --build-arg TARGET_DB=${dbtag} ${buildtags} docker/tinode
done
if [ "$db" ]; then
exit 0
fi
# Build chatbot image
buildtags="--tag tinode/chatbot:${ver[0]}.${ver[1]}.${ver[2]}"
rmitags="tinode/chatbot:${ver[0]}.${ver[1]}.${ver[2]}"
if [ -n "$FULLRELEASE" ]; then
rmitags="${rmitags} tinode/chatbot:latest tinode/chatbot:${ver[0]}.${ver[1]}"
buildtags="${buildtags} --tag tinode/chatbot:latest --tag tinode/chatbot:${ver[0]}.${ver[1]}"
fi
docker rmi ${rmitags}
docker ${buildcmd} --build-arg VERSION=$tag ${buildtags} docker/chatbot
# Build exporter image
buildtags="--tag tinode/exporter:${ver[0]}.${ver[1]}.${ver[2]}"
rmitags="tinode/exporter:${ver[0]}.${ver[1]}.${ver[2]}"
if [ -n "$FULLRELEASE" ]; then
rmitags="${rmitags} tinode/exporter:latest tinode/exporter:${ver[0]}.${ver[1]}"
buildtags="${buildtags} --tag tinode/exporter:latest --tag tinode/exporter:${ver[0]}.${ver[1]}"
fi
docker rmi ${rmitags}
docker ${buildcmd} --build-arg VERSION=$tag ${buildtags} docker/exporter
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
# Publish Tinode docker images to hub.docker.com
function containerName() {
if [ "$1" == "alldbs" ]; then
# For alldbs, container name is simply tinode.
local name="tinode"
else
# Otherwise, tinode-$dbtag.
local name="tinode-${dbtag}"
fi
echo $name
}
for line in $@; do
eval "$line"
done
tag=${tag#?}
if [ -z "$tag" ]; then
echo "Must provide tag as 'tag=v1.2.3' or 'v1.2.3-abc0'"
exit 1
fi
# Convert tag into a version
ver=( ${tag//./ } )
if [[ ${ver[2]} != *"-"* ]]; then
FULLRELEASE=1
fi
if [ "$db" ]; then
dbtags=( "$db" )
else
dbtags=( mysql postgres mongodb rethinkdb alldbs )
fi
# Read dockerhub login/password from a separate file
source .dockerhub
# Login to docker hub
docker login -u $user -p $pass
# Deploy images for various DB backends
for dbtag in "${dbtags[@]}"
do
name="$(containerName $dbtag)"
# Deploy tagged image
if [ -n "$FULLRELEASE" ]; then
docker push tinode/${name}:latest
docker push tinode/${name}:"${ver[0]}.${ver[1]}"
fi
docker push tinode/${name}:"${ver[0]}.${ver[1]}.${ver[2]}"
done
if [ "$db" ]; then
exit 0
fi
# Deploy chatbot images
if [ -n "$FULLRELEASE" ]; then
docker push tinode/chatbot:latest
docker push tinode/chatbot:"${ver[0]}.${ver[1]}"
fi
docker push tinode/chatbot:"${ver[0]}.${ver[1]}.${ver[2]}"
# Deploy exporter images
if [ -n "$FULLRELEASE" ]; then
docker push tinode/exporter:latest
docker push tinode/exporter:"${ver[0]}.${ver[1]}"
fi
docker push tinode/exporter:"${ver[0]}.${ver[1]}.${ver[2]}"
docker logout
+273
View File
@@ -0,0 +1,273 @@
# Using Docker to run Tinode
All images are available at https://hub.docker.com/r/tinode/
1. [Install Docker](https://docs.docker.com/install/) 1.8 or above. The provided dockerfiles are dependent on [Docker networking](https://docs.docker.com/network/) which may not work with the older Docker.
2. Create a bridge network. It's used to connect Tinode container with the database container.
```
$ docker network create tinode-net
```
3. Decide which database backend you want to use: MySQL, PostgreSQL, MongoDB or RethinkDB. Run the selected database container, attaching it to `tinode-net` network:
1. **MySQL**: If you've decided to use MySQL backend, run the official MySQL Docker container:
```
$ docker run --name mysql --network tinode-net --restart always --env MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7
```
See [instructions](https://hub.docker.com/_/mysql/) for more options. MySQL 5.7 or above is required.
2. **PostgreSQL**: If you've decided to use PostgreSQL backend, run the official PostgreSQL Docker container:
```
$ docker run --name postgres --network tinode-net --restart always --env POSTGRES_PASSWORD=postgres -d postgres:13
```
See [instructions](https://hub.docker.com/_/postgres/) for more options. PostgresSQL 13 or above is required.
The name `rethinkdb`, `mysql`, `mongodb` or `postgres` in the `--name` assignment is important. It's used by other containers as a database's host name.
3. **MongoDB**: If you've decided to use MongoDB backend, run the official MongoDB Docker container and initialise it as single node replica set (you can change "rs0" if you wish):
```
$ docker run --name mongodb --network tinode-net --restart always -d mongo:latest --replSet "rs0"
$ docker exec -it mongodb mongosh
# And inside mongo shell:
> rs.initiate( {"_id": "rs0", "members": [ {"_id": 0, "host": "mongodb:27017"} ]} )
> quit()
```
See [instructions](https://hub.docker.com/_/mongo/) for more options. MongoDB 4.2 or above is required.
4. **RethinkDB**: If you've decided to use RethinkDB backend, run the official RethinkDB Docker container:
```
$ docker run --name rethinkdb --network tinode-net --restart always -d rethinkdb:2.3
```
See [instructions](https://hub.docker.com/_/rethinkdb/) for more options.
4. Run the Tinode container for the appropriate database:
1. **MySQL**:
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-mysql:latest
```
2. **PostgreSQL**:
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-postgres:latest
```
3. **MongoDB**:
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-mongodb:latest
```
4. **RethinkDB**:
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-rethinkdb:latest
```
You can also run Tinode with the `tinode/tinode` image (which has all of the above DB adapters compiled in). You will need to specify the database adapter via `STORE_USE_ADAPTER` environment variable. E.g. for `mysql`, the command line will look like
```
$ docker run -p 6060:6060 -d -e STORE_USE_ADAPTER mysql --name tinode-srv --network tinode-net tinode/tinode:latest
```
See [below](#supported-environment-variables) for more options.
The port mapping `-p 5678:1234` tells Docker to map container's port 1234 to host's port 5678 making server accessible at http://localhost:5678/. The container will initialize the database with test data on the first run.
You may replace `:latest` with a different tag. See all all available tags here:
* [MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/)
* [PostgreSQL tags](https://hub.docker.com/r/tinode/tinode-postgresql/tags/) (beta version)
* [MongoDB tags](https://hub.docker.com/r/tinode/tinode-mongodb/tags/)
* [RethinkDB tags](https://hub.docker.com/r/tinode/tinode-rethink/tags/)
* [All bundle tags](https://hub.docker.com/r/tinode/tinode/tags/)
5. Test the installation by pointing your browser to [http://localhost:6060/](http://localhost:6060/).
## Optional
### External config file
The container comes with a built-in config file which can be customized with values from the environment variables (see [Supported environment variables](#supported_environment_variables) below). If changes are extensive it may be more convenient to replace the built-in config file with a custom one. In that case map the config file located on your host (e.g. `/users/jdoe/new_tinode.conf`) to container (e.g. `/tinode.conf`) using [Docker volumes](https://docs.docker.com/storage/volumes/) `--volume /users/jdoe/new_tinode.conf:/tinode.conf` then instruct the container to use the new config `--env EXT_CONFIG=/tinode.conf`:
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \
--volume /users/jdoe/new_tinode.conf:/tinode.conf \
--env EXT_CONFIG=/tinode.conf \
tinode/tinode-mysql:latest
```
When `EXT_CONFIG` is set, most other environment variables are ignored. Consult [the table](#supported-environment-variables) below for a full list.
### Resetting or upgrading the database
The database schema may change from time to time. An error `Invalid database version 101. Expected 103` means the schema has changed and needs to be updated, in this case from version 101 to version 103. You need to either reset or upgrade the database to continue:
Shut down the Tinode container and remove it:
```
$ docker stop tinode-srv && docker rm tinode-srv
```
then repeat step 4 adding `--env RESET_DB=true` to reset or `--env UPGRADE_DB=true` to upgrade.
Also, the database is automatically created if missing.
### Enable push notifications
Tinode uses Google Firebase Cloud Messaging (FCM) to send pushes.
Follow [instructions](../docs/faq.md#q-how-to-setup-fcm-push-notifications) for obtaining the required FCM credentials.
* Download and save the [FCM service account credentials](https://cloud.google.com/docs/authentication/production) file.
* Obtain values for `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`.
Assuming your Firebase credentials file is named `myproject-1234-firebase-adminsdk-abc12-abcdef012345.json`
and it's saved at `/Users/jdoe/`, web API key is `AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ`, Sender ID `141421356237`,
Project ID `myproject-1234`, App ID `1:141421356237:web:abc7de1234fab56cd78abc`, VAPID key (a.k.a. "Web Push certificates")
is `83_Or_So_Random_Looking_Characters`, start the container with the following parameters (using MySQL container as an example):
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \
-v /Users/jdoe:/config \
--env FCM_CRED_FILE=/config/myproject-1234-firebase-adminsdk-abc12-abcdef012345.json \
--env FCM_API_KEY=AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ \
--env FCM_APP_ID=1:141421356237:web:abc7de1234fab56cd78abc \
--env FCM_PROJECT_ID=myproject-1234 \
--env FCM_SENDER_ID=141421356237 \
--env FCM_VAPID_KEY=83_Or_So_Random_Looking_Characters \
tinode/tinode-mysql:latest
```
### Configure video calling
Tinode uses [WebRTC](https://webrtc.org/) for video and audio calls. WebRTC needs [Interactive Communication Establishment (ICE)](https://tools.ietf.org/id/draft-ietf-ice-rfc5245bis-13.html) [TURN(S)](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) and/or [STUN](https://en.wikipedia.org/wiki/STUN) servers to traverse [NAT](https://en.wikipedia.org/wiki/Network_address_translation), otherwise calls may not work. Tinode does not include TURN(S) or STUN out of the box. You need to obtain and configure your own service. Once you setup your TURN(S) and/or STUN service, save its configuration to a file, for example `/Users/jdoe/turn-config.json` and provide path to this file when starting the container:
```
$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \
-v /Users/jdoe:/config \
--env ICE_SERVERS_FILE=/config/turn-config.json \
< ... other config parameters ... >
tinode/tinode-mysql:latest
```
The config file uses the following format:
```json
[
{
"urls": [
"stun:stun.example.com"
]
},
{
"username": "user-name-to-use-for-authentication-with-the-server",
"credential": "your-password",
"urls": [
"turn:turn.example.com:80?transport=udp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:443?transport=tcp",
]
}
]
```
[XIRSYS](https://xirsys.com/) offers a free tier for developers. We are in no way affiliated with XIRSYS. We do not endorse or otherwise take any responsibility for your use of their services.
### Run the chatbot
See [instructions](../chatbot/python/).
The chatbot password is generated only when the database is initialized or reset. It's saved to `/botdata` directory in the container. If you want to keep the data available between container changes, such as image upgrades, make sure the `/botdata` is a mounted volume (i.e. you always launch the container with `--volume botdata:/botdata` option).
## Supported environment variables
You can specify the following environment variables when issuing `docker run` command:
| Variable | Type | Default | Purpose |
| --- | --- | --- | --- |
| `ACC_GC_ENABLED`[^2] | bool | `false` | Enable/diable automatic deletion of unfinished account registrations. |
| `AUTH_TOKEN_KEY`[^2] | string | `wfaY2RgF2S1OQI/ZlK+LS​rp1KB2jwAdGAIHQ7JZn+Kc=` | base64-encoded 32 random bytes used as salt for authentication tokens. |
| `AWS_ACCESS_KEY_ID`[^2] | string | | AWS Access Key ID when using `s3` media handler. |
| `AWS_CORS_ORIGINS`[^2] | string | `["*"]` | Allowed origins ([CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin)) URL for downloads. Generally use your server URL and its aliases. |
| `AWS_REGION`[^2] | string | | AWS Region when using `s3` media handler |
| `AWS_S3_BUCKET`[^2] | string | | Name of the AWS S3 bucket when using `s3` media handler. |
| `AWS_S3_ENDPOINT`[^2] | string | | An endpoint URL (hostname only or fully qualified URI) to override the default endpoint; can be of any S3-compatible service, such as `minio-api.x.io` |
| `AWS_SECRET_ACCESS_KEY`[^2] | string | | AWS [Secret Access Key](https://aws.amazon.com/blogs/security/wheres-my-secret-access-key/) when using `s3` media handler. |
| `CLUSTER_SELF` | string | | Node name if the server is running in a Tinode cluster. |
| `CLUSTER_PROXY_TO_MASTER_BUFFER` | number | `4096` | Total bounded proxy-to-master queue capacity for each peer. |
| `CLUSTER_PROXY_TO_MASTER_WORKERS` | number | `16` | Ordered queue shards and synchronous RPC workers for each peer. |
| `CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS` | number | `300` | Maximum wait for a full proxy-to-master shard before returning 503. |
| `CLUSTER_ROUTE_RETRIES` | number | `3` | Number of retries after a remote server-message route is rejected as overloaded. |
| `CLUSTER_ROUTE_RETRY_BACKOFF_MS` | number | `25` | Initial exponential retry delay for remote server-message routes. |
| `TOPIC_CLIENT_QUEUE_BUFFER` | number | `4096` | Per-topic client publish queue capacity. Increase for large groups with bursty publishers. |
| `TOPIC_SERVER_QUEUE_BUFFER` | number | `512` | Per-topic server-message queue capacity. |
| `TOPIC_PUBLISH_ENQUEUE_WAIT_MS` | number | `200` | Maximum wait for an attached session publish to enter a full topic queue before returning 503. |
| `DEBUG_EMAIL_VERIFICATION_CODE`[^2] | string | | Enable dummy email verification code, e.g. `123456`. Disabled by default (empty string). |
| `DEFAULT_COUNTRY_CODE`[^2] | string | `US` | 2-letter country code to assign to sessions by default when the country isn't specified by the client explicitly and it's impossible to infer it. |
| `EXT_CONFIG`[^1] | string | | Path to external config file to use instead of the built-in one. If this parameter is used, most other variables are ignored[^1]. |
| `EXT_STATIC_DIR` | string | | Path to external directory containing static data (e.g. Tinode Webapp files). |
| `FCM_CRED_FILE`[^2] | string | | Path to JSON file with FCM server-side service account credentials which will be used to send push notifications. |
| `FCM_API_KEY` | string | | Firebase API key; required for receiving push notifications in the web client. |
| `FCM_APP_ID` | string | | Firebase web app ID; required for receiving push notifications in the web client. |
| `FCM_PROJECT_ID` | string | | Firebase project ID; required for receiving push notifications in the web client. |
| `FCM_SENDER_ID` | string | | Firebase FCM sender ID; required for receiving push notifications in the web client. |
| `FCM_VAPID_KEY` | string | | Also called 'Web Client certificate' in the FCM console; required by the web client to receive push notifications. |
| `FCM_INCLUDE_ANDROID_NOTIFICATION`[^2] | boolean | true | If true, pushes a data + notification message, otherwise a data-only message. [More info](https://firebase.google.com/docs/cloud-messaging/concept-options). |
| `FCM_MEASUREMENT_ID` | string | | Google Analytics ID of the form `G-123ABCD789`. |
| `FS_CORS_ORIGINS`[^2] | string | `["*"]` | Cors origins when media is served from the file system. See `AWS_CORS_ORIGINS` for details. |
| `ICE_SERVERS_FILE`[^2] | string | | Path to JSON file with configuration of ICE servers to be used for video calls. |
| `MEDIA_HANDLER`[^2] | string | `sys_config` | Legacy override for the large file handler. The generated runtime config uses `sys_config` so production can resolve `oss` from backend configuration. |
| `MYSQL_DSN`[^2] | string | <code>'root@tcp(mysql)/meeting_im?&#8203;parseTime=true&#8203;&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). |
| `PLUGIN_PYTHON_CHAT_BOT_ENABLED`[^2] | bool | `false` | Enable calling into the plugin provided by Python chatbot. |
| `POSTGRES_DSN`[^2] | string | <code>'postgresql://postgres:postgres@&#8203;localhost:5432/meeting_im?&#8203;sslmode=disable&#8203;&connect_timeout=10'</code> | PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). |
| `RESET_DB` | bool | `false` | Drop and recreate the database. |
| `SAMPLE_DATA` | string | _see comment →_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `` (empty string) to disable. |
| `SMTP_AUTH_MECHANISM`[^2] | string | `"plain"` | SMTP authentication mechanism to use; one of "login", "cram-md5", "plain". |
| `SMTP_DOMAINS`[^2] | string | | White list of email domains; when non-empty, accept registrations with emails from these domains only (email verification). |
| `SMTP_HELO_HOST`[^2] | string | _see comment →_ | FQDN to use in SMTP HELO/EHLO command; if missing, the hostname from `SMTP_HOST_URL` is used. |
| `SMTP_HOST_URL`[^2] | string | `'http://localhost:6060/'` | URL of the host where the webapp is running (email verification). |
| `SMTP_LOGIN`[^2] | string | | Optional login to use for authentication with the SMTP server (email verification). |
| `SMTP_PASSWORD`[^2] | string | | Optional password to use for authentication with the SMTP server (email verification). |
| `SMTP_PORT`[^2] | number | | Port number of the SMTP server to use for sending verification emails, e.g. `25` or `587`. |
| `SMTP_SENDER` | string | | [RFC 5322](https://tools.ietf.org/html/rfc5322) email address to use in the `FROM` field of verification emails, e.g. `'"John Doe" <jdoe@example.com>'`. |
| `SMTP_SERVER`[^2] | string | | Name of the SMTP server to use for sending verification emails, e.g. `smtp.gmail.com`. If SMTP_SERVER is not defined, email verification will be disabled. |
| `STORE_USE_ADAPTER`[^2] | string | | DB adapter name (specify with `tinode/tinode` container only). |
| `TEL_HOST_URL`[^2] | string | `'http://localhost:6060/'` | URL of the host where the webapp is running for phone verification. |
| `TEL_SENDER`[^2] | string | | Sender name to pass to SMS sending service. |
| `TLS_CONTACT_ADDRESS`[^2] | string | | Optional email to use as contact for [LetsEncrypt](https://letsencrypt.org/) certificates, e.g. `jdoe@example.com`. |
| `TLS_DOMAIN_NAME`[^2] | string | | If non-empty, enables TLS (http**s**) and configures domain name of your container, e.g. `www.example.com`. In order for TLS to work you have to expose your HTTPS port to the Internet and correctly configure DNS. It WILL FAIL with `localhost` or unroutable IPs. |
| `TNPG_AUTH_TOKEN` | string | | Tinode Push Gateway authentication token. |
| `TNPG_ORG`[^2] | string | | Tinode Push Gateway organization name as registered at https://console.tinode.co |
| `UID_ENCRYPTION_KEY`[^2] | string | `dkXxWLwqUNRfYmdXRzOkZw==` | base64-encoded 16 random bytes used as an encryption key for user IDs. |
| `UPGRADE_DB` | bool | `false` | Upgrade database schema, if necessary. |
| `WAIT_FOR` | string | | If non-empty, waits for the specified database `host:port` to be available before starting the server. |
[^1]: If set, variables marked with the footnote `[2]` are ignored.
[^2]: Ignored if `EXT_CONFIG` is set.
A convenient way to generate a desired number of random bytes and base64-encode them on Linux and Mac:
```
$ openssl rand -base64 <desired length>
```
## Metrics Exporter
See [monitoring/exporter/README](../monitoring/exporter/README.md) for information on the Exporter.
Container is also available as a part of the Tinode docker distribution: `tinode/exporter`.
Run it with
```
$ docker run -p 6222:6222 -d --name tinode-exporter --network tinode-net \
--env SERVE_FOR=<prometheus|influxdb> \
--env TINODE_ADDR=<tinode metrics endpoint> \
... <monitoring service specific vars> \
tinode/exporter:latest
```
Available variables:
| Variable | Type | Default | Function |
| --- | --- | --- | --- |
| `SERVE_FOR` | string | `` | Monitoring service: `prometheus` or `influxdb` |
| `TINODE_ADDR` | string | `http://localhost/stats/expvar/` | Tinode metrics path |
| `INFLUXDB_VERSION` | string | `1.7` | InfluxDB version (`1.7` or `2.0`) |
| `INFLUXDB_ORGANIZATION` | string | `org` | InfluxDB organization |
| `INFLUXDB_PUSH_INTERVAL` | int | `60` | Exporter metrics push interval in seconds |
| `INFLUXDB_PUSH_ADDRESS` | string | `https://mon.tinode.co/intake` | InfluxDB backend url |
| `INFLUXDB_AUTH_TOKEN` | string | `` | InfluxDB auth token |
| `PROM_NAMESPACE` | string | `tinode` | Prometheus namespace |
| `PROM_METRICS_PATH` | string | `/metrics` | Exporter webserver path that Prometheus server scrapes |
+39
View File
@@ -0,0 +1,39 @@
# Dockerfile builds an image with a chatbot (Tino) for Tinode.
FROM python:3.13-slim
ARG VERSION=0.25
ARG LOGIN_AS=
ARG TINODE_HOST=tinode-srv:16060
ENV VERSION=$VERSION
ARG BINVERS=$VERSION
LABEL maintainer="Tinode Team <info@tinode.co>"
LABEL name="TinodeChatbot"
LABEL version=$VERSION
RUN mkdir -p /usr/src/bot
WORKDIR /usr/src/bot
# Volume with login cookie. Not created automatically.
# VOLUME /botdata
# Get tarball with the chatbot code and data.
ADD https://github.com/tinode/chat/releases/download/v${BINVERS}/py-chatbot.tar.gz .
# Unpack chatbot, delete archive
RUN tar -xzf py-chatbot.tar.gz \
&& rm py-chatbot.tar.gz
RUN pip install --no-cache-dir -r requirements.txt
# Healthcheck: try to connect to the gRPC port (40051)
HEALTHCHECK --interval=1m --timeout=3s --start-period=15s \
CMD python -c "import socket; s=socket.create_connection(('localhost', 40051), timeout=1); s.close()" || exit 1
# Use docker's command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino.
CMD ["/bin/sh", "-c", "python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=${TINODE_HOST} >> /var/log/chatbot.log"]
# Plugin port
EXPOSE 40051
+68
View File
@@ -0,0 +1,68 @@
# Docker compose for end-to-end setup.
These reference docker-compose files will run Tinode with the MySql backend either as [a single-instance](single-instance.yml) or [a 3-node cluster](cluster.yml) setup.
```
docker-compose -f <name of the file> [-f <name of override file>] up -d
```
By default, this command starts up a mysql instance, Tinode server(s) and Tinode exporter(s).
Tinode server(s) is(are) configured similar to [Tinode Demo/Sandbox](../../README.md#demosandbox) and
maps its web port to the host's port 6060 (6061, 6062). Tinode exporter(s) serve(s) metrics for InfluxDB.
Reference configuration for the following databases is also available in the override files:
* [PostgreSQL 15.2](https://hub.docker.com/_/postgres/tags)
* [MongoDB 4.2.3](https://hub.docker.com/_/mongo/tags)
* [RethinkDB 2.4.2](https://hub.docker.com/_/rethinkdb/tags)
## Commands
### Full stack
To bring up the full stack, you can use the following commands:
* MySql:
- Single-instance setup: `docker-compose -f single-instance.yml up -d`
- Cluster: `docker-compose -f cluster.yml up -d`
* PostgreSQL:
- Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.postgres.yml up -d`
- Cluster: `docker-compose -f cluster.yml -f cluster.postgres.yml up -d`
* MongoDb:
- Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.mongodb.yml up -d`
- Cluster: `docker-compose -f cluster.yml -f cluster.mongodb.yml up -d`
* RethinkDb:
- Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d`
- Cluster: `docker-compose -f cluster.yml -f cluster.rethinkdb.yml up -d`
You can run individual/separate components of the setup by providing their names to the `docker-compose` command.
E.g. to start the Tinode server in the single-instance MySql setup,
```
docker-compose -f single-instance.yml up -d tinode-0
```
### Database resets and/or version upgrades
To reset the database or upgrade the database version, you can set `RESET_DB` or `UPGRADE_DB` environment variable to true when starting Tinode with docker-compose.
E.g. for upgrading the database in MongoDb cluster setup, use:
```
UPGRADE_DB=true docker-compose -f cluster.yml -f cluster.mongodb.yml up -d tinode-0
```
For resetting the database in RethinkDb single-instance setup, run:
```
RESET_DB=true docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d tinode-0
```
## Troubleshooting
Print out and verify your docker-compose configuration by running:
```
docker-compose -f <name of the file> [-f <name of override file>] config
```
If the Tinode server(s) are failing, you can print the job's stdout/stderr with:
```
docker logs tinode-<instance number>
```
Additionally, you can examine the jobs `tinode.log` file. To download it from the container, run:
```
docker cp tinode-<instance number>:/var/log/tinode.log .
```
+38
View File
@@ -0,0 +1,38 @@
version: '3.8'
x-mongodb-tinode-env-vars: &mongodb-tinode-env-vars
"STORE_USE_ADAPTER": "mongodb"
services:
db:
image: mongo:4.2.3
container_name: mongodb
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs0" ]
healthcheck:
test: ["CMD", "curl -f http://localhost:28017/ || exit 1"]
# Initializes MongoDb replicaset.
initdb:
image: mongo:4.2.3
container_name: initdb
depends_on:
- db
command: >
bash -c "echo 'Starting replica set initialize';
until mongo --host mongodb --eval 'print(\"waited for connection\")'; do sleep 2; done;
echo 'Connection finished';
echo 'Creating replica set';
echo \"rs.initiate({'_id': 'rs0', "members": [ {'_id': 0, 'host': 'mongodb:27017'} ]})\" | mongo --host mongodb"
tinode-0:
environment:
<< : *mongodb-tinode-env-vars
"WAIT_FOR": "mongodb:27017"
tinode-1:
environment:
<< : *mongodb-tinode-env-vars
tinode-2:
environment:
<< : *mongodb-tinode-env-vars
@@ -0,0 +1,24 @@
version: '3.8'
x-postgres-tinode-env-vars: &postgres-tinode-env-vars
"STORE_USE_ADAPTER": "postgres"
services:
db:
image: postgres:15.2
container_name: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
tinode-0:
environment:
<< : *postgres-tinode-env-vars
"WAIT_FOR": "postgres:5432"
tinode-1:
environment:
<< : *postgres-tinode-env-vars
tinode-2:
environment:
<< : *postgres-tinode-env-vars
@@ -0,0 +1,24 @@
version: '3.8'
x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars
"STORE_USE_ADAPTER": "rethinkdb"
services:
db:
image: rethinkdb:2.4.2
container_name: rethinkdb
healthcheck:
test: ["CMD", "curl -f http://localhost:8080/ || exit 1"]
tinode-0:
environment:
<< : *rethinkdb-tinode-env-vars
"WAIT_FOR": "rethinkdb:8080"
tinode-1:
environment:
<< : *rethinkdb-tinode-env-vars
tinode-2:
environment:
<< : *rethinkdb-tinode-env-vars
+192
View File
@@ -0,0 +1,192 @@
# Reference configuration for a simple 3-node Tinode cluster.
# Includes:
# * Mysql database
# * 3 Tinode servers
# * 3 exporters
version: '3.8'
# Base Tinode template.
x-tinode:
&tinode-base
depends_on:
- db
image: tinode/tinode:latest
restart: always
x-exporter:
&exporter-base
image: tinode/exporter:latest
restart: always
x-tinode-env-vars: &tinode-env-vars
"STORE_USE_ADAPTER": "mysql"
"PPROF_URL": "/pprof"
"REDIS_ADDR": "host.docker.internal:56379"
"REDIS_PASSWORD": "HMTJzkm.h>Y+E9ep"
"REDIS_DB": 0
"REDIS_KEY_PREFIX": "im"
"REDIS_GROUP_FANOUT_ENABLED": "true"
# You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to
# "EXT_CONFIG": "/etc/tinode/tinode.conf"
"WAIT_FOR": "mysql:3306"
# Push notifications.
# Modify as appropriate.
# Tinode Push Gateway configuration.
"TNPG_PUSH_ENABLED": "false"
# "TNPG_USER": "<user name>"
# "TNPG_AUTH_TOKEN": "<token>"
# FCM specific server configuration.
"FCM_PUSH_ENABLED": "false"
# "FCM_CRED_FILE": "<path to FCM credentials file>"
# "FCM_INCLUDE_ANDROID_NOTIFICATION": false
#
# FCM Web client configuration.
"FCM_API_KEY": "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ"
"FCM_APP_ID": "1:114126160546:web:aca6ea2981feb81fb44dfb"
"FCM_PROJECT_ID": "tinode-1000"
"FCM_SENDER_ID": 114126160546
"FCM_VAPID_KEY": "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
"FCM_MEASUREMENT_ID": "G-WNJDQR34L3"
# iOS app universal links configuration.
# "IOS_UNIV_LINKS_APP_ID": "<ios universal links app id>"
# Video calls
"WEBRTC_ENABLED": "false"
# "ICE_SERVERS_FILE": "<path to ICE servers config>"
x-exporter-env-vars: &exporter-env-vars
"TINODE_ADDR": "http://tinode.host:18080/stats/expvar/"
# InfluxDB configation:
"SERVE_FOR": "influxdb"
"INFLUXDB_VERSION": 1.7
"INFLUXDB_ORGANIZATION": "<your organization>"
"INFLUXDB_PUSH_INTERVAL": 30
"INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake"
"INFLUXDB_AUTH_TOKEN": "<auth token>"
# Prometheus configuration:
# "SERVE_FOR": "prometheus"
# "PROM_NAMESPACE": "tinode"
# "PROM_METRICS_PATH": "/metrics"
services:
db:
image: mysql:8.0
container_name: mysql
restart: always
# Use your own volume.
# volumes:
# - <mysql directory in your file system>:/var/lib/mysql
environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 5s
retries: 10
security_opt:
- seccomp=unconfined
# Tinode servers.
tinode-0:
<< : *tinode-base
container_name: tinode-0
hostname: tinode-0
# You can mount your volumes as necessary:
# volumes:
# # E.g. external config (assuming EXT_CONFIG is set).
# - <path to your tinode.conf>:/etc/tinode/tinode.conf
# # Logs directory.
# - <path to your tinode-0 logs directory>:/var/log
ports:
- "6060:6060"
environment:
<< : *tinode-env-vars
"CLUSTER_SELF": "tinode-0"
"RESET_DB": ${RESET_DB:-false}
"UPGRADE_DB": ${UPGRADE_DB:-false}
tinode-1:
<< : *tinode-base
container_name: tinode-1
hostname: tinode-1
# You can mount your volumes as necessary:
# volumes:
# # E.g. external config (assuming EXT_CONFIG is set).
# - <path to your tinode.conf>:/etc/tinode/tinode.conf
# # Logs directory.
# - <path to your tinode-1 logs directory>:/var/log
ports:
- "6061:6060"
environment:
<< : *tinode-env-vars
"CLUSTER_SELF": "tinode-1"
# Wait for tinode-0, not the database since
# we let tinode-0 perform all database initialization and upgrade work.
"WAIT_FOR": "tinode-0:6060"
"NO_DB_INIT": "true"
tinode-2:
<< : *tinode-base
container_name: tinode-2
hostname: tinode-2
# You can mount your volumes as necessary:
# volumes:
# # E.g. external config (assuming EXT_CONFIG is set).
# - <path to your tinode.conf>:/etc/tinode/tinode.conf
# # Logs directory.
# - <path to your tinode-2 logs directory>:/var/log
ports:
- "6062:6060"
environment:
<< : *tinode-env-vars
"CLUSTER_SELF": "tinode-2"
# Wait for tinode-0, not the database since
# we let tinode-0 perform all database initialization and upgrade work.
"WAIT_FOR": "tinode-0:6060"
"NO_DB_INIT": "true"
# Monitoring.
# Exporters are paired with tinode instances.
exporter-0:
<< : *exporter-base
container_name: exporter-0
hostname: exporter-0
depends_on:
- tinode-0
ports:
- 6222:6222
links:
- tinode-0:tinode.host
environment:
<< : *exporter-env-vars
"INSTANCE": "tinode-0"
"WAIT_FOR": "tinode-0:6060"
exporter-1:
<< : *exporter-base
container_name: exporter-1
hostname: exporter-1
depends_on:
- tinode-1
ports:
- 6223:6222
links:
- tinode-1:tinode.host
environment:
<< : *exporter-env-vars
"INSTANCE": "tinode-1"
"WAIT_FOR": "tinode-1:6060"
exporter-2:
<< : *exporter-base
container_name: exporter-2
hostname: exporter-2
depends_on:
- tinode-2
ports:
- 6224:6222
links:
- tinode-2:tinode.host
environment:
<< : *exporter-env-vars
"INSTANCE": "tinode-2"
"WAIT_FOR": "tinode-2:6060"
@@ -0,0 +1,30 @@
version: '3.8'
x-mongodb-tinode-env-vars: &mongodb-tinode-env-vars
"STORE_USE_ADAPTER": "mongodb"
services:
db:
image: mongo:4.2.3
container_name: mongodb
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs0" ]
healthcheck:
test: ["CMD", "curl -f http://localhost:28017/ || exit 1"]
# Initializes MongoDb replicaset.
initdb:
image: mongo:4.2.3
container_name: initdb
depends_on:
- db
command: >
bash -c "echo 'Starting replica set initialize';
until mongo --host mongodb --eval 'print(\"waited for connection\")'; do sleep 2; done;
echo 'Connection finished';
echo 'Creating replica set';
echo \"rs.initiate({'_id': 'rs0', "members": [ {'_id': 0, 'host': 'mongodb:27017'} ]})\" | mongo --host mongodb"
tinode-0:
environment:
<< : *mongodb-tinode-env-vars
"WAIT_FOR": "mongodb:27017"
@@ -0,0 +1,16 @@
version: '3.8'
x-postgres-tinode-env-vars: &postgres-tinode-env-vars
"STORE_USE_ADAPTER": "postgres"
services:
db:
image: postgres:15.2
container_name: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
tinode-0:
environment:
<< : *postgres-tinode-env-vars
"WAIT_FOR": "postgres:5432"
@@ -0,0 +1,16 @@
version: '3.8'
x-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars
"STORE_USE_ADAPTER": "rethinkdb"
services:
db:
image: rethinkdb:2.4.0
container_name: rethinkdb
healthcheck:
test: ["CMD", "curl -f http://localhost:8080/ || exit 1"]
tinode-0:
environment:
<< : *rethinkdb-tinode-env-vars
"WAIT_FOR": "rethinkdb:8080"
+116
View File
@@ -0,0 +1,116 @@
# Reference configuration for a simple Tinode server.
# Includes:
# * Mysql database
# * Tinode server
# * Tinode exporters
version: '3.8'
# Base Tinode template.
x-tinode:
&tinode-base
depends_on:
- db
image: tinode/tinode:latest
restart: always
x-tinode-env-vars: &tinode-env-vars
"STORE_USE_ADAPTER": "mysql"
"PPROF_URL": "/pprof"
"REDIS_ADDR": "host.docker.internal:56379"
"REDIS_PASSWORD": "HMTJzkm.h>Y+E9ep"
"REDIS_DB": 0
"REDIS_KEY_PREFIX": "im"
"REDIS_GROUP_FANOUT_ENABLED": "true"
# You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to
# "EXT_CONFIG": "/etc/tinode/tinode.conf"
"WAIT_FOR": "mysql:3306"
# Push notifications.
# Modify as appropriate.
# Tinode Push Gateway configuration.
"TNPG_PUSH_ENABLED": "false"
# "TNPG_USER": "<user name>"
# "TNPG_AUTH_TOKEN": "<token>"
# FCM specific server configuration.
"FCM_PUSH_ENABLED": "false"
# "FCM_CRED_FILE": "<path to FCM credentials file>"
# "FCM_INCLUDE_ANDROID_NOTIFICATION": false
#
# FCM Web client configuration.
"FCM_API_KEY": "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ"
"FCM_APP_ID": "1:114126160546:web:aca6ea2981feb81fb44dfb"
"FCM_PROJECT_ID": "tinode-1000"
"FCM_SENDER_ID": 114126160546
"FCM_VAPID_KEY": "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
"FCM_MEASUREMENT_ID": "G-WNJDQR34L3"
# iOS app universal links configuration.
# "IOS_UNIV_LINKS_APP_ID": "<ios universal links app id>"
# Video calls
"WEBRTC_ENABLED": "false"
# "ICE_SERVERS_FILE": "<path to ICE servers config>"
x-exporter-env-vars: &exporter-env-vars
"TINODE_ADDR": "http://tinode.host:6060/stats/expvar/"
# InfluxDB configation:
"SERVE_FOR": "influxdb"
"INFLUXDB_VERSION": 1.7
"INFLUXDB_ORGANIZATION": "<your organization>"
"INFLUXDB_PUSH_INTERVAL": 30
"INFLUXDB_PUSH_ADDRESS": "https://mon.tinode.co/intake"
"INFLUXDB_AUTH_TOKEN": "<auth token>"
# Prometheus configuration:
# "SERVE_FOR": "prometheus"
# "PROM_NAMESPACE": "tinode"
# "PROM_METRICS_PATH": "/metrics"
services:
db:
image: mysql:8.0
container_name: mysql
restart: always
# Use your own volume.
# volumes:
# - <mysql directory in your file system>:/var/lib/mysql
environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 5s
retries: 10
security_opt:
- seccomp=unconfined
# Tinode.
tinode-0:
<< : *tinode-base
container_name: tinode-0
hostname: tinode-0
# You can mount your volumes as necessary:
# volumes:
# # E.g. external config (assuming EXT_CONFIG is set).
# - <path to your tinode.conf>:/etc/tinode/tinode.conf
# # Logs directory.
# - <path to your tinode-0 logs directory>:/var/log
ports:
- "6060:6060"
environment:
<< : *tinode-env-vars
"RESET_DB": ${RESET_DB:-false}
"UPGRADE_DB": ${UPGRADE_DB:-false}
# Monitoring.
# Exporters are paired with tinode instances.
exporter-0:
container_name: exporter-0
hostname: exporter-0
depends_on:
- tinode-0
image: tinode/exporter:latest
restart: always
ports:
- "6222:6222"
links:
- tinode-0:tinode.host
environment:
<< : *exporter-env-vars
"WAIT_FOR": "tinode-0:6060"
+37
View File
@@ -0,0 +1,37 @@
FROM alpine:3.14
ARG VERSION=0.16.4
ENV VERSION=$VERSION
LABEL maintainer="Tinode Team <info@tinode.co>"
LABEL name="TinodeMetricExporter"
LABEL version=$VERSION
ENV SERVE_FOR=""
ENV WAIT_FOR=""
ENV TINODE_ADDR=http://localhost/stats/expvar/
ENV INSTANCE="exporter-instance"
ENV INFLUXDB_VERSION=1.7
ENV INFLUXDB_ORGANIZATION="org"
ENV INFLUXDB_PUSH_INTERVAL=60
ENV INFLUXDB_PUSH_ADDRESS=""
ENV INFLUXDB_AUTH_TOKEN=""
ENV PROM_NAMESPACE="tinode"
ENV PROM_METRICS_PATH="/metrics"
WORKDIR /opt/tinode
RUN apk add --no-cache bash
# Fetch exporter build from Github.
ADD https://github.com/tinode/chat/releases/download/v$VERSION/exporter.linux-amd64 ./exporter
COPY entrypoint.sh .
RUN chmod +x exporter && chmod +x entrypoint.sh
ENTRYPOINT ./entrypoint.sh
EXPOSE 6222
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Check if environment variables (provided as argument list) are set.
function check_vars() {
local varnames=( "$@" )
for varname in "${varnames[@]}"
do
eval value=\$${varname}
if [ -z "$value" ] ; then
echo "$varname env var must be specified."
exit 1
fi
done
}
# Make sure the system uses /etc/hosts when resolving domain names
# (needed for docker-compose's `extra_hosts` param to work correctly).
# See https://github.com/gliderlabs/docker-alpine/issues/367,
# https://github.com/golang/go/issues/35305 for details.
echo "hosts: files dns" > /etc/nsswitch.conf
# Accept http requests at.
LISTEN_AT=":6222"
# Required env vars.
common_vars=( TINODE_ADDR INSTANCE SERVE_FOR )
influx_varnames=( INFLUXDB_VERSION INFLUXDB_ORGANIZATION INFLUXDB_PUSH_INTERVAL \
INFLUXDB_PUSH_ADDRESS INFLUXDB_AUTH_TOKEN )
prometheus_varnames=( PROM_NAMESPACE PROM_METRICS_PATH )
check_vars "${common_vars[@]}"
# Common arguments.
args=("--tinode_addr=${TINODE_ADDR}" "--instance=${INSTANCE}" "--listen_at=${LISTEN_AT}" "--serve_for=${SERVE_FOR}")
# Platform-specific arguments.
case "$SERVE_FOR" in
"prometheus")
check_vars "${prometheus_varnames[@]}"
args+=("--prom_namespace=${PROM_NAMESPACE}" "--prom_metrics_path=${PROM_METRICS_PATH}")
if [ ! -z "$PROM_TIMEOUT" ]; then
args+=("--prom_timeout=${PROM_TIMEOUT}")
fi
;;
"influxdb")
check_vars "${influxdb_varnames[@]}"
args+=("--influx_db_version=${INFLUXDB_VERSION}" \
"--influx_organization=${INFLUXDB_ORGANIZATION}" \
"--influx_push_interval=${INFLUXDB_PUSH_INTERVAL}" \
"--influx_push_addr=${INFLUXDB_PUSH_ADDRESS}" \
"--influx_auth_token=${INFLUXDB_AUTH_TOKEN}")
if [ ! -z "$INFLUXDB_BUCKET" ]; then
args+=("--influx_bucket=${INFLUXDB_BUCKET}")
fi
;;
*)
echo "\$SERVE_FOR must be set to either 'prometheus' or 'influxdb'"
exit 1
;;
esac
# Wait for Tinode server if needed.
if [ ! -z "$WAIT_FOR" ] ; then
IFS=':' read -ra TND <<< "$WAIT_FOR"
if [ ${#TND[@]} -ne 2 ]; then
echo "\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT"
exit 1
fi
until nc -z -v -w5 ${TND[0]} ${TND[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 5; done
fi
./exporter "${args[@]}"
+255
View File
@@ -0,0 +1,255 @@
# Docker file builds an image with a tinode chat server.
#
# In order to run the image you have to link it to a running database container. For example, to
# to use RethinkDB (named 'rethinkdb') and map the port where the tinode server accepts connections:
#
# $ docker run -p 6060:6060 -d --link rethinkdb \
# --env UID_ENCRYPTION_KEY=base64+encoded+16+bytes= \
# --env API_KEY_SALT=base64+encoded+32+bytes \
# --env AUTH_TOKEN_KEY=base64+encoded+32+bytes \
# tinode-server
FROM alpine:3.22
ARG VERSION=0.25
ENV VERSION=$VERSION
ARG BINVERS=$VERSION
LABEL maintainer="Tinode Team <info@tinode.co>"
LABEL name="TinodeChatServer"
LABEL version=$VERSION
# Build-time options.
# Database selector. Builds for MySQL by default.
# Alternatively use one of: postgres mongodb rethinkdb for a corresponsing
# DB backend or alldbs to build a generic Tinode docker image, for example:
# `--build-arg TARGET_DB=postgres` to build for PostgreSQL.
ARG TARGET_DB=mysql
ENV TARGET_DB=$TARGET_DB
# Runtime options.
# Specifies the database host:port pair to wait for before running Tinode.
# Ignored if empty.
ENV WAIT_FOR=
# An option to reset database.
ENV RESET_DB=false
# An option to upgrade database.
ENV UPGRADE_DB=false
# Option to skip DB initialization when it's missing.
ENV NO_DB_INIT=true
# Load sample data to database from data.json.
ARG SAMPLE_DATA=data.json
ENV SAMPLE_DATA=$SAMPLE_DATA
# Default country code to use in communication.
ENV DEFAULT_COUNTRY_CODE=US
# The MySQL DSN connection.
ENV MYSQL_DSN='root@tcp(mysql)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci'
ENV REDIS_ADDR=
ENV REDIS_SENTINEL_MASTER_NAME=
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
ENV REDIS_USERNAME=
ENV REDIS_PASSWORD=
ENV REDIS_DB=0
ENV REDIS_KEY_PREFIX=im
ENV REDIS_DIAL_TIMEOUT_MS=2000
ENV REDIS_READ_TIMEOUT_MS=2000
ENV REDIS_WRITE_TIMEOUT_MS=2000
ENV REDIS_PENDING_TTL_SECONDS=120
ENV REDIS_DONE_TTL_SECONDS=86400
ENV REDIS_FAILED_TTL_SECONDS=600
ENV REDIS_HOT_MESSAGE_TTL_SECONDS=86400
ENV REDIS_PRESENCE_TTL_SECONDS=120
ENV REDIS_CACHE_TTL_SECONDS=300
ENV REDIS_SHORT_STATE_TTL_SECONDS=600
ENV REDIS_GROUP_FANOUT_ENABLED=true
ENV KAFKA_ENABLED=false
ENV KAFKA_BROKERS_JSON='[]'
ENV KAFKA_CLIENT_ID=im-server
ENV KAFKA_CONSUMER_GROUP=im-server
ENV KAFKA_DIAL_TIMEOUT_MS=3000
ENV KAFKA_WRITE_TIMEOUT_MS=3000
ENV KAFKA_READ_TIMEOUT_MS=3000
ENV KAFKA_BATCH_TIMEOUT_MS=10
ENV KAFKA_REQUIRED_ACKS=1
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
ENV KAFKA_TLS_ENABLED=false
ENV KAFKA_SASL_ENABLED=false
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=3
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
ENV KAFKA_MESSAGE_BATCH_SIZE=100
ENV KAFKA_MESSAGE_BATCH_WAIT_MS=10
ENV KAFKA_MESSAGE_SINGLE_RETRY_COUNT=3
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
ENV KAFKA_TOPIC_CONFIG_EVENT=im.config.event
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
ENV KAFKA_TOPIC_GROUP_MESSAGE_PERSIST=im.group.message.persist
ENV KAFKA_TOPIC_MESSAGE_PUSH=im.message.push
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=true
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET=
ENV INTERNAL_API_CULTIVATE_SERVICE_NAME=cultivate-server
ENV INTERNAL_API_CULTIVATE_SECRET=
ENV ADMIN_INTERNAL_API_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# The PostgreSQL DSN connection.
ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/meeting_im?sslmode=disable&connect_timeout=10'
# Disable chatbot plugin by default.
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
# Default handler for large files
ENV MEDIA_HANDLER=sys_config
# Whitelisted domains for file and S3 large media handler.
ENV FS_CORS_ORIGINS='["*"]'
ENV AWS_CORS_ORIGINS='["*"]'
# AWS S3 parameters
ENV AWS_ACCESS_KEY_ID=
ENV AWS_SECRET_ACCESS_KEY=
ENV AWS_REGION=
ENV AWS_S3_BUCKET=
ENV AWS_S3_ENDPOINT=
# Default externally-visible hostname for email verification.
ENV SMTP_HOST_URL='http://localhost:6060'
# Email parameters decalarations.
ENV SMTP_SERVER=
ENV SMTP_PORT=
ENV SMTP_SENDER=
ENV SMTP_LOGIN=
ENV SMTP_PASSWORD=
ENV SMTP_AUTH_MECHANISM=
ENV SMTP_HELO_HOST=
ENV EMAIL_VERIFICATION_REQUIRED=
ENV DEBUG_EMAIL_VERIFICATION_CODE=
# Whitelist of permitted email domains for email verification (empty list means all domains are permitted)
ENV SMTP_DOMAINS=''
# Various encryption and salt keys. Replace with your own in production.
# Salt used to generate the API key. Don't change it unless you also change the
# API key in the webapp & Android.
ENV API_KEY_SALT=T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=
# Key used to sign authentication tokens.
ENV AUTH_TOKEN_KEY=wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc=
# Key to initialize UID generator
ENV UID_ENCRYPTION_KEY=dkXxWLwqUNRfYmdXRzOkZw==
# Disable TLS by default.
ENV TLS_ENABLED=false
ENV TLS_DOMAIN_NAME=
ENV TLS_CONTACT_ADDRESS=
# Disable push notifications by default.
ENV FCM_PUSH_ENABLED=false
# Declare FCM-related vars
ENV FCM_API_KEY=
ENV FCM_APP_ID=
ENV FCM_SENDER_ID=
ENV FCM_PROJECT_ID=
ENV FCM_VAPID_KEY=
ENV FCM_MEASUREMENT_ID=
# Enable Android-specific notifications by default.
ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true
# Disable push notifications via Tinode Push Gateway.
ENV TNPG_PUSH_ENABLED=false
# Tinode Push Gateway authentication token.
ENV TNPG_AUTH_TOKEN=
# Tinode Push Gateway organization name as registered at console.tinode.co
ENV TNPG_ORG=
# Disable JPush by default. The handler can be enabled from HotGo sys_config
# through the im_jpush dictionary group when the config stanza is present.
ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push
ENV JPUSH_WORKERS=8
ENV JPUSH_DEVICE_CACHE_TTL_SECONDS=30
ENV JPUSH_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false
ENV JPUSH_USE_ALIAS=false
ENV JPUSH_CLASSIFICATION=1
ENV JPUSH_TITLE=MeetingIM
# Video calls configuration.
ENV WEBRTC_ENABLED=false
ENV ICE_SERVERS_FILE=/opt/tinode/ice-servers.json
# Use the target db by default.
# When TARGET_DB is "alldbs", it is the user's responsibility
# to set STORE_USE_ADAPTER to the desired db adapter correctly.
ENV STORE_USE_ADAPTER=$TARGET_DB
# Url path for exposing the server's internal status. E.g. '/status'
ENV SERVER_STATUS_PATH=''
# Garbage collection of unfinished account registrations.
ENV ACC_GC_ENABLED=false
# Install root certificates, they are needed for email validator to work
# with the TLS SMTP servers like Gmail or Mailjet. Also add bash and grep.
RUN apk update && \
apk add --no-cache ca-certificates bash grep
WORKDIR /opt/tinode
# Copy config template to the container.
COPY config.template .
COPY entrypoint.sh .
COPY ice-servers.json .
# Get the desired Tinode build.
ADD https://github.com/tinode/chat/releases/download/v$BINVERS/tinode-$TARGET_DB.linux-amd64.tar.gz .
# Unpack the Tinode archive.
RUN tar -xzf tinode-$TARGET_DB.linux-amd64.tar.gz \
&& rm tinode-$TARGET_DB.linux-amd64.tar.gz
# Create directory for chatbot data.
RUN mkdir /botdata
# Make scripts runnable
RUN chmod +x entrypoint.sh
RUN chmod +x credentials.sh
# Healthcheck: verify the process and database dependency are responsive.
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
# Generate config from template and run the server.
ENTRYPOINT ["./entrypoint.sh"]
# HTTP, gRPC, cluster ports
EXPOSE 6060 16060 12000-12003
+148
View File
@@ -0,0 +1,148 @@
# Builds the Tinode server + tinode-db + tenant-db-migration tools from THIS
# repo's local source, instead of downloading a pre-built release from GitHub
# (the upstream docker/tinode/Dockerfile does that, so it never picks up the
# tenant/workbench/etc. customizations that live only in this repo's source).
# ---- Stage 1: compile from local source ----
FROM golang:1.26-alpine AS build
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags mysql -o /out/tinode ./server
RUN CGO_ENABLED=0 go build -tags mysql -o /out/init-db ./tinode-db
# scripts/apply_hotgo_tenant_db.go is `//go:build ignore`-tagged (a standalone
# startup database validator, not part of any package) - build it by exact path.
RUN CGO_ENABLED=0 go build -o /out/apply-hotgo-tenant-db ./scripts/apply_hotgo_tenant_db.go
# ---- Stage 2: runtime image ----
FROM alpine:3.22
LABEL maintainer="118im"
LABEL name="ImServer"
# Runtime options (same names/semantics as upstream docker/tinode/Dockerfile).
ENV WAIT_FOR=
ENV RESET_DB=false
ENV UPGRADE_DB=false
ENV NO_DB_INIT=true
# Empty by default: do not load Tinode's canned demo users into a real deployment.
# Override explicitly if sample data is ever wanted.
ENV SAMPLE_DATA=
ENV DEFAULT_COUNTRY_CODE=US
ENV STORE_USE_ADAPTER=mysql
ENV REDIS_ADDR=
ENV REDIS_SENTINEL_MASTER_NAME=
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
ENV REDIS_USERNAME=
ENV REDIS_DB=0
ENV REDIS_DIAL_TIMEOUT_MS=2000
ENV REDIS_READ_TIMEOUT_MS=2000
ENV REDIS_WRITE_TIMEOUT_MS=2000
ENV REDIS_PENDING_TTL_SECONDS=120
ENV REDIS_DONE_TTL_SECONDS=86400
ENV REDIS_FAILED_TTL_SECONDS=600
ENV REDIS_HOT_MESSAGE_TTL_SECONDS=86400
ENV REDIS_PRESENCE_TTL_SECONDS=120
ENV REDIS_CACHE_TTL_SECONDS=300
ENV REDIS_SHORT_STATE_TTL_SECONDS=600
ENV REDIS_GROUP_FANOUT_ENABLED=true
ENV KAFKA_ENABLED=false
ENV KAFKA_BROKERS_JSON='[]'
ENV KAFKA_CLIENT_ID=im-server
ENV KAFKA_CONSUMER_GROUP=im-server
ENV KAFKA_DIAL_TIMEOUT_MS=3000
ENV KAFKA_WRITE_TIMEOUT_MS=3000
ENV KAFKA_READ_TIMEOUT_MS=3000
ENV KAFKA_BATCH_TIMEOUT_MS=10
ENV KAFKA_REQUIRED_ACKS=1
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
ENV KAFKA_TLS_ENABLED=false
ENV KAFKA_SASL_ENABLED=false
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=3
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
ENV KAFKA_MESSAGE_BATCH_SIZE=100
ENV KAFKA_MESSAGE_BATCH_WAIT_MS=10
ENV KAFKA_MESSAGE_SINGLE_RETRY_COUNT=3
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
ENV KAFKA_TOPIC_CONFIG_EVENT=im.config.event
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
ENV KAFKA_TOPIC_GROUP_MESSAGE_PERSIST=im.group.message.persist
ENV KAFKA_TOPIC_MESSAGE_PUSH=im.message.push
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=true
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET=
ENV INTERNAL_API_CULTIVATE_SERVICE_NAME=cultivate-server
ENV INTERNAL_API_CULTIVATE_SECRET=
ENV ADMIN_INTERNAL_API_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# Tenant registry lives in the hotgo database (shared with im_manager); tenant
# business data lives in the shared meeting_im DB and is scoped by tenant_id.
ENV TENANT_REGISTRY_DB=hotgo
ENV MEDIA_HANDLER=sys_config
ENV FS_CORS_ORIGINS='["*"]'
ENV AWS_CORS_ORIGINS='["*"]'
ENV SMTP_HOST_URL='http://localhost:6060'
ENV TEL_HOST_URL='http://localhost:6060'
ENV TLS_ENABLED=false
ENV FCM_PUSH_ENABLED=false
ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true
ENV TNPG_PUSH_ENABLED=false
ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push
ENV JPUSH_WORKERS=8
ENV JPUSH_DEVICE_CACHE_TTL_SECONDS=30
ENV JPUSH_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false
ENV JPUSH_USE_ALIAS=false
ENV JPUSH_CLASSIFICATION=1
ENV JPUSH_TITLE=MeetingIM
ENV WEBRTC_ENABLED=false
ENV ICE_SERVERS_FILE=/opt/tinode/ice-servers.json
ENV SERVER_STATUS_PATH=''
ENV ACC_GC_ENABLED=false
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
RUN apk add --no-cache ca-certificates bash grep
WORKDIR /opt/tinode
COPY docker/tinode/config.template .
COPY docker/tinode/entrypoint.sh .
COPY docker/tinode/ice-servers.json .
COPY tinode-db/credentials.sh .
COPY server/templ ./templ
COPY --from=build /out/tinode .
COPY --from=build /out/init-db .
COPY --from=build /out/apply-hotgo-tenant-db .
RUN mkdir -p /botdata static \
&& chmod +x entrypoint.sh credentials.sh tinode init-db apply-hotgo-tenant-db
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
ENTRYPOINT ["./entrypoint.sh"]
EXPOSE 6060 16060
+316
View File
@@ -0,0 +1,316 @@
{
"listen": ":6060",
"api_path": "/",
"cache_control": 39600,
"static_mount": "/",
"grpc_listen": ":16060",
"grpc_keepalive_enabled": true,
"ws_ping_period_seconds": $WS_PING_PERIOD_SECONDS,
"ws_pong_wait_seconds": $WS_PONG_WAIT_SECONDS,
"api_key_salt": "$API_KEY_SALT",
"max_message_size": 4194304,
"max_subscriber_count": 128,
"max_tag_count": 16,
"expvar": "/stats/expvar/",
"server_status": "$SERVER_STATUS_PATH",
"use_x_forwarded_for": true,
"default_country_code": "$DEFAULT_COUNTRY_CODE",
"media": {
"use_handler": "sys_config",
"max_size": 33554432,
"gc_period": 60,
"gc_block_size": 100,
"handlers": {
"fs": {
"upload_dir": "uploads",
"cache_control": "max-age=86400",
"cors_origins": $FS_CORS_ORIGINS
},
"s3":{
"access_key_id": "$AWS_ACCESS_KEY_ID",
"secret_access_key": "$AWS_SECRET_ACCESS_KEY",
"region": "$AWS_REGION",
"bucket": "$AWS_S3_BUCKET",
"endpoint": "$AWS_S3_ENDPOINT",
"presign_ttl": 3600,
"cache_control": "max-age=86400",
"cors_origins": $AWS_CORS_ORIGINS
},
"oss": {
"use_sys_config": true
}
}
},
"tls": {
"enabled": $TLS_ENABLED,
"http_redirect": ":80",
"strict_max_age": 604800,
"autocert": {
"cache": "/etc/letsencrypt/live/$TLS_DOMAIN_NAME",
"email": "$TLS_CONTACT_ADDRESS",
"domains": ["$TLS_DOMAIN_NAME"]
}
},
"auth_config": {
"logical_names": [],
"basic": {
"add_to_tags": true,
"min_login_length": 3,
"min_password_length": 6
},
"token": {
"expire_in": 1209600,
"serial_num": 1,
"key": "$AUTH_TOKEN_KEY"
},
"code": {
"expire_in": 900,
"max_retries": 3,
"code_length": 6
}
},
"store_config": {
"uid_key": "$UID_ENCRYPTION_KEY",
"max_results": 1024,
"use_adapter": "$STORE_USE_ADAPTER",
"adapters": {
"mysql": {
"database": "meeting_im",
"dsn": "$MYSQL_DSN",
"max_open_conns": $MYSQL_MAX_OPEN_CONNS,
"max_idle_conns": $MYSQL_MAX_IDLE_CONNS,
"registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS,
"registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS,
"conn_max_lifetime": $MYSQL_CONN_MAX_LIFETIME_SECONDS,
"sql_timeout": 10,
"tenant_registry_db": "$TENANT_REGISTRY_DB",
"tenant_db_routing": true
},
"postgres": {
"database": "meeting_im",
"dsn": "$POSTGRES_DSN"
},
"rethinkdb": {
"database": "meeting_im",
"addresses": "rethinkdb"
},
"mongodb": {
"database": "meeting_im",
"addresses": "mongodb",
"replica_set": "rs0"
}
},
"redis": {
"addr": "$REDIS_ADDR",
"sentinel_master_name": "$REDIS_SENTINEL_MASTER_NAME",
"sentinel_addrs": $REDIS_SENTINEL_ADDRS_JSON,
"username": "$REDIS_USERNAME",
"password": "$REDIS_PASSWORD",
"db": $REDIS_DB,
"key_prefix": "$REDIS_KEY_PREFIX",
"dial_timeout_ms": $REDIS_DIAL_TIMEOUT_MS,
"read_timeout_ms": $REDIS_READ_TIMEOUT_MS,
"write_timeout_ms": $REDIS_WRITE_TIMEOUT_MS,
"pending_ttl_seconds": $REDIS_PENDING_TTL_SECONDS,
"done_ttl_seconds": $REDIS_DONE_TTL_SECONDS,
"failed_ttl_seconds": $REDIS_FAILED_TTL_SECONDS,
"hot_message_ttl_seconds": $REDIS_HOT_MESSAGE_TTL_SECONDS,
"presence_ttl_seconds": $REDIS_PRESENCE_TTL_SECONDS,
"cache_ttl_seconds": $REDIS_CACHE_TTL_SECONDS,
"short_state_ttl_seconds": $REDIS_SHORT_STATE_TTL_SECONDS,
"group_fanout_enabled": $REDIS_GROUP_FANOUT_ENABLED
}
},
"kafka": {
"enabled": true,
"brokers": $KAFKA_BROKERS_JSON,
"client_id": "im-server",
"consumer_group": "im-server",
"dial_timeout_ms": 3000,
"write_timeout_ms": 3000,
"read_timeout_ms": 3000,
"batch_timeout_ms": 10,
"required_acks": -1,
"allow_auto_topic_creation": false,
"tls_enabled": false,
"sasl_enabled": false,
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "",
"sasl_password": "",
"max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"message_batch_size": $KAFKA_MESSAGE_BATCH_SIZE,
"message_batch_wait_ms": $KAFKA_MESSAGE_BATCH_WAIT_MS,
"message_single_retry_count": $KAFKA_MESSAGE_SINGLE_RETRY_COUNT,
"dead_letter_retention_seconds": 7776000,
"topics": {
"admin_command": "im.admin.command",
"admin_command_result": "im.admin.command.result",
"config_event": "im.config.event",
"business_event": "im.business.event",
"message_persist": "im.message.persist",
"group_message_persist": "im.group.message.persist",
"message_push": "im.message.push",
"file_security": "im.file.security",
"cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event",
"dead_letter": "im.dead.letter"
}
},
"internal_api": {
"enabled": $INTERNAL_API_ENABLED,
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
"services": {
"$INTERNAL_API_HOTGO_SERVICE_NAME": {
"secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"]
},
"$INTERNAL_API_CULTIVATE_SERVICE_NAME": {
"secret": "$INTERNAL_API_CULTIVATE_SECRET",
"allow": ["cultivate"]
}
}
},
"admin_internal_api": {
"enabled": true,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET",
"timeout_ms": $ADMIN_INTERNAL_API_TIMEOUT_MS
},
"acc_validation": {
"email": {
"add_to_tags": true,
"required": [$EMAIL_VERIFICATION_REQUIRED],
"config": {
"host_url": "$SMTP_HOST_URL",
"smtp_server": "$SMTP_SERVER",
"smtp_port": "$SMTP_PORT",
"sender": "$SMTP_SENDER",
"login": "$SMTP_LOGIN",
"sender_password": "$SMTP_PASSWORD",
"auth_mechanism": "$SMTP_AUTH_MECHANISM",
"smtp_helo_host": "$SMTP_HELO_HOST",
"languages": ["en", "es", "fr", "ru", "vi", "zh"],
"validation_templ": "./templ/email-validation-{{.Language}}.templ",
"reset_secret_templ": "./templ/email-password-reset-{{.Language}}.templ",
"max_retries": 3,
"domains": [$SMTP_DOMAINS],
"debug_response": "$DEBUG_EMAIL_VERIFICATION_CODE"
}
},
"tel": {
"add_to_tags": true,
"config": {
"host_url": "$TEL_HOST_URL",
"languages": ["en", "es", "fr", "pt", "ru", "vi", "zh"],
"sender": "$TEL_SENDER",
"universal_templ": "./templ/sms-universal-{{.Language}}.templ",
"max_retries": 3,
"debug_response": "$DEBUG_TEL_VERIFICATION_CODE"
}
}
},
"acc_gc_config": {
"enabled": $ACC_GC_ENABLED,
"gc_period": 3600,
"gc_block_size": 10,
"gc_min_account_age": 48
},
"push": [
{
"name":"tnpg",
"config": {
"enabled": $TNPG_PUSH_ENABLED,
"token": "$TNPG_AUTH_TOKEN",
"org": "$TNPG_ORG"
}
},
{
"name":"fcm",
"config": {
"enabled": $FCM_PUSH_ENABLED,
"project_id": "$FCM_PROJECT_ID",
"credentials_file": "$FCM_CRED_FILE",
"time_to_live": 3600,
"android": {
"enabled": $FCM_INCLUDE_ANDROID_NOTIFICATION,
"icon": "ic_logo_push",
"icon_color": "#3949AB",
"click_action": ".MessageActivity",
"msg": {
"title_loc_key": "new_message",
"title": "",
"body_loc_key": "",
"body": ""
},
"sub": {
"title_loc_key": "new_chat",
"body_loc_key": ""
}
}
}
},
{
"name": "jpush",
"config": {
"enabled": $JPUSH_PUSH_ENABLED,
"api_domain": "$JPUSH_API_DOMAIN",
"push_path": "$JPUSH_PUSH_PATH",
"workers": $JPUSH_WORKERS,
"device_cache_ttl_seconds": $JPUSH_DEVICE_CACHE_TTL_SECONDS,
"default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS,
"call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS,
"apns_production": $JPUSH_APNS_PRODUCTION,
"use_alias": $JPUSH_USE_ALIAS,
"classification": $JPUSH_CLASSIFICATION,
"title": "$JPUSH_TITLE"
}
}
],
"webrtc": {
"enabled": $WEBRTC_ENABLED,
"call_establishment_timeout": 30,
"ice_servers_file": "$ICE_SERVERS_FILE"
},
"cluster_config": {
"self": "",
"nodes": $CLUSTER_NODES_JSON,
"proxy_to_master_buffer": $CLUSTER_PROXY_TO_MASTER_BUFFER,
"proxy_to_master_workers": $CLUSTER_PROXY_TO_MASTER_WORKERS,
"proxy_to_master_enqueue_timeout_ms": $CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS,
"route_retries": $CLUSTER_ROUTE_RETRIES,
"route_retry_backoff_ms": $CLUSTER_ROUTE_RETRY_BACKOFF_MS,
"failover": {
"enabled": true,
"heartbeat": 100,
"vote_after": 8,
"node_fail_after": 16
}
},
"plugins": [
{
"enabled": $PLUGIN_PYTHON_CHAT_BOT_ENABLED,
"name": "python_chat_bot",
"timeout": 20000,
"filters": {
"account": "C"
},
"failure_code": 0,
"failure_text": null,
"service_addr": "tcp://localhost:40051"
}
]
}
+211
View File
@@ -0,0 +1,211 @@
#!/bin/bash
# If EXT_CONFIG is set, use it as a config file.
if [ ! -z "$EXT_CONFIG" ] ; then
CONFIG="$EXT_CONFIG"
# Enable push notifications.
if [ ! -z "$FCM_SENDER_ID" ] ; then
FCM_PUSH_ENABLED=true
fi
else
CONFIG=working.config
: "${REDIS_KEY_PREFIX:=im}"
: "${WS_PING_PERIOD_SECONDS:=15}"
: "${WS_PONG_WAIT_SECONDS:=90}"
: "${CLUSTER_PROXY_TO_MASTER_BUFFER:=4096}"
: "${CLUSTER_PROXY_TO_MASTER_WORKERS:=16}"
: "${CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS:=300}"
: "${CLUSTER_ROUTE_RETRIES:=3}"
: "${CLUSTER_ROUTE_RETRY_BACKOFF_MS:=25}"
: "${MYSQL_MAX_OPEN_CONNS:=24}"
: "${MYSQL_MAX_IDLE_CONNS:=8}"
: "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}"
: "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}"
: "${MYSQL_CONN_MAX_LIFETIME_SECONDS:=1800}"
if [ -z "$CLUSTER_NODES_JSON" ] ; then
CLUSTER_NODES_JSON='[{"name":"tinode-0","addr":"tinode-0:12000"},{"name":"tinode-1","addr":"tinode-1:12001"},{"name":"tinode-2","addr":"tinode-2:12002"}]'
fi
# Remove the old config.
rm -f working.config
# The 'alldbs' is not a valid adapter name.
if [ "$TARGET_DB" = "alldbs" ] ; then
TARGET_DB=
fi
# Enable email verification if $SMTP_SERVER is defined.
if [ ! -z "$SMTP_SERVER" ] ; then
EMAIL_VERIFICATION_REQUIRED='"auth"'
fi
# Enable TLS (httpS).
if [ ! -z "$TLS_DOMAIN_NAME" ] ; then
TLS_ENABLED=true
fi
# Enable push notifications.
if [ ! -z "$FCM_CRED_FILE" ] ; then
FCM_PUSH_ENABLED=true
fi
if [ ! -z "$TNPG_AUTH_TOKEN" ] ; then
TNPG_PUSH_ENABLED=true
fi
if [ ! -z "$ICE_SERVERS_FILE" ] ; then
WEBRTC_ENABLED=true
fi
# Generate a new 'working.config' from template and environment
while IFS='' read -r line || [[ -n $line ]] ; do
while [[ "$line" =~ (\$[A-Z_][A-Z_0-9]*) ]] ; do
LHS=${BASH_REMATCH[1]}
RHS="$(eval echo "\"$LHS\"")"
line=${line//$LHS/"$RHS"}
done
echo "$line" >> working.config
done < config.template
fi
# If external static dir is defined, use it.
# Otherwise, fall back to "./static".
if [ ! -z "$EXT_STATIC_DIR" ] ; then
STATIC_DIR=$EXT_STATIC_DIR
else
STATIC_DIR="./static"
fi
# Do not load data when upgrading database.
if [ "$UPGRADE_DB" = "true" ] ; then
SAMPLE_DATA=
fi
# If push notifications are enabled, generate client-side firebase config file.
if [ ! -z "$FCM_PUSH_ENABLED" ] || [ ! -z "$TNPG_PUSH_ENABLED" ] ; then
# Write client config to $STATIC_DIR/firebase-init.js
cat > $STATIC_DIR/firebase-init.js <<- EOM
const FIREBASE_INIT = {
apiKey: "$FCM_API_KEY",
appId: "$FCM_APP_ID",
messagingSenderId: "$FCM_SENDER_ID",
projectId: "$FCM_PROJECT_ID",
messagingVapidKey: "$FCM_VAPID_KEY",
measurementId: "$FCM_MEASUREMENT_ID"
};
EOM
else
# Create an empty firebase-init.js
echo "" > $STATIC_DIR/firebase-init.js
fi
if [ ! -z "$IOS_UNIV_LINKS_APP_ID" ] ; then
# Write config to $STATIC_DIR/apple-app-site-association config file.
# See https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html for details.
cat > $STATIC_DIR/apple-app-site-association <<- EOM
{
"applinks": {
"apps": [],
"details": [
{
"appID": "$IOS_UNIV_LINKS_APP_ID",
"paths": [ "*" ]
}
]
}
}
EOM
fi
# Wait for database if needed.
if [ ! -z "$WAIT_FOR" ] ; then
IFS=':' read -ra DB <<< "$WAIT_FOR"
if [ ${#DB[@]} -ne 2 ]; then
echo "\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT"
exit 1
fi
until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 3; done
fi
run_with_retries() {
local name="$1"
shift
local attempts="${STARTUP_RETRY_ATTEMPTS:-10}"
local delay="${STARTUP_RETRY_DELAY_SECONDS:-3}"
local i=1
while [ "$i" -le "$attempts" ]; do
"$@"
local status=$?
if [ "$status" -eq 0 ]; then
return 0
fi
echo "$name failed with status $status (attempt $i/$attempts)."
if [ "$i" -eq "$attempts" ]; then
return "$status"
fi
sleep "$delay"
i=$((i + 1))
done
}
init_db_once() {
./init-db \
--reset=${RESET_DB} \
--upgrade=${UPGRADE_DB} \
--config=${CONFIG} \
--data=${SAMPLE_DATA} \
--no_init=${NO_DB_INIT} \
1>${init_stdout}
}
apply_hotgo_tenant_db_once() {
IM_MYSQL_DSN="${MYSQL_DSN}" ./apply-hotgo-tenant-db
}
init_stdout=./init-db-stdout.txt
if [ "$NO_DB_INIT" = "true" ] ; then
echo "NO_DB_INIT=true: skipping init-db and apply-hotgo-tenant-db."
else
# Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested.
run_with_retries "init-db" init_db_once
if [ $? -ne 0 ]; then
echo "./init-db failed. Quitting."
exit 1
fi
# Validate that startup is pointed at the shared meeting_im database. Schema
# changes are applied separately and are never performed during pod startup.
run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once
if [ $? -ne 0 ]; then
echo "./apply-hotgo-tenant-db failed. Quitting."
exit 1
fi
fi
# If sample data was provided, try to find Tino password.
if [ ! -z "$SAMPLE_DATA" ] ; then
grep "usr;tino;" $init_stdout > /botdata/tino-password
fi
if [ -s /botdata/tino-password ] ; then
# Convert Tino's authentication credentials into a cookie file.
# /botdata/tino-password could be empty if DB was not updated. In such a case the
# /botdata/.tn-cookie will not be modified.
./credentials.sh /botdata/.tn-cookie < /botdata/tino-password
fi
args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL")
# Run the tinode server, mirroring output to both stdout (so kubectl logs/Promtail's
# standard container-log collection actually sees it -- confirmed live that the old
# file-only redirect meant Loki never received a single real tinode log line, only
# this script's own earlier init-db output) and the log file (kept for exec-based
# inspection, e.g. `kubectl exec ... -- cat /var/log/tinode.log`). PIPESTATUS[0] is
# required here: with output piped through tee, a bare $? would report tee's exit
# status (almost always 0) instead of tinode's.
./tinode "${args[@]}" 2>&1 | tee -a /var/log/tinode.log
exit "${PIPESTATUS[0]}"
+5
View File
@@ -0,0 +1,5 @@
[
{
"urls": ["stun:stun.l.google.com:19302"]
}
]
+1248
View File
File diff suppressed because it is too large Load Diff
+1396
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
# Tinode 个人贡献者许可协议
为了明确来自任何个人或实体的贡献所授予的知识产权许可,[Tinode LLC](https://tinode.co) 必须有每位贡献者签署的贡献者许可协议("CLA"),表明同意以下许可条款。此许可是为了保护您作为贡献者以及保护 Tinode LLC;它不会改变您将贡献用于任何其他目的的权利。
您接受并同意以下关于您提交给 Tinode 的现有和未来贡献的条款和条件。除本协议授予 Tinode LLC 和 Tinode LLC 分发的软件接收者的许可外,您保留对您的贡献的所有权利、所有权和利益。
1. 定义。
"您"(或"您的")应指与本协议与 Tinode LLC 达成协议的版权所有者或版权所有者授权的法律实体。对于法律实体,做出贡献的实体与控制该实体、受该实体控制或与该实体共同控制的所有其他实体被视为单一贡献者。就本定义而言,"控制"指(i)直接或间接导致该实体的方向或管理的权力,无论是通过合同或其他方式,或(ii)拥有百分之五十(50%)以上的流通股,或(iii)该实体的实益所有权。
"贡献"应指任何原创作者作品,包括对现有作品的任何修改或添加,由您有意提交给 Tinode LLC 以包含在 Tinode LLC 拥有或管理的任何产品中或作为其文档("作品")。就本定义而言,"提交"指发送给 Tinode LLC 或其代表的任何形式的电子、口头或书面通信,包括但不限于在电子邮件列表、源代码控制系统和由 Tinode LLC 管理或代表 Tinode LLC 管理的问题跟踪系统上的通信,用于讨论和改进作品,但不包括明显标记或由您书面指定为"非贡献"的通信。
2. 版权许可授予。
在遵守本协议条款和条件的前提下,您特此授予 Tinode LLC 和 Tinode LLC 分发的软件接收者永久、全球、非独占、免费、免版税、不可撤销的版权许可,以复制、准备衍生作品、公开展示、公开表演、再许可和分发您的贡献和此类衍生作品。
3. 专利许可授予。
在遵守本协议条款和条件的前提下,您特此授予 Tinode LLC 和 Tinode LLC 分发的软件接收者永久、全球、非独占、免费、免版税、不可撤销(本节规定除外)的专利许可,以制造、已制造、使用、要约销售、销售、进口和以其他方式转移作品,此类许可仅适用于您的贡献单独或与该贡献所提交的作品组合必然侵权的可许可专利权利要求。如果任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的反诉或交叉索赔),声称您的贡献或您贡献的作品构成直接或贡献性专利侵权,则根据本协议授予该实体的该贡献或作品的任何专利许可应于该诉讼提起之日终止。
4. 您声明您有合法资格授予上述许可。如果您的雇主对您创建的包含您贡献的知识产权拥有权利,您声明您已获得代表该雇主做出贡献的许可,您的雇主已放弃其对您贡献给 Tinode 的权利,或您的雇主已与 Tinode LLC 签署单独的企业 CLA。
5. 您声明您的每个贡献都是您的原创作品(见第 7 节关于代表他人提交)。您声明您的贡献提交包含您个人知道的与您贡献的任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的完整详情。
6. 您无需为您的贡献提供支持,除非您愿意提供支持。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或书面同意,您按"原样"提供您的贡献,不提供任何种类的明示或暗示的保证或条件,包括但不限于关于所有权、不侵权、适销性或特定用途适用性的任何保证或条件。
7. 如果您希望提交非您原创的作品,您可以将其与任何贡献分开提交给 Tinode,注明其来源的完整详情以及您个人知道的任何许可或其他限制(包括但不限于相关专利、商标和许可协议),并明确标记该作品为"代表第三方提交:[在此命名]"。
8. 您同意在得知任何会使这些声明在任何方面不准确的事实或情况时通知 Tinode LLC。
---
## [立即签署](https://docs.google.com/forms/d/e/1FAIpQLSfmtJDHzFOJTzIv5jZ-gHRxVU0ysTdIMJakv1xgUUCu_RGeKQ/formResponse)
+35
View File
@@ -0,0 +1,35 @@
# Tinode Individual Contributor License Agreement
In order to clarify the intellectual property license granted with Contributions from any person or entity,
[Tinode LLC](https://tinode.co) must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor,
indicating agreement to the license terms below. This license is for your protection as a Contributor as
well as the protection of Tinode LLC; it does not change your rights to use your own Contributions for any
other purpose.
You accept and agree to the following terms and conditions for Your present and future Contributions
submitted to Tinode. Except for the license granted herein to Tinode LLC and recipients of software distributed
by Tinode LLC, You reserve all right, title, and interest in and to Your Contributions.
1. Definitions.
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Tinode LLC. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Tinode LLC for inclusion in, or documentation of, any of the products owned or managed by Tinode LLC (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Tinode LLC or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Tinode LLC for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
2. Grant of Copyright License.
Subject to the terms and conditions of this Agreement, You hereby grant to Tinode LLC and to recipients of software distributed by Tinode LLC a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
3. Grant of Patent License.
Subject to the terms and conditions of this Agreement, You hereby grant to Tinode LLC and to recipients of software distributed by Tinode LLC a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to Tinode, or that your employer has executed a separate Corporate CLA with Tinode LLC.
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may submit it to Tinode separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
8. You agree to notify Tinode LLC of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
---
## [SIGN NOW](https://docs.google.com/forms/d/e/1FAIpQLSfmtJDHzFOJTzIv5jZ-gHRxVU0ysTdIMJakv1xgUUCu_RGeKQ/formResponse)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+632
View File
@@ -0,0 +1,632 @@
# Tinode IM 功能详解
> 本文档全面介绍 Tinode 即时通讯平台的功能特性,帮助理解产品能力和使用场景。
---
## 目录
- [产品概述](#产品概述)
- [核心功能](#核心功能)
- [消息类型](#消息类型)
- [会话类型](#会话类型)
- [用户体系](#用户体系)
- [群组功能](#群组功能)
- [文件传输](#文件传输)
- [推送通知](#推送通知)
- [视频通话](#视频通话)
- [安全机制](#安全机制)
- [扩展能力](#扩展能力)
- [应用场景](#应用场景)
---
## 产品概述
### 什么是 Tinode
Tinode 是一个开源的即时通讯平台,提供完整的 IM 基础设施:
| 特性 | 说明 |
|------|------|
| **开源免费** | GPL 3.0 许可证,代码完全开源 |
| **跨平台** | 支持 Android、iOS、Web 三端 |
| **多协议** | WebSocket、gRPC、长轮询 |
| **多数据库** | MySQL、PostgreSQL、MongoDB、RethinkDB |
| **高并发** | 支持数万并发连接 |
| **可扩展** | 支持集群部署、插件系统 |
### 核心架构
```
┌─────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Android │ │ iOS │ │ Web │ │
│ │ Tindroid │ │ Tinodious│ │TinodeWeb │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼─────────────┼─────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 协议层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │WebSocket │ │ gRPC │ │Long Poll │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼─────────────┼─────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 服务端 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Hub │ │ Topic │ │ Session │ │ Auth │ │
│ │ 连接中心 │ │ 消息路由 │ │ 会话管理 │ │ 认证模块 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 存储层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MySQL │ │PostgreSQL│ │ MongoDB │ │ RethinkDB│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 核心功能
### 1. 消息收发
#### 基本特性
| 功能 | 说明 |
|------|------|
| **实时消息** | WebSocket 实时推送,毫秒级延迟 |
| **离线消息** | 用户上线自动推送离线期间的消息 |
| **消息回执** | 已发送、已送达、已读取三种状态 |
| **消息撤回** | 支持撤回已发送的消息 |
| **消息引用** | 回复特定消息,形成对话链 |
| **消息转发** | 转发消息到其他会话 |
#### 消息状态流转
```
发送 → 已发送(sent) → 已送达(delivered) → 已读取(read)
│ │ │
│ │ └── 接收方打开会话
│ │
│ └── 接收方客户端收到
│
└── 服务器确认接收
```
### 2. 消息类型
#### 文本消息
- 纯文本消息
- 富文本消息(Drafty 格式)
- 支持@提及功能
- 支持表情符号
#### 多媒体消息
| 类型 | 格式支持 | 说明 |
|------|----------|------|
| **图片** | JPEG, PNG, GIF, WebP | 自动生成缩略图 |
| **视频** | MP4, MOV, WebM | 支持视频预览 |
| **音频** | MP3, AAC, OGG | 语音消息支持 |
| **文件** | 任意格式 | 文档、压缩包等 |
#### 特殊消息
| 类型 | 说明 |
|------|------|
| **位置** | 地理位置分享 |
| **名片** | 用户名片分享 |
| **链接预览** | 自动抓取网页标题、描述、图片 |
### 3. 输入状态
实时显示对方输入状态:
| 状态 | 说明 |
|------|------|
| `kp` | 正在输入文字 |
| `kpa` | 正在录制语音 |
| `kpv` | 正在录制视频 |
---
## 会话类型
### 1. 单聊(P2P)
两个用户之间的私密对话:
| 特性 | 说明 |
|------|------|
| **自动创建** | 首次发消息自动创建会话 |
| **双向同步** | 双方看到不同的会话名(对方用户ID) |
| **加密存储** | 消息加密存储在数据库 |
| **阅后即焚** | 支持(需客户端实现) |
### 2. 群聊(Group)
多用户之间的群组对话:
| 特性 | 说明 |
|------|------|
| **创建群组** | 任意用户可创建群组 |
| **成员上限** | 默认支持数百人(可配置) |
| **权限管理** | 所有者、管理员、普通成员 |
| **邀请加入** | 成员可邀请新成员 |
| **踢出成员** | 管理员可踢出成员 |
| **退出群组** | 成员可主动退出 |
#### 群组角色
| 角色 | 权限 |
|------|------|
| **所有者(Owner)** | 全部权限,可转让所有者 |
| **管理员(Admin)** | 管理成员、修改群信息 |
| **普通成员(Member)** | 发送消息、查看成员 |
### 3. 频道(Channel)
单向广播式群组:
| 特性 | 普通群组 | 频道 |
|------|----------|------|
| 订阅者数量 | 有限制 | 无限制(只读) |
| 消息发送者 | 所有成员 | 仅管理员 |
| 加入通知 | 有 | 无 |
| 消息来源 | 显示发送者 | 不显示 |
| 用途 | 团队协作 | 内容分发 |
### 4. 系统消息(sys)
与系统管理员通信的通道:
- 普通用户可向系统发送消息(如举报)
- root 用户可订阅并查看所有消息
- 用于客服、举报等场景
---
## 用户体系
### 1. 用户属性
| 属性 | 说明 |
|------|------|
| `username` | 登录名(唯一,用于 basic 认证) |
| `public` | 公开资料(昵称、头像、简介) |
| `private` | 私有数据(仅自己可见) |
| `trusted` | 可信标记(由系统设置) |
| `tags` | 发现标签(用于搜索) |
### 2. 用户状态
| 状态 | 说明 |
|------|------|
| `ok` | 正常状态 |
| `susp` | 暂停状态(禁止访问,可恢复) |
| `del` | 已删除状态(软删除) |
### 3. 认证方式
| 方式 | 说明 |
|------|------|
| **Basic** | 用户名 + 密码 |
| **Token** | 加密令牌(主要方式) |
| **Anonymous** | 匿名登录(仅创建账户) |
| **REST** | 外部认证系统集成 |
### 4. 多设备登录
- 支持同一用户多设备同时在线
- 每个设备独立会话
- 消息同步到所有设备
- 设备标识(User Agent)区分
---
## 群组功能
### 1. 群组管理
#### 创建群组
```
{sub topic="new"} → 创建普通群组
{sub topic="nch"} → 创建频道
```
#### 群组信息
| 信息 | 说明 |
|------|------|
| 名称 | 群组名称 |
| 头像 | 群组图标 |
| 简介 | 群组描述 |
| 标签 | 发现标签 |
### 2. 成员管理
#### 邀请加入
```
发送者: {set topic="grpXXX" sub={user="usrYYY", mode="JRWS"}}
接收者: {pres what="acs"} → 收到邀请通知
```
#### 权限控制
| 权限 | 说明 |
|------|------|
| `J` | 加入权限(订阅话题) |
| `R` | 读取权限(接收消息) |
| `W` | 写入权限(发送消息) |
| `P` | 在线状态(查看成员状态) |
| `A` | 管理权限(审批、踢人) |
| `S` | 分享权限(邀请他人) |
| `D` | 删除权限(硬删除消息) |
| `O` | 所有者(完全控制) |
### 3. 群组操作
| 操作 | 说明 |
|------|------|
| 修改群名 | 所有者权限 |
| 修改群头像 | 所有者权限 |
| 修改群公告 | 管理员权限 |
| 设置管理员 | 所有者权限 |
| 踢出成员 | 管理员权限 |
| 解散群组 | 所有者权限 |
---
## 文件传输
### 1. 上传方式
#### 带外上传(推荐)
大文件通过 HTTP POST 单独上传:
```
POST /v0/file/u
Content-Type: multipart/form-data
响应: {ctrl: {params: {url: "/v0/file/s/xxx.jpg"}}}
```
#### 消息引用
```json
{
"pub": {
"head": {"mime": "text/x-drafty"},
"content": {"ent": [{"data": {"ref": "/v0/file/s/xxx.jpg"}}]}
},
"extra": {"attachments": ["/v0/file/s/xxx.jpg"]}
}
```
### 2. 存储方式
| 方式 | 说明 |
|------|------|
| **本地文件系统** | 适用于单机部署 |
| **AWS S3** | 适用于云端部署 |
| **自定义存储** | 实现 media 接口即可 |
### 3. 文件限制
| 配置项 | 说明 |
|------|------|
| `max_file_size` | 最大文件大小 |
| `allowed_mime_types` | 允许的 MIME 类型 |
| `upload_timeout` | 上传超时时间 |
---
## 推送通知
### 1. 支持平台
| 平台 | 推送服务 |
|------|----------|
| Android | Firebase Cloud Messaging (FCM) |
| iOS | FCM / APNs |
| Web | Web Push API |
### 2. 推送内容
```json
{
"topic": "grpXXX",
"xfrom": "usrYYY",
"ts": "2024-01-01T00:00:00Z",
"seq": "123",
"mime": "text/x-drafty",
"content": "消息内容..."
}
```
### 3. 推送策略
| 状态 | 推送行为 |
|------|----------|
| 前台运行 | 不推送,实时接收 |
| 后台运行 | 推送通知 |
| 离线 | 推送通知,上线后同步 |
### 4. 设备管理
- 每个设备注册唯一 `dev_id`
- 支持多设备推送
- 支持设备解绑
---
## 视频通话
### 1. 基本流程
```
┌─────────────────────────────────────────────────────────────┐
│ 呼叫建立 │
│ │
│ 呼叫方 服务端 被叫方 │
│ │ │ │ │
│ │──── {note what="call"} ─┼───── {info what="call"} ────>│
│ │ │ │ │
│ │ │<──── {note what="call"} ─────│ 接听
│ │ │ │ │
│ │<───── {info what="call"} ──────────────────────────────│
│ │ │ │ │
│ │<════════════════════ WebRTC Signaling ════════════════>│
│ │ │ │ │
│ │<═══════════════════════ Media Stream ═════════════════>│
│ │ │ │ │
└─────────────────────────────────────────────────────────────┘
```
### 2. 通话类型
| 类型 | 说明 |
|------|------|
| **音频通话** | 纯语音通话 |
| **视频通话** | 音视频通话 |
| **屏幕共享** | 共享屏幕内容 |
### 3. 信令流程
1. **发起呼叫**: `{note what="call", seq=...}`
2. **接听/拒绝**: `{note what="call", seq=...}`
3. **挂断**: `{note what="call", seq=...}`
4. **WebRTC 信令**: 通过 `{note what="data", content=...}` 传递
---
## 安全机制
### 1. 传输安全
| 层面 | 措施 |
|------|------|
| **传输加密** | TLS/HTTPS/WSS |
| **API 鉴权** | API Key 验证 |
| **防篡改** | HMAC 签名 |
### 2. 认证安全
| 措施 | 说明 |
|------|------|
| **Token 加密** | 令牌使用 AES 加密 |
| **Token 过期** | 可配置过期时间 |
| **密码哈希** | bcrypt 存储密码 |
### 3. 访问控制
#### 权限矩阵
```
实际权限 = want AND given
```
| 操作 | 权限要求 |
|------|----------|
| 发送消息 | W |
| 接收消息 | R |
| 查看成员 | P |
| 邀请成员 | S |
| 管理成员 | A |
| 删除消息 | D |
| 管理群组 | O |
### 4. 数据安全
| 功能 | 说明 |
|------|------|
| **软删除** | 消息标记删除,保留数据 |
| **硬删除** | 物理删除消息(需 D 权限) |
| **消息清理** | 自动清理过期消息 |
---
## 扩展能力
### 1. 插件系统
通过 gRPC Plugin API 开发插件:
```go
service Plugin {
rpc Firehose(stream PluginReq) returns (stream PluginResp);
}
```
#### 插件事件
| 事件 | 说明 |
|------|------|
| `Account creation` | 账户创建 |
| `Message publish` | 消息发布 |
| `Subscription` | 订阅事件 |
| `Topic creation` | 话题创建 |
### 2. 聊天机器人
示例:chatbot 插件
```python
# 监听新用户创建
# 自动发送欢迎消息
# 回复用户消息
```
### 3. 自定义认证
集成外部认证系统:
```
┌─────────────┐ REST API ┌─────────────┐
│ Tinode │ ───────────────> │ External │
│ Server │ │ Auth │
│ │ <─────────────── │ Server │
└─────────────┘ JSON Resp └─────────────┘
```
### 4. 数据库适配
实现 `db.Adapter` 接口即可支持新数据库:
```go
type Adapter interface {
Open(config string) error
Close() error
UserGet(uid string) (*User, error)
MessageSave(msg *Message) error
// ...
}
```
---
## 应用场景
### 1. 社交应用
| 功能 | 实现方式 |
|------|----------|
| 私信 | P2P 话题 |
| 群聊 | 群组话题 |
| 发现用户 | fnd 话题 + tags |
| 用户资料 | me 话题 + public |
### 2. 企业办公
| 功能 | 实现方式 |
|------|----------|
| 部门群组 | 群组话题 |
| 公司公告 | 频道话题 |
| 文件共享 | 带外文件 + 消息引用 |
| 视频会议 | WebRTC 视频通话 |
### 3. 客服系统
| 功能 | 实现方式 |
|------|----------|
| 匿名咨询 | anonymous 认证 |
| 客服工号 | sys 话题 |
| 消息记录 | 历史消息查询 |
| 机器人客服 | chatbot 插件 |
### 4. IoT 通知
| 功能 | 实现方式 |
|------|----------|
| 设备告警 | 群组话题 |
| 远程控制 | 自定义消息格式 |
| 数据上报 | 消息 content 字段 |
---
## 功能对比
### 与其他 IM 方案对比
| 功能 | Tinode | 自建 WebSocket | 第三方 SDK |
|------|--------|----------------|-----------|
| 开源免费 | ✅ | ✅ | ❌ |
| 多端支持 | ✅ | 需自建 | ✅ |
| 消息存储 | ✅ | 需自建 | ✅ |
| 离线推送 | ✅ | 需自建 | ✅ |
| 群组功能 | ✅ | 需自建 | ✅ |
| 视频通话 | ✅ | 需自建 | ✅ |
| 自定义认证 | ✅ | 完全自主 | 受限 |
| 数据私有 | ✅ | ✅ | ❌ |
| 部署难度 | 中等 | 高 | 低 |
---
## 快速体验
### 1. Docker 部署
```bash
# 启动 MySQL 版本
docker run -d -p 6060:18080 \
--name tinode-server \
tinode/tinode-mysql:latest
# 访问 Web 客户端
open http://localhost:6060
```
### 2. 默认账户
使用 `tinode-db` 工具初始化测试数据:
| 用户名 | 密码 | 说明 |
|--------|------|------|
| alice | alice123 | 测试用户 |
| bob | bob123 | 测试用户 |
| carol | carol123 | 测试用户 |
| dave | dave123 | 测试用户 |
| tino | tino123 | 聊天机器人 |
### 3. API 测试
```bash
# 使用 tn-cli 测试
cd tn-cli
pip install -r requirements.txt
python tn-cli.py --login-basic=alice:alice123
```
---
## 客户端下载
| 平台 | 仓库 | 说明 |
|------|------|------|
| Web | github.com/tinode/webapp | React SPA |
| Android | github.com/tinode/tindroid | 原生应用 |
| iOS | github.com/tinode/tinodious | Swift 原生 |
---
## 更多文档
- [项目结构详解](./PROJECT-STRUCTURE.md)
- [代码通读指南](./CODE-WALKTHROUGH.md)
- [消息存储机制](./MESSAGE-STORAGE.md)
- [API 文档](./API-CN.md)
- [Drafty 富文本格式](./drafty.md)
- [视频通话建立](./call-establishment.md)
@@ -0,0 +1,821 @@
# IM 006 商户双机消息压力测试方案
日期:2026-09-25
## 1. 测试目标
> 双机实际操作以 [`loadtest/im-pressure/README.md`](../loadtest/im-pressure/README.md) 为准。当前开发机固定为机器 A,负责数据准备、协调器、agent A、集中采集和最终报告;另一台机器固定为机器 B,只运行 agent B 并回传结果。
> 当前测试环境实际企业码为 `allrich`,沿用本方案最初为 006 商户定义的账号规模、场景和统计口径。已完成的 200 对复测、故障根因和修复证据见 [`docs/test/IM-单聊压测Pod日志根因分析与改进方案-20260927.md`](./test/IM-单聊压测Pod日志根因分析与改进方案-20260927.md)。
本次测试使用 006 商户的 2000 个账号,在两台压测机上各运行 1000 个在线 WebSocket 用户,验证多 Pod 场景下:
1. 1000 对 P2P 会话同时收发时,消息是否被接受、实时投递并最终持久化。
2. 2000 人大群中 1000 个成员轮流发言时,跨 Pod 群广播的丢失率、重复率、乱序率和延迟。
3. 300 对 P2P 与 500 个群发送者同时工作时,混合负载是否相互影响。
4. Tinode 原生 Cluster、Kafka 消息持久化、数据库分表和 WebSocket 下行是否出现容量瓶颈。
本轮不在正式发压窗口内注册账号、加好友或建群。所有数据准备完成并核验后,再开始消息压力测试。
## 2. 本方案对需求的精确定义
### 2.1 账号与机器分片
账号使用只包含小写字母和数字、且不超过 10 个字符的用户名,以满足当前注册校验:
| 压测机 | 账号 | 数量 | 用户名示例 |
| --- | --- | ---: | --- |
| Machine A | A0001-A1000 | 1000 | `lta0001`、`lta1000` |
| Machine B | B0001-B1000 | 1000 | `ltb0001`、`ltb1000` |
固定配对关系为 `A0001 <-> B0001`,依此类推到 `A1000 <-> B1000`。“号码相同”指账号序号相同,不使用相同手机号。
每个账号最终记录:`username`、`user_id`、`token`、`token_expires`、`machine`、`pair_no`、`p2p_topic`、`group_topic`。密码只从环境变量读取,不写入结果文件或日志。
### 2.2 群聊需求解释
“1000 个人间隔 0.1 秒发消息,2 轮合计 2000 条”按全局调度解释:
- 每 100ms 只安排一个发送者发送一条消息。
- 每轮 1000 个发送者各发送 1 条,共 1000 条,持续约 100 秒。
- 连续执行 2 轮,共 2000 条,正式发压约 200 秒。
- 不是每个发送者每 100ms 发一条;后一种解释会产生 10000 条/秒和 100 万条/100 秒,与“合计 2000 条”矛盾。
### 2.3 P2P 场景默认强度
原需求未指定每对消息数。本方案默认每对双方各发 10 条:
- 1000 对同时开始。
- 每个用户每秒发送 1 条,共 10 秒;每次加 0-200ms 确定性抖动,避免所有请求落在同一毫秒。
- 总发布量:`1000 × 2 × 10 = 20000` 条。
- 主要期望投递量:20000 次对端投递;发送方自身回显单独统计,不混入对端丢失率。
消息数、间隔和抖动必须可通过命令行参数修改,但每轮结果必须记录实际参数。
## 3. 协议与接口契约
### 3.1 WebSocket 建连与账号注册
WebSocket 地址为 `/v0/channels`,Upgrade 携带普通 API Key。每个连接严格执行:
```text
Upgrade
-> hi(tenant="006")
-> acc 或 login
-> sub(me)
-> sub(目标 P2P/群 Topic)
-> READY
```
006 是企业商户,注册使用 `registration_type: 0` 和 `tenant_code: "006"`:
```json
{
"acc": {
"id": "prep-acc-lta0001",
"user": "new",
"scheme": "basic",
"username": "lta0001",
"registration_type": 0,
"tenant_code": "006",
"secret": "base64(lta0001:password)",
"login": true,
"desc": {"public": {"fn": "LT A0001"}}
}
}
```
当前 006 环境必须确认未强制手机号或邀请码注册。准备程序应先尝试 basic 登录:已存在且密码正确则复用;仅在账号不存在时注册。账号存在但密码不匹配必须停止,禁止覆盖真实账号。
### 3.2 HTTP 公共请求头
好友、隐私和群接口使用登录返回的同一 Token:
```text
X-MeetingIM-APIKey: <ordinary api key>
X-IM-Schema: 1
X-Request-ID: <unique request id>
Authorization: Bearer <token>
Idempotency-Key: <unique key for write requests>
```
所有密钥和 Token 通过环境变量或权限受控文件传入,日志只记录账号索引和脱敏 ID。
### 3.3 好友准备
对每个序号执行:
1. A 调用 `POST /v1/im/friend-requests`,Body 为 `{"target_user_id":"usr_b","message":"loadtest","source":"search","source_topic":""}`。
2. 若返回 `pending`,B 调用 `POST /v1/im/friend-requests/{request_id}/accept`。
3. 若已经是好友,读取好友列表或会话 context 并复用关系。
4. 保存响应中的 P2P `topic`;必要时调用 `POST /v1/im/conversations/p2p`,Body 为 `{"peer_user_id":"usr_b"}`。
5. 最终核验 1000 对关系均为 `friend`,且双方能订阅同一个 P2P Topic。
好友创建会产生 `friend_added` 系统消息,因此正式统计只识别本轮 `run_id` 对应的 `x-im-client-mid`,不能把 Topic 的初始 `seq` 当成 0。
### 3.4 2000 人群准备
1. 使用 A0001 调用 `POST /v1/im/groups` 创建群。创建接口最多附带 99 个成员,建议只创建群主。
2. 先确认所有账号的 `group_invite_verification=false`,避免进入当前尚未完整开放的邀请确认链路。
3. 调用 `POST /v1/im/groups/{topic}/invitations` 分批邀请,每批不超过 100 人,每批使用独立 `Idempotency-Key`。
4. 邀请完成后分页调用 `GET /v1/im/groups/{topic}/members`,核验去重后的有效成员恰好为 2000。
5. 2000 个 WebSocket 全部订阅该群,并完成屏障消息验证后才允许发压。
直接邀请会产生群业务事件和系统消息。准备结束后至少静默 30 秒,并记录正式测试前的群 `baseline_seq`;统计时仍以 `run_id/client_mid` 为准。
## 4. 环境硬门槛
任一门槛不满足,本轮标记为 `INVALID`,不能把结果当作服务端性能结论:
1. `max_subscriber_count >= 2500`。仓库配置默认是 128,必须在所有 Pod 上统一调高并滚动重启。
2. 006 商户 `max_group_members` 为 0(不限)或不小于 2000;目标群 `max_member_count` 同样满足要求。
3. IM Server 所有 Pod 版本和配置一致,原生 Cluster 成员数与 Running Pod 数一致。
4. Kafka 的 P2P、群消息持久化 Topic 可写,消费组无持续积压,死信在基线时为 0;测试环境的 `im.message.persist` 和 `im.group.message.persist` 均为 3 分区、3 副本。
5. 两台压测机到目标入口网络稳定,文件描述符上限、临时端口和 NAT 容量足够。
6. 两台压测机使用 chrony/NTP,同一时刻的时钟偏差绝对值小于 5ms,否则跨机器单向延迟无效。
7. 每台压测机至少预留 2 CPU、4GB 内存和稳定的 100Mbps 网络;测试进程自身 CPU 不应长期超过 70%。
8. 2000 个连接全部 READY;正式阶段不允许把连接失败用户从分母中静默删除。
## 5. 多 Pod 拓扑
仅通过同一个负载均衡入口连接,不能证明消息确实跨 Pod。推荐提供仅供压测使用的三个直连入口,并保持业务数据路径不变:
```text
Machine A: A0001-A0500 -> Pod 0
A0501-A1000 -> Pod 1
Machine B: B0001-B1000 -> Pod 2
```
这样 1000 对 P2P 的双方一定不在同一个 Pod;群成员也覆盖全部 3 个 Pod。若只能提供两个直连入口,则 Machine A 固定 Pod 0、Machine B 固定 Pod 1,并在另一轮把 Machine B 切到 Pod 2 重跑。
入口必须能明确确认实际 Pod,例如握手诊断日志或反向代理访问日志能关联 `session -> pod`。不要依赖负载均衡概率分布。
Topic 消息在启用 Tinode 原生 Cluster 时由 Cluster 路由;此时 Redis realtime 指标中的 `publish/skip_native_cluster` 是预期状态,不代表丢失。Redis 仍需观察用户级业务事件和 Session 控制类 fanout。
## 6. 统一消息格式
所有正式测试消息都使用 JSON 文本消息,并设置 `noecho:false`:
```json
{
"pub": {
"id": "s1-a0001-00001",
"topic": "p2p_or_grp",
"noecho": false,
"head": {
"mime": "application/json",
"x-im-schema": 1,
"x-im-type": "text",
"x-im-client-mid": "lt-20260925-s1-a0001-00001"
},
"content": {
"text": "loadtest",
"run_id": "lt-20260925-s1",
"scenario": "p2p",
"sender_index": 1,
"message_index": 1,
"sent_at_unix_nano": 0
}
}
}
```
`x-im-client-mid` 在 `tenant + topic + sender` 范围内必须唯一,重试时必须复用原值。发送程序用 `{ctrl.id}` 关联请求,用 `ctrl.params.seq` 记录服务端分配的 Topic 序号。
## 7. 执行阶段
### 7.1 阶段 P0:预检与热身
1. 两台机器各以 50 连接/秒建立 1000 条连接,总建连时间约 20 秒。
2. 每个用户订阅 `me`、自己的 P2P Topic 和群 Topic。
3. 群主发送 1 条不计入正式结果的群屏障消息;必须在 10 秒内被 2000 个连接全部收到。
4. 每个 P2P Topic 发送 1 条不计入正式结果的屏障消息;双方必须收到,且对端投递为 1000/1000。
5. 等待 30 秒,确认错误计数、Kafka lag、CPU、内存和连接数回到稳定状态。
### 7.2 阶段 S1:1000 对 P2P 同时聊天
参与者:全部 2000 人,1000 个 P2P Topic。
默认调度:
- A、B 双方各发送 10 条,每用户 1 条/秒,持续 10 秒。
- 每个用户使用由账号索引计算的 0-200ms 固定抖动,确保可复现。
- 总发布量 20000,平均发布速率约 2000 条/秒。
- 发布完成后保留连接并等待 30 秒排空,再进行历史核对。
主要期望:每条被服务端接受的消息恰好被对应的另一方实时收到一次。发送方回显也应收到一次,但作为独立指标。
### 7.3 阶段 S2:2000 人群、1000 人发言
参与者:2000 个在线订阅者。发送者选择 A0001-A0500 与 B0001-B0500,共 1000 人,避免所有发布压力只落在一台压测机上。
默认调度:
- Machine A 每 200ms 发 1 条,Machine B 在其后错开 100ms 发 1 条,合并后全局间隔为 100ms。
- 每轮每个发送者恰好发送 1 条,共 1000 条、约 100 秒。
- 连续 2 轮,共 2000 条、约 200 秒。
- `noecho:false`,因此每条被接受的消息期望投递到 2000 个在线连接。
- 理论实时投递总量:`2000 × 2000 = 4000000` 次,平均约 20000 次/秒。
- 发布结束后等待 60 秒排空,然后由多个校验账号分页拉取历史。
### 7.4 阶段 S3:P2P 与群聊混合
正式窗口为 100 秒:
- P2P:A0001-A0300 与 B0001-B0300,共 300 对;双方各发 5 条,总发布量 `300 × 2 × 5 = 3000`。在第 0、20、40、60、80 秒附近发送,并加入固定抖动。
- 群聊:A0301-A0550 与 B0301-B0550,共 500 个发送者;每人发 2 条。两台机器错峰形成全局 100ms 间隔,两轮共 1000 条,持续约 100 秒。
- 其余用户保持在线并接收群消息。
- 群实时理论投递总量:`1000 × 2000 = 2000000` 次。
- P2P 主要理论对端投递总量:3000 次;发送方回显另计 3000 次。
- 发布结束后等待 60 秒排空,再做 P2P 和群历史核对。
混合场景的群发送者与 P2P 发送者故意分开,便于判断瓶颈来自连接写队列、群广播还是同一连接并发写。通过后可增加一轮“发送者重叠”增强测试。
## 8. 丢失率与正确性口径
不能只统计压测端成功调用 `WriteJSON` 的数量。每轮必须同时输出以下四层结果。
### 8.1 发送接受率
```text
publish_accept_rate = ctrl_202_with_seq / publish_attempted
publish_reject_rate = (ctrl_4xx + ctrl_5xx + ctrl_timeout + write_error) / publish_attempted
```
只有收到 `ctrl.code=202` 且包含有效 `params.seq` 的消息,才进入后续“期望投递”分母。
### 8.2 实时投递丢失率
实时到达率必须由 2000 个 WebSocket 连接实际监听得到,不能使用发送 ACK、Kafka offset 或数据库行数代替。每个连接建立后启动一个常驻 read loop,全量读取服务端下行,并把 `{ctrl}`、`{data}`、`{meta}`、`{pres}`、`{info}` 分流处理。
监听器要求:
1. 每个 WebSocket 只能有一个 goroutine 调用 `ReadMessage/ReadJSON`,避免多个读取者争抢数据;`ctrl` 通过 `id` 分发给等待中的发布请求。
2. 只统计 `head.x-im-client-mid` 前缀和 `content.run_id` 都匹配本轮的 `{data}`,忽略准备阶段系统消息、其他业务消息和历史噪声。
3. 记录 `receiver_index + client_mid + seq + first_received_at`。相同接收者再次收到同一 `client_mid` 计为重复,不增加到达数。
4. 服务端允许 `{data}` 早于对应 `{ctrl 202}` 到达,因此监听器先保存观测事实,最终再与 ACK ledger 对账,不能因为 ACK 尚未到达而丢弃该 `{data}`。
5. P2P 以配对的另一方为主要期望接收者;发送者自身回显单独统计。
6. 群消息以发压开始时的 2000 个 READY 成员为固定分母。因为 `noecho:false`,发送者本人也属于期望接收者。
7. 正式窗口内连接断开时不得从分母中删除。记录断开时间、所属机器和 Pod;服务端导致的断开计入结果,压测机自身饱和导致的断开则将整轮标记为无效。
8. P2P 发布结束后继续监听 30 秒,群聊和混合场景继续监听 60 秒。窗口内未收到计为实时丢失;窗口后到达另记 `late_delivery`,不能把它改写为正常实时到达。
期望投递账本由成功 ACK 生成:
```text
P2P expected key = run_id + client_mid + peer_receiver_index
Group expected key = run_id + client_mid + each_ready_receiver_index
```
群场景 2000 条消息对应 400 万个期望键。实现时为每个接收者维护按消息序号索引的 bitset,不保存 400 万份完整 JSON;延迟使用 HDR Histogram 或固定桶,缺失和重复才输出逐条明细。
P2P 主要口径:
```text
p2p_realtime_loss =
(accepted_messages - unique_peer_data_received_within_drain_window)
/ accepted_messages
```
群聊口径:
```text
group_expected_deliveries = accepted_group_messages × ready_group_subscribers
group_realtime_loss =
(group_expected_deliveries - unique_group_deliveries_within_drain_window)
/ group_expected_deliveries
```
每个接收者按 `run_id + x-im-client-mid` 去重。重复到达不用于抵消其他消息的缺失。
同时输出:
```text
arrival_rate = unique_deliveries_within_drain_window / expected_deliveries
loss_rate = 1 - arrival_rate
late_rate = unique_late_deliveries / expected_deliveries
duplicate_rate = duplicate_deliveries / expected_deliveries
```
实时监听结束后继续执行历史回查,将缺失分为:
- `realtime_lost_but_persisted`:WS 窗口内没收到,但历史中存在,说明实时投递链路丢失,客户端可通过重连补偿。
- `persisted_but_late`:超过实时排空窗口才由 WS 收到,同时历史中存在。
- `durable_lost`:WS 未收到且最长等待后历史也不存在,属于真正的持久化消息丢失。
- `receiver_disconnected`:期望接收者在消息窗口内断线,单独统计并保留在实时丢失分母中。
### 8.3 持久化丢失率
Kafka 持久化是异步链路,`ctrl 202` 不等于数据库已经可查。排空后通过 `{get what:"data"}` 分页回查:
```text
persistence_loss =
(accepted_unique_messages - unique_messages_found_in_history)
/ accepted_unique_messages
```
- P2P:1000 个 Topic 各由一侧账号回查。
- 群聊:至少使用分别连接不同 Pod 的 3 个成员回查,三份结果应完全一致。
- 最长等待 120 秒,每 5 秒重试一次;超时仍不存在才记为持久化丢失。
### 8.4 重复与乱序
```text
duplicate_rate = duplicate_deliveries / expected_deliveries
out_of_order_rate = receivers_observing_seq_regression / ready_receivers
```
同时记录:同一 `client_mid` 对应多个 `seq`、同一 `seq` 对应多个测试消息、接收端 Topic `seq` 回退、ACK `seq` 与下行 `seq` 不一致。这些都必须单独报错,不能归入普通丢失。
## 9. 延迟口径
记录以下时间点:
- `t_write`:压测端开始写 `{pub}`。
- `t_ack`:收到对应 `{ctrl 202}`。
- `t_data`:接收者收到并完成 JSON 解析。
输出:
```text
ack_latency = t_ack - t_write
e2e_realtime_latency = t_data - sent_at
```
每个场景输出 p50、p95、p99、p99.9 和 max。跨机器延迟仅在 NTP 偏差达标时有效;否则仍可统计 ACK 延迟,但端到端单向延迟标记为无效。
群聊 400 万次投递不应把所有延迟样本常驻内存。使用固定桶直方图或 HDR Histogram,并按接收端机器和 Pod 分组。
## 10. 建议通过标准
第一轮基线建议使用:
| 指标 | 标准 |
| --- | --- |
| READY 连接 | 2000/2000 |
| 发布接受率 | 100% |
| 持久化丢失率 | 0 |
| 同一 `client_mid` 多 `seq` | 0 |
| P2P 对端实时丢失率 | <= 0.001%,且任何丢失都要输出明细 |
| 群实时投递丢失率 | <= 0.001%,发布版本验收目标为 0 |
| 重复投递率 | 0 |
| Topic `seq` 回退 | 0 |
| ACK p95 / p99 | <= 500ms / <= 1s;必须用原始 ACK 时间戳计算,不能用直方图桶上界代替 |
| 实时投递 p95 / p99 | <= 1s / <= 2s;跨机器时要求时钟偏差达标 |
| `deliver/hub_full`、WS 写队列满 | 0 |
| Kafka producer/commit 错误 | 0 |
| Kafka 消费重试、单条降级、死信新增 | 0 |
| Kafka 排空 | 发布结束后 60 秒内 lag 回到基线/0 |
| Kafka 到 MySQL 落库 p95 / p99 | < 200ms / < 500ms |
| MySQL 实际落库数 | 与 `ctrl 202` 唯一消息数完全一致 |
| MySQL deadlock、lock timeout、连接池耗尽 | 0 |
| Redis 操作错误、淘汰、拒绝连接 | 0 |
| Redis command p95 / p99 | < 5ms / < 10ms |
| Pod CPU throttling ratio | < 10% |
| Pod CPU、内存峰值 | < limit 的 80% |
| 容器/节点网络 drop、error | 0 新增或不高于基线噪声 |
| 压测机 CPU、内存、网络 | < 70%,无 read-loop backlog |
| Pod 重启、OOM、持续 GC/内存增长 | 0 |
如果实时有缺失但历史回查完整,结论应写“实时链路丢失、可由历史恢复”,不能写成“消息未丢失”。如果历史也缺失,则属于持久化消息丢失,直接判失败。
## 11. 全链路观测与资源采集
### 11.1 观测目标与时间窗口
监控必须能把同一轮测试拆成以下链路:
```text
压测机 WriteJSON
-> IM Pod 收到 pub
-> Redis 分配消息 seq / 幂等 claim
-> Kafka producer 写入消息持久化 Topic
-> IM Pod 返回 ctrl 202 并实时广播
-> Kafka consumer 拉取与组批
-> MySQL 批量提交
-> Kafka offset commit
-> WebSocket 历史查询可见
```
每轮采集窗口为:正式 `start_at` 前 10 分钟、正式测试全程、排空与历史核对结束后 10 分钟。Prometheus 在压测期间建议使用 5 秒抓取周期,重要 counter 以 `increase` 计算,不使用瞬时值推算总量。
每轮在 Grafana 或结果文件中记录四个时间点:
- `baseline_start`:基线开始。
- `load_start`:正式发送开始。
- `load_end`:最后一次发布结束。
- `reconcile_end`:历史核对完成。
所有图表使用相同 UTC 时间轴,并标注 `run_id`、scenario、Git commit、Pod 镜像版本和压测参数。
### 11.2 采集组件
推荐最小采集栈:
| 对象 | 采集方式 | 主要用途 |
| --- | --- | --- |
| IM Server | `/metrics`、`/debug/vars` | WS、HTTP、DB、fanout、Go runtime、连接池 |
| Kubernetes Pod/Container | kube-state-metrics + kubelet/cAdvisor | CPU、内存、磁盘 IO、网络、重启、限流 |
| Kubernetes Node | node-exporter | 节点 CPU、内存、磁盘、网络、conntrack |
| Kafka | kafka-exporter + Broker JMX Exporter | consumer lag、吞吐、ISR、请求队列、磁盘 |
| Redis | redis-exporter | OPS、延迟、内存、连接、命中率、淘汰、网络 |
| MySQL | mysqld-exporter + performance_schema | QPS、事务、锁、Buffer Pool、慢 SQL、IO |
| 两台压测机 | node-exporter + 压测 agent `/metrics` | 生成端 CPU/网络/连接和客户端统计 |
| 链路探测 | blackbox-exporter 或压测 agent 主动探测 | DNS、TCP、HTTP、依赖 RTT |
如果环境没有完整 Prometheus 栈,压测前至少以 5 秒周期抓取上述 HTTP 指标,并同步保存 `kubectl top`、Pod 日志、Kafka consumer group、Redis INFO 和 MySQL status。手工快照只能作为兜底,不能替代连续时间序列。
### 11.3 当前已有与必须补充的应用指标
当前 IM Server 已经提供:
| 指标 | 状态 | 用途 |
| --- | --- | --- |
| `ws_connections_active` | 已有 | 当前 Pod/实例的活跃 WS 数 |
| `http_requests_total` | 已有 | HTTP 请求量和错误码 |
| `http_request_duration_seconds` | 已有 | HTTP 延迟 |
| `db_query_duration_seconds` | 已有 | MySQL query/exec/commit 等通用耗时 |
| `realtime_fanout_events_total` | 已有 | Redis realtime 发布、接收和本地投递结果 |
| `realtime_fanout_delivery_duration_seconds` | 已有 | Redis realtime 消息年龄 |
| `/debug/vars` 中 `DbStats` | 已有 | Go SQL 连接池状态 |
| `/debug/vars` 中 Cluster/Session/Topic 计数 | 已有 | Cluster 节点、会话和活跃 Topic |
为准确测量 Kafka 落库速度,压测版本必须再补以下低基数指标。严禁把 `topic`、`user_id`、`client_mid`、`run_id` 或 tenant ID 放入 Prometheus label:
```text
message_persist_events_total{stage,kind,status}
message_persist_duration_seconds{stage,kind,status}
message_persist_end_to_end_seconds{kind,status}
message_persist_batch_size{kind}
message_persist_inflight{kind}
kafka_consumer_commit_total{topic_kind,status}
redis_operation_duration_seconds{operation,status}
redis_operation_errors_total{operation}
mysql_pool_connections{state}
mysql_pool_wait_total
mysql_pool_wait_duration_seconds_total
cluster_route_events_total{operation,status}
cluster_route_duration_seconds{operation,status}
ws_outbound_events_total{status}
ws_outbound_queue_depth
```
标签约定:
- `kind`:仅允许 `p2p`、`group`。
- `stage`:`producer_write`、`consumer_fetch`、`db_batch`、`consume_finish`。
- `status`:`ok`、`error`、`duplicate`、`retry`、`dead`。
- `operation`:Redis 使用 `seq_reserve`、`unique_claim`、`hot_cache`、`fanout_publish`、`fanout_receive`;Cluster 使用 `route`、`proxy_join`、`proxy_leave`。
`message_persist_end_to_end_seconds` 从 Kafka envelope 的 `occurred_at` 计算到 MySQL 提交并标记消费成功的时刻。它是回答“消息从服务端接受到真正落库用了多久”的核心指标。
另外应在自定义 Prometheus Registry 中注册 Go collector 和 process collector,提供标准 `go_*`、`process_*` 指标;在完成前从 `/debug/vars` 采集对应 runtime 数据。
### 11.4 Kafka 处理速度与积压
Kafka 需要同时观察应用层和 Broker 层,至少包含:
1. P2P Topic `im.message.persist` 的生产/消费条数与字节数。
2. 群 Topic `im.group.message.persist` 的生产/消费条数与字节数。
3. 对应 consumer group 每个 partition 的 lag、总 lag 和 lag 恢复到 0 的时间。
4. producer 写入延迟、错误、重试和 timeout。
5. consumer fetch 速率、批量大小、DB 批次耗时、offset commit 延迟和失败。
6. 批量落库失败后降级为单条写入的次数。
7. 消费重试、Redis 幂等 claim 失败和死信新增数。
8. Broker request queue、network processor idle、request handler idle、ISR shrink、under-replicated/offline partition 和磁盘使用率。
不同 exporter 的 lag 指标名可能是 `kafka_consumergroup_lag` 或 `kafka_consumergroup_group_lag`,部署时必须以实际 `/metrics` 为准建立 dashboard,不允许直接照抄不存在的指标名。
典型查询:
```promql
sum by (kind, stage, status) (
rate(message_persist_events_total[1m])
)
```
```promql
histogram_quantile(
0.99,
sum by (le, kind) (
rate(message_persist_end_to_end_seconds_bucket[5m])
)
)
```
```promql
sum by (consumergroup, topic) (
kafka_consumergroup_lag{
topic=~"im.message.persist|im.group.message.persist"
}
)
```
每轮报告必须给出:峰值生产速度、峰值消费速度、峰值 lag、lag 清零耗时、平均/最大 batch size、DB batch p95/p99、端到端落库 p95/p99/max、重试数和死信数。
### 11.5 数据库落库正确性与速度
006 商户使用默认库 `meeting_im`,但消息表名按数据库中的数值 `tenant_id` 生成,不能直接把企业码字符串当表后缀。准备阶段先从租户注册信息解析并记录 006 的真实 `tenant_id`,再生成只读核对 SQL。
当前消息存储位置:
- P2P:`im_<tenant_id三位补零>_messages`,例如真实 tenant ID 为 6 时是 `im_006_messages`。
- 群聊:`im_<tenant_id三位补零>_group_messages_YYYYMM`。
- 群消息分片注册:`im_group_message_shards`。
- Kafka 消费幂等状态:Redis `kafka_consume` namespace,TTL 7 天。
- Kafka 死信:MySQL `im_kafka_dead_letters`。
正式核对优先使用 WebSocket 历史接口,SQL 作为服务端审计证据。SQL 由核对程序使用白名单表名生成,不允许把外部输入直接拼入表名。
P2P 落库数量示例:
```sql
SELECT
COUNT(*) AS rows_written,
COUNT(DISTINCT client_msg_id) AS unique_client_mid,
MIN(createdat) AS first_persisted_at,
MAX(createdat) AS last_persisted_at
FROM im_006_messages
WHERE client_msg_id LIKE 'lt-20260925-s1-%';
```
群消息落库数量示例:
```sql
SELECT
COUNT(*) AS rows_written,
COUNT(DISTINCT client_msg_id) AS unique_client_mid,
MIN(created_at) AS first_persisted_at,
MAX(created_at) AS last_persisted_at
FROM im_006_group_messages_202609
WHERE client_msg_id LIKE 'lt-20260925-s2-%';
```
重复检查:
```sql
SELECT client_msg_id, COUNT(*) AS cnt, MIN(seqid), MAX(seqid)
FROM im_006_messages
WHERE client_msg_id LIKE 'lt-20260925-s1-%'
GROUP BY client_msg_id
HAVING COUNT(*) <> 1;
```
群表执行同样检查。P2P 有 1000 个 Topic,不能用整张表的 `MAX(seq)-MIN(seq)+1` 判断丢失;必须以压测端保存的 `client_mid -> topic -> ack_seq` manifest 与数据库逐条比对。
MySQL 性能至少采集:
- `Threads_connected`、`Threads_running`、连接使用率和连接池 wait count/duration。
- QPS、Com_insert、事务提交/回滚、行写入速度。
- InnoDB Buffer Pool 命中率、dirty pages、checkpoint age、log waits。
- row lock waits/time、deadlock、慢 SQL、临时磁盘表。
- data/log fsync、读写 IOPS、吞吐、磁盘 await、util 和剩余空间。
- P2P 批量 INSERT、群月表批量 INSERT 的 p95/p99。
- 主从架构下的 replication lag;压测历史回查必须明确读主还是读从。
数据库落库速度报告包含两个值:
```text
db_write_throughput = 测试消息成功落库数 / 首次到最后一次 DB commit 时间
drain_time = 最后一条 ctrl 202 到 Kafka lag=0 且全部历史可见的时间
```
### 11.6 Redis 观测
Redis 在当前消息链路中承担 seq 分配、`client_mid` 幂等 claim、群热消息缓存、Kafka 消费幂等状态和用户级 realtime fanout。需要采集:
- `instantaneous_ops_per_sec`、命令总量与各命令耗时。
- connected/blocked clients、连接拒绝和连接创建速度。
- used memory、RSS、碎片率、maxmemory 使用率。
- evicted/expired keys、keyspace hit/miss。
- network input/output bytes、Pub/Sub channel/pattern/subscriber 数。
- Redis 主线程 CPU、事件循环延迟、slowlog 数量。
- Sentinel/主从环境的 role、replication offset、replication lag、failover。
- `seq_reserve`、`unique_claim`、`hot_cache`、`kafka_consume` 操作错误和 p95/p99。
压测期间禁止使用无界 `KEYS` 或全库 `SCAN` 做统计,这会污染结果。Kafka 消费状态只通过应用指标抽样统计,不逐条扫描 Redis key。
常见异常判定:
- evicted keys 或 rejected connections 增加:直接判失败。
- used memory 持续增长且排空后不回落:检查 hot message、幂等 key TTL 和消费记录泄漏。
- p99 command latency 超过 10ms:标记风险;超过 50ms 且与消息延迟尖刺相关时判失败。
- replication lag 持续扩大或发生 failover:本轮单独标记,不与无故障基线混合。
### 11.7 Pod、容器与节点资源
每个 IM Pod 单独展示,不只看 namespace 汇总:
```promql
sum by (pod) (
rate(container_cpu_usage_seconds_total{namespace="im-server-system",container="im-server"}[1m])
)
```
```promql
sum by (pod) (
container_memory_working_set_bytes{namespace="im-server-system",container="im-server"}
)
```
```promql
sum by (pod) (
rate(container_network_receive_bytes_total{namespace="im-server-system",pod=~"im-server-.*"}[1m])
)
```
```promql
sum by (pod) (
rate(container_network_transmit_bytes_total{namespace="im-server-system",pod=~"im-server-.*"}[1m])
)
```
必须采集:
- CPU 使用核数、CPU limit 使用率、CFS throttled seconds/periods。
- memory working set、RSS、cache、limit 使用率、OOM 和 page fault。
- 容器文件系统 read/write bytes、IOPS、IO wait;若根文件系统不承载业务数据,也要观察日志写入压力。
- 网络接收/发送 bytes、packets、errors、drops、TCP retransmit。
- Pod restart、readiness、liveness、Pending/NotReady 时间。
- Go goroutine、heap、stack、GC 次数、GC pause、进程文件描述符。
- `LiveSessions`、`LiveTopics`、`LiveClusterNodes`、Cluster leader 和代理 Topic 数。
- Node CPU steal/iowait、内存压力、磁盘压力、网络错误、conntrack 使用率。
资源报告既要给峰值,也要给排空后是否回落。单个 Pod 明显高于其他 Pod 时,结合连接分布、Topic master 位置和网络流量判断是否发生热点。
建议风险线:
- Pod CPU 持续超过 limit 的 80%,或 throttling ratio 超过 10%。
- memory working set 超过 limit 的 80%,或排空 10 分钟后仍持续增长。
- 节点磁盘 util 持续超过 80%,await 持续超过 20ms。
- 网络 drop/error 或 TCP retransmit 明显增长。
- 任一 Pod restart/OOM/NotReady,直接判本轮失败。
### 11.8 网络时延与压测机资源
两台压测机分别持续测量:
- 到各 Pod 直连入口和负载均衡入口的 DNS、TCP connect、TLS handshake、HTTP `/healthz` RTT。
- WebSocket Ping/Pong RTT p50/p95/p99/max,每 5 秒一次,不能用业务 `note kp` 代替协议 Ping。
- 到 Kafka、Redis、MySQL 服务地址的 TCP connect RTT;只做低频只读探测,不绕过业务链路写数据。
- 两台压测机之间的 RTT、丢包和 NTP offset。
- 压测进程 CPU、RSS、GC、goroutine、FD、socket、send/receive bytes、read-loop queue depth。
压力生成端出现 CPU > 70%、网卡 > 70%、FD 接近上限、read-loop backlog 或 GC 尖刺时,本轮标记 `LOAD_GENERATOR_SATURATED`,不能把客户端处理不过来算成服务端丢消息。
### 11.9 现有 fanout 与 Cluster 查询
每轮开始前记录 counter 快照,结束后记录增量:
```promql
sum by (stage, kind, target_type, status) (
increase(realtime_fanout_events_total[10m])
)
```
```promql
histogram_quantile(
0.99,
sum by (le, kind, target_type) (
rate(realtime_fanout_delivery_duration_seconds_bucket[5m])
)
)
```
Topic 消息使用原生 Cluster 时,`publish/skip_native_cluster` 增加属于正常现象。重点检查 Cluster 连接数、route/proxy 错误、`node for topic not found`、gob 编解码错误、Hub 队列满和 WS 写失败。
### 11.10 日志、快照与关联规则
压测日志统一带 `run_id`、scenario、machine、account_index 和脱敏 Topic hash。服务端日志不新增用户 ID 或 `client_mid` 的高基数 Prometheus label;需要定位时通过压测端保存的 manifest 关联。
每轮保存:
- Prometheus 查询结果或 Grafana dashboard snapshot。
- IM Pod、Kafka、Redis、MySQL 在测试窗口内的错误日志。
- Kafka consumer group 压测前、峰值、结束、排空后的 offset/lag 快照。
- Redis INFO、SLOWLOG 摘要和 exporter 指标快照。
- MySQL GLOBAL STATUS、InnoDB status 摘要、慢 SQL 摘要和表行数核对。
- `kubectl top pod/node`、Pod restart/condition/event 和容器日志摘要。
- 两台压测机 node/process 指标及 agent summary。
日志采集必须过滤 Token、密码、API Key、Kafka/Redis/MySQL 凭证和完整消息正文。
### 11.11 建议仪表盘布局
一张压测 dashboard 分为六行:
1. 业务结果:attempt、ACK、实时接收、历史可见、丢失、重复、乱序、p95/p99。
2. IM Pod:连接数、发布/下行速率、Cluster route、CPU、内存、GC、goroutine。
3. Kafka:producer/consumer rate、lag、batch、commit、retry、dead letter、broker health。
4. Redis:OPS、command latency、memory、clients、network、eviction、replication。
5. MySQL:insert/transaction rate、DB batch latency、pool wait、locks、Buffer Pool、IO。
6. 基础设施:Pod/Node CPU、memory、disk IO、network、压测机资源和网络 RTT。
所有面板至少按 Pod、Kafka Topic/consumer group、Redis instance、MySQL instance 和压测机分组;不得只展示总和。
## 12. 压测程序结构
现有 `loadtest/realtime_fanout_probe.go` 只支持单群和单进程,不能直接用于本方案。新工具使用 Go 实现并提供六个子命令:
```text
im-pressure prepare 注册、好友、群和 manifest
im-pressure verify 只读核验 2000 账号、1000 对关系和 2000 群成员
im-pressure coordinator 协调双机 READY、统一起跑时间、ACK 台账与结果上报
im-pressure agent 在每台压测机运行连接与场景
im-pressure collect 抓取 Prometheus、K8s 和依赖组件快照
im-pressure merge 合并两台机器结果并生成报告
```
推荐文件:
```text
loadtest/im-pressure/config.example.yaml
loadtest/im-pressure/cmd/im-pressure/
loadtest/im-pressure/internal/protocol/
loadtest/im-pressure/internal/prepare/
loadtest/im-pressure/internal/runner/
loadtest/im-pressure/internal/collector/
loadtest/im-pressure/internal/report/
```
协调方式:Machine A 启动 coordinator,等待 A/B 两个 agent 都上报 1000 个 READY,然后发布一个至少晚于当前时间 30 秒的 UTC `start_at`。到达 `start_at` 后,A/B 必须在前后 3 秒内向 coordinator 完成最终起跑确认;只有双方都确认后才开始发送,任一方迟到则两边共同终止并将本轮标记为 `INVALID`。两台机器按相同 `run_id` 和确定性调度执行,任何 agent 失联同样判定为 `INVALID`。
`agent` 自身暴露 `/metrics`,提供连接状态、发布、接收、重复、乱序、断连及 Go/process 指标;ACK 和投递延迟分布写入最终 summary。`collect` 只执行只读查询,并把各数据源的原始快照与查询时间一起保存;连接凭证通过环境变量传入,不写入结果目录。
## 13. 结果文件
每轮保留以下文件:
```text
results/<run_id>/config.redacted.json
results/<run_id>/manifest.sha256
results/<run_id>/agent-a-summary.json
results/<run_id>/agent-b-summary.json
results/<run_id>/publish.jsonl
results/<run_id>/missing.jsonl
results/<run_id>/duplicate.jsonl
results/<run_id>/history-reconcile.json
results/<run_id>/db-reconcile-p2p.json
results/<run_id>/db-reconcile-group.json
results/<run_id>/timeline.json
results/<run_id>/metrics/im-server.prom
results/<run_id>/metrics/load-agent.prom
results/<run_id>/metrics/kafka.json
results/<run_id>/metrics/redis.json
results/<run_id>/metrics/mysql.json
results/<run_id>/metrics/kubernetes.json
results/<run_id>/metrics/network.json
results/<run_id>/logs/im-server-errors.log
results/<run_id>/logs/kafka-errors.log
results/<run_id>/logs/mysql-slow-summary.log
results/<run_id>/grafana-snapshot.json
results/<run_id>/report.md
```
缺失明细至少包含 `run_id`、场景、Topic 脱敏哈希、发送者索引、接收者索引、`client_mid`、ACK `seq`、发送时间、最后等待时间、发送 Pod 和接收 Pod。不得写入 Token、密码、API Key 或完整手机号。
最终 `report.md` 必须分别给出:
- 压测业务量和消息正确性结论。
- 实时投递与历史恢复结论。
- Kafka 生产、消费、lag、排空和死信结论。
- MySQL 落库数量、吞吐、耗时、锁与 IO 结论。
- Redis OPS、耗时、内存、幂等/seq 错误和 fanout 结论。
- 每个 Pod/Node 以及两台压测机的资源峰值和瓶颈判断。
- 测试是否有效、是否通过,以及所有未通过项的原始证据路径。
## 14. 推荐执行顺序
1. 在测试窗口前完成环境门槛检查和 2000 账号准备。
2. 完成 1000 对好友关系与 2000 人群,运行 `verify` 直到全部通过。
3. 先用 20 对 P2P、100 人群做冒烟测试,验证统计口径。
4. 执行 P0 热身。
5. P2P 按 200 对、500 对、1000 对逐级执行;每一级通过后才能进入下一级。
6. 执行 S2 群聊,等待恢复基线至少 10 分钟。
7. 执行 S3 混合场景。
8. 合并结果,按实时链路与持久化链路分别给出结论。
9. 全部稳定后再单独进行 Pod 重启、断网或 Kafka/Redis 故障注入,不与本轮无故障基线混跑。
### 14.1 P2P 容量阶梯
三个档位复用同一批账号和好友关系,只裁剪 runtime manifest,不重新注册或重复加好友。每档至少执行两轮;发布验收档建议执行三轮。任何一轮出现消息丢失、重复、乱序、非预期拒绝、数据库不一致或 Pod 重启,都必须停止升级并先定位。
| 档位 | A/B 用户 | WS 总数 | 每用户消息 | Publish 总数 | 目标 |
|---|---:|---:|---:|---:|---|
| P2P-200 | 各 200 | 400 | 10 | 4000 | 验证正确性、指标和基础延迟 |
| P2P-500 | 各 500 | 1000 | 10 | 10000 | 验证 Kafka 三分区、MySQL 和 Cluster 中档容量 |
| P2P-1000 | 各 1000 | 2000 | 10 | 20000 | 单聊发布验收 |
每档的执行流程固定为:生成唯一 `run_id`;开始前采集至少 2 分钟基线;启动 coordinator;A/B agent 全部 READY;统一时间发压;P2P 发送结束后继续监听至少 30 秒;等待 Kafka lag 归零;执行历史回查与 MySQL `client_mid` 精确核验;发送结束后继续采集至少 5 分钟;最后合并报告。
每档升级门槛:Ready、Publish ACK、实时到达、历史查询和 MySQL 精确匹配全部达到期望数;丢失、重复、乱序、断连、403/502/503、Cluster queue overflow、Kafka retry/dead letter、MySQL deadlock、Pod 重启均为 0;Kafka lag 在发送结束后 60 秒内归零;Pod 无持续 CPU throttling。ACK P95 超标时必须按压测机分别报告,并先排除 DNS、TCP、TLS、WebSocket RTT 和时钟偏差。
### 14.2 P2P 通过后的业务场景
1. **GROUP-2000**:2000 条 WS 全部加入同一群,A/B 各 500 个发送者;每人发送 2 条,两轮合计 2000 条。实时投递期望量按每条消息的实际 Ready 接收者计算,并保留 60 秒 drain。
2. **MIXED**:300 对私聊用户双方各发 5 条,共 3000 条私聊 Publish;同时 A/B 各 250 个群发送者每人发 2 条,共 1000 条群 Publish。
3. **稳定性复跑**:GROUP 和 MIXED 至少各两轮,确认无冷启动偶然性。
4. **故障注入**:分别执行单 Pod 滚动重启、Kafka 短暂不可用与恢复、Redis 短暂不可用与恢复;故障轮只验证恢复能力,不与无故障性能基线混合统计。
### 14.3 当前下一步
当前已通过 P2P-200 的消息正确性、Kafka 60 秒排空、MySQL 精确落库、零乱序、零审计死锁和零 CPU throttling。下一步执行 **P2P-500**:A/B 各取账号索引 `1..500`,共 1000 条 WS、10000 条 Publish。若连续两轮满足上述升级门槛,再进入 P2P-1000。
@@ -0,0 +1,599 @@
# IM Admin 内部查询接口缺口清单
> 目标:梳理 HotGo/Admin 迁移到“Admin 只访问 hotgo,IM Server 只访问租户库”后,IM Server 需要补齐的 `/internal/admin-query/*` 只读接口。
> 当前日期:2026-08-27
> 适用项目:`im_manager` Admin 后台、`im_server` IM 服务端。
## 1. 背景
Kafka 写链路已经在 Admin 侧补齐:Admin 写操作落 `im_admin_commands`、`im_admin_outbox`,通过 Kafka 发给 IM Server,IM Server 执行业务后回传 `im.admin.command.result`。
查询链路还没有完全隔离。Admin 当前仍有不少页面通过 `imtenant.DB()` 或 `resolveImTenantDB()` 直连租户库。若要严格满足目标架构,Admin 查询租户业务数据时应调用 IM Server 的内部只读接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/...
```
IM Server 当前已经具备部分内部查询接口,但还不能覆盖 Admin 后台页面。
## 2. 已有接口
以下接口在 IM Server 当前实现中已经存在,可供 Admin 先迁移:
| 接口 | 用途 | 备注 |
|---|---|---|
| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 用户详情 | 需要增强字段,见 P0。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 群详情和治理配置 | 需要增强 topic 基础字段,见 P0。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 群成员列表 | 当前偏 cursor 分页,Admin 表格还需要 total。 |
| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 消息详情 | 当前 Admin 暂未完整迁移消息页面。 |
| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 文件详情 | 还缺文件列表和引用列表。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | Kafka 死信列表 | Admin 已接入。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | Kafka 死信详情 | Admin 已接入。 |
## 3. 统一约定
### 3.1 鉴权
沿用 IM Server 内部 API HMAC 鉴权:
```text
X-Service-Name
X-Timestamp
X-Nonce
X-Signature
```
服务端需要校验:
1. service 是否存在。
2. service 是否包含 `admin_query` capability。
3. timestamp 是否在允许窗口内。
4. nonce 是否防重放。
5. 签名是否匹配。
### 3.2 分页返回
Admin 的 `BasicTable` 更适合 page/per_page/total 格式。建议所有列表接口统一返回:
```json
{
"code": 0,
"data": {
"items": [],
"total": 0,
"page": 1,
"per_page": 20
}
}
```
如果 IM Server 内部更适合 cursor,也建议同时返回:
```json
{
"code": 0,
"data": {
"items": [],
"total": 0,
"page": 1,
"per_page": 20,
"next_cursor": "",
"has_more": false
}
}
```
### 3.3 详情返回
详情接口统一返回:
```json
{
"code": 0,
"data": {}
}
```
### 3.4 时间与 ID
1. 时间统一返回 RFC3339/RFC3339Nano 字符串。
2. 雪花 ID、Tinode 用户 ID、19 位整数 ID 建议以字符串返回,避免前端丢精度。
3. 入参同时兼容字符串 ID 和纯数字 ID 时,IM Server 内部自行解析。
## 4. P0:优先补齐
P0 是 Admin 当前核心页面迁移所需接口,补完后可以先迁移用户、群组、文件等高频页面。
### 4.1 用户列表
```text
GET /internal/admin-query/tenants/{tenant_code}/users
```
用途:IM 用户管理列表。
建议查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `user_id` | string | 用户 ID,支持精确查询。 |
| `nickname` | string | 昵称模糊查询。 |
| `auth_name` | string | 登录标识,匹配 username/phone/email/凭证。 |
| `state` | int | 用户状态。 |
| `created_at_start` | string | 创建时间开始。 |
| `created_at_end` | string | 创建时间结束。 |
| `last_seen_start` | string | 最后在线开始。 |
| `last_seen_end` | string | 最后在线结束。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量,最大建议 200。 |
建议返回字段:
| 字段 | 说明 |
|---|---|
| `user_id` | 字符串用户 ID。 |
| `username` | 用户名。 |
| `phone` | 手机号。 |
| `nickname` | 昵称。 |
| `public_account` | 公开账号。 |
| `state` | 用户状态。 |
| `gender` | 性别。 |
| `region` | 地区。 |
| `auth_names` | 登录标识摘要。 |
| `credentials` | 凭证摘要。 |
| `auth_version` | 鉴权版本。 |
| `password_updated_at` | 密码更新时间。 |
| `last_seen` | 最后在线时间。 |
| `user_agent` | 最后设备 UA。 |
| `created_at` | 创建时间。 |
| `updated_at` | 更新时间。 |
当前 Admin 直连涉及表:`im_users`、`im_user_profiles`、`im_user_security`、`im_credentials`。
### 4.2 用户详情增强
现有接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}
```
建议增强返回字段:
| 字段 | 说明 |
|---|---|
| `profile` | 结构化用户资料。 |
| `public` | Tinode public JSON。 |
| `trusted` | Tinode trusted JSON。 |
| `access` | 访问权限 JSON。 |
| `tags` | 标签。 |
| `security` | `auth_version/password_updated_at` 等安全信息。 |
| `credentials` | 凭证列表,包含 method/value/done。 |
### 4.3 用户设备列表
```text
GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}/devices
```
用途:用户详情中的登录设备列表。
建议查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `device_id` | string | 设备 ID。 |
| `platform` | string | 平台。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量。 |
建议返回字段:
| 字段 | 说明 |
|---|---|
| `device_id` | 设备 ID。 |
| `platform` | 平台。 |
| `user_agent` | UA。 |
| `last_seen` | 最后在线。 |
| `created_at` | 创建时间。 |
| `updated_at` | 更新时间。 |
当前 Admin 直连涉及表:`im_devices`。
### 4.4 群列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups
```
用途:IM 群组管理列表。
建议查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `topic` | string | 群 Topic。 |
| `owner_id` | string | 群主 ID。 |
| `state` | int | Tinode topic 状态。 |
| `group_status` | string | 业务群状态。 |
| `created_at_start` | string | 创建时间开始。 |
| `created_at_end` | string | 创建时间结束。 |
| `touched_at_start` | string | 活跃时间开始。 |
| `touched_at_end` | string | 活跃时间结束。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量。 |
建议返回字段:
| 字段 | 说明 |
|---|---|
| `topic` | 群 Topic。 |
| `state` | Tinode 状态。 |
| `owner_id` | 群主 ID。 |
| `seq_id` | 最新消息序号。 |
| `del_id` | 删除序号。 |
| `sub_count` | Tinode 订阅成员数。 |
| `touched_at` | 活跃时间。 |
| `created_at` | 创建时间。 |
| `updated_at` | 更新时间。 |
| `public` | Tinode public JSON。 |
| `trusted` | Tinode trusted JSON。 |
| `tags` | 标签。 |
| `aux` | 辅助 JSON。 |
| `group_name` | 群名称。 |
| `avatar_file_id` | 群头像文件 ID。 |
| `description` | 群简介。 |
| `group_status` | 业务群状态。 |
| `join_approval` | 入群审批开关。 |
| `mute_all` | 全员禁言。 |
| `member_upload_files` | 成员上传文件。 |
| `member_private_chat` | 成员私聊。 |
| `member_invite` | 成员邀请。 |
| `max_member_count` | 最大成员数。 |
| `max_admin_count` | 最大管理员数。 |
| `business_member_count` | 业务成员数。 |
| `settings_version` | 治理配置版本。 |
| `settings_updated_at` | 治理配置更新时间。 |
当前 Admin 直连涉及表:`im_topics`、`im_group_settings`。
### 4.5 群详情增强
现有接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}
```
当前接口主要返回 `im_group_settings`。建议增强 Tinode topic 基础字段:
| 字段 | 说明 |
|---|---|
| `state` | Topic 状态。 |
| `seq_id` | 最新消息序号。 |
| `del_id` | 删除序号。 |
| `sub_count` | 订阅数量。 |
| `public` | public JSON。 |
| `trusted` | trusted JSON。 |
| `access` | access JSON。 |
| `tags` | tags JSON。 |
| `aux` | aux JSON。 |
| `touched_at` | 活跃时间。 |
### 4.6 群成员列表增强
现有接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members
```
建议增强查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `user_id` | string | 用户 ID 精确过滤。 |
| `role` | string | `owner/admin/member`。 |
| `member_status` | string | 成员状态。 |
| `q` | string | 昵称、公开账号等关键词。 |
| `eligible_role` | string | 候选角色筛选。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量。 |
| `cursor` | string | 兼容 cursor 分页。 |
| `limit` | int | 兼容 cursor 分页。 |
建议增强返回:
| 字段 | 说明 |
|---|---|
| `total` | 总数,Admin 表格需要。 |
| `items[].user_id` | 用户 ID。 |
| `items[].nickname` | 昵称。 |
| `items[].state` | 用户状态。 |
| `items[].mode_want` | 期望权限。 |
| `items[].mode_given` | 授予权限。 |
| `items[].recv_seq_id` | 接收序号。 |
| `items[].read_seq_id` | 已读序号。 |
| `items[].del_id` | 删除序号。 |
| `items[].role` | 业务角色。 |
| `items[].member_status` | 成员状态。 |
| `items[].group_nickname` | 群昵称。 |
| `items[].pinned` | 置顶。 |
| `items[].mute_notifications` | 免打扰。 |
| `items[].join_source` | 加入来源。 |
| `items[].joined_by` | 邀请或处理人。 |
| `items[].joined_at` | 加入时间。 |
| `items[].left_at` | 离开时间。 |
| `items[].removed_by` | 移除人。 |
| `items[].version` | 版本。 |
当前 Admin 直连涉及表:`im_group_members`、`im_subscriptions`、`im_users`、`im_user_profiles`。
### 4.7 入群申请列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/join-requests
```
建议查询参数:`status`、`applicant_user_id`、`page`、`per_page`。
建议返回字段:`request_id`、`topic`、`applicant_user_id`、`inviter_user_id`、`source`、`message`、`status`、`reject_reason`、`handled_by`、`handled_at`、`expires_at`、`version`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_join_requests`。
### 4.8 群邀请列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/invitations
```
建议查询参数:`status`、`target_user_id`、`inviter_user_id`、`page`、`per_page`。
建议返回字段:`invitation_id`、`topic`、`inviter_user_id`、`target_user_id`、`message`、`status`、`result`、`join_request_id`、`expires_at`、`handled_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_invitations`。
### 4.9 群公告列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/announcements
```
建议查询参数:`status`、`page`、`per_page`。
建议返回字段:`announcement_id`、`topic`、`author_user_id`、`content`、`status`、`version`、`published_at`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_announcements`。
### 4.10 群禁言列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/mutes
```
建议查询参数:`status`、`user_id`、`page`、`per_page`。
建议返回字段:`topic`、`user_id`、`muted_by`、`muted_at`、`expires_at`、`reason`、`status`、`revoked_by`、`revoked_at`、`version`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_mutes`。
### 4.11 群管理日志
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/admin-logs
```
建议查询参数:`action`、`operator_id`、`page`、`per_page`。
建议返回字段:`log_id`、`topic`、`operator_id`、`target_user_id`、`action`、`object_type`、`object_id`、`before_data`、`after_data`、`request_id`、`idempotency_key`、`created_at`。
当前 Admin 直连涉及表:`im_group_admin_logs`。
### 4.12 群文件列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/files
```
用途:群治理页面中的群文件列表。不要和已有单文件详情接口混淆。
建议查询参数:`status`、`file_type`、`file_name`、`uploader_id`、`page`、`per_page`。
建议返回字段:`group_file_id`、`topic`、`file_id`、`source_topic`、`source_seq`、`uploader_id`、`file_name`、`mime_type`、`file_type`、`file_size`、`status`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_files`。
### 4.13 文件列表
```text
GET /internal/admin-query/tenants/{tenant_code}/files
```
用途:IM 文件管理列表。
建议查询参数:`file_id`、`user_id`、`status`、`mime_type`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`file_id`、`user_id`、`mime_type`、`file_size`、`file_name`、`location`、`etag`、`status`、`quarantine_reason`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_fileuploads`。
### 4.14 文件引用列表
```text
GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}/links
```
用途:文件详情中展示消息引用。
建议返回字段:`topic`、`seq_id`、`user_id`、`created_at`。
当前 Admin 直连涉及表:`im_filemsglinks`。
## 5. P1:后台审核和运营页面
### 5.1 审计日志
```text
GET /internal/admin-query/tenants/{tenant_code}/audit-logs
GET /internal/admin-query/tenants/{tenant_code}/audit-logs/{id}
```
建议查询参数:`actor_user_id`、`action`、`target_type`、`target_id`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`request_id`、`actor_user_id`、`action`、`target_type`、`target_id`、`ip`、`user_agent`、`detail`、`created_at`。
当前 Admin 直连涉及表:`im_audit_logs`。
### 5.2 实名审核
```text
GET /internal/admin-query/tenants/{tenant_code}/realname-verifications
GET /internal/admin-query/tenants/{tenant_code}/realname-verifications/{id}
```
建议查询参数:`user_id`、`status`、`real_name`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`user_id`、`real_name`、`id_card_no_masked`、`front_file_id`、`back_file_id`、`status`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_realname_verifications`。
### 5.3 举报工单
```text
GET /internal/admin-query/tenants/{tenant_code}/abuse-reports
GET /internal/admin-query/tenants/{tenant_code}/abuse-reports/{id}
GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target
```
建议查询参数:`reporter_user_id`、`target_type`、`target_id`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`report_id`、`reporter_user_id`、`target_type`、`target_id`、`reason_code`、`description`、`evidence_file_ids`、`status`、`handler_id`、`handled_at`、`handle_result`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_abuse_reports`。
### 5.4 意见反馈
```text
GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions
GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions/{id}
```
建议查询参数:`user_id`、`category`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`feedback_id`、`user_id`、`category`、`content`、`contact`、`file_ids`、`status`、`handler_id`、`handled_at`、`reply`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_feedback_submissions`。
### 5.5 业务 Outbox
```text
GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events
GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events
GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events/{id}
GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events/{id}
```
建议查询参数:`event_type`、`status`、`target_type`、`target`、`created_at_start`、`created_at_end`、`page`、`per_page`。
当前 Admin 直连涉及表:`im_business_events_outbox`、`im_group_events_outbox`。
## 6. P2:如果要完全消除租户库直连
这些不是 Kafka 联调第一优先级,但如果最终要求 Admin 完全不碰租户库,也需要 IM Server 提供内部查询或改为 hotgo 摘要表。
### 6.1 组织架构
```text
GET /internal/admin-query/tenants/{tenant_code}/org/departments
GET /internal/admin-query/tenants/{tenant_code}/org/members
```
当前 Admin 直连涉及表:`im_org_departments`、`im_org_members`、`im_users`。
### 6.2 工作台应用
```text
GET /internal/admin-query/tenants/{tenant_code}/workbench/apps
```
当前 Admin 直连涉及工作台相关租户表。
### 6.3 租户任务看板摘要
```text
GET /internal/admin-query/tenants/{tenant_code}/dashboard/summary
```
当前 Admin 会统计多个租户库中的待处理实名、举报、反馈、Outbox 等数量。
### 6.4 租户配置类查询
如果这些配置仍放在租户库,也需要补接口;如果后续迁到 hotgo 控制库,则不需要通过 IM Server 查询。
```text
GET /internal/admin-query/tenants/{tenant_code}/feedback-config
GET /internal/admin-query/tenants/{tenant_code}/mailbox-config
GET /internal/admin-query/tenants/{tenant_code}/abuse-reasons
GET /internal/admin-query/tenants/{tenant_code}/app-releases
```
涉及模块:反馈配置、总经理信箱配置、举报原因、App 发布策略。
## 6.5 IM Server 补齐状态
截至 2026-08-27,IM Server 已补齐本清单中的内部查询入口,统一走 `/internal/admin-query/tenants/{tenant_code}/...`,并沿用 HMAC 鉴权。
已接入范围:
1. P0:用户列表、用户详情增强、用户设备、群列表、群详情增强、群成员 total、入群申请、群邀请、群公告、群禁言、群管理日志、群文件、文件列表、文件详情、文件引用。
2. P1:审计日志、实名审核、举报工单、举报目标快照、意见反馈、业务 Outbox、群 Outbox。
3. P2:组织部门、组织成员、工作台应用、任务看板摘要、反馈配置、总经理信箱配置、举报原因、App 发布策略。
实现说明:
1. IM Server 内部使用白名单只读查询规格,不提供任意 SQL 入口。
2. 列表接口统一返回 `items/total/page/per_page`。
3. 详情接口统一返回单对象。
4. 复杂字段先返回租户库原始 JSON 或轻量关联摘要,Admin 联调时如需展示专用字段再做二次增强。
5. 若租户库未创建某些 P1/P2 表,对应接口会返回内部错误;发版前需要确保租户库 schema 已完整迁移。
## 7. Admin 侧迁移建议
### 7.1 第一批迁移
在 IM Server 补完 P0 后,Admin 优先迁移:
1. 用户列表、用户详情、用户设备。
2. 群列表、群详情、群成员。
3. 群入群申请、邀请、公告、禁言、管理日志、群文件。
4. 文件列表、文件详情、文件引用。
### 7.2 第二批迁移
在 IM Server 补完 P1 后,Admin 再迁移:
1. 审计日志。
2. 实名审核。
3. 举报工单。
4. 意见反馈。
5. 业务 Outbox。
### 7.3 保留 hotgo 查询的范围
以下数据天然属于 hotgo 控制库,Admin 可继续查 hotgo,不需要走 IM Server:
1. 租户表和租户绑定表。
2. Admin 命令表:`im_admin_commands`。
3. Admin Outbox 表:`im_admin_outbox`。
4. Admin Kafka 审计事件表:`im_audit_events`。
5. 菜单、角色、管理员、系统配置、字典等 HotGo 系统表。
## 8. 验收标准
1. Admin 查询租户业务数据时,不再出现新的 `imtenant.DB()` 调用。
2. Admin 查询 IM Server 内部接口时,统一使用 HMAC 签名。
3. 列表接口支持 `page/per_page/total`,兼容后台表格分页。
4. 详情接口返回结构化 JSON,不返回数据库内部敏感字段。
5. 用户 ID、雪花 ID 等大整数以字符串返回。
6. IM Server 为内部查询写审计日志,至少包含 `tenant_code`、`service_name`、`path`、`query`、`trace_id`、`status`、`duration_ms`。
7. 关闭 IM Server 内部查询能力时,Admin 应得到明确错误,不泄露租户库信息。
@@ -0,0 +1,207 @@
# MeetingIM App 版本更新前后端对接文档
## 1. 适用范围
本文档用于 iOS、Android、Web 客户端检查新版本,以及执行推荐更新和强制更新。
- 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。
- iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。
- Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。
- Web 端没有安装包时,`store_url` 和 `package_url` 可以为空,使用 `update_type`、`min_supported_version` 和 `release_notes` 控制强刷、兼容提醒或维护提示。
- 客户端不直接访问数据库,也不自行拼接 OSS 地址。
## 2. 检查版本接口
```http
GET https://api.118im.com/v1/im/app/releases/latest
```
该接口允许未登录调用。
### 2.1 请求 Header
| Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 |
| `X-Client-Platform` | 建议 | `ios` / `android` / `web` | 客户端平台 |
### 2.2 Query 参数
| 参数 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `apikey` | 是 | `AQAAAA...` | 应用 API Key |
| `platform` | 是 | `android` | 仅支持 `ios`、`android`、`web` |
| `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 |
| `channel` | 否 | `stable` | 发布渠道,默认 `stable` |
Android 请求示例:
```bash
curl -H 'X-IM-Schema: 1' \
-H 'X-Client-Platform: android' \
'https://api.118im.com/v1/im/app/releases/latest?apikey=AQAAAA...&platform=android&current_version=1.2.0&channel=stable'
```
## 3. 成功响应
```json
{
"code": 0,
"data": {
"platform": "android",
"channel": "stable",
"current_version": "1.2.0",
"latest_version": "1.3.0",
"min_supported_version": "1.1.0",
"has_update": true,
"force_update": false,
"update_type": "recommended",
"release_notes": "优化消息稳定性并修复已知问题。",
"store_url": "",
"package_url": "https://download.example.com/app-releases/android/stable/1.3.0/meetingim-1.3.0.apk",
"checksum": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"remind_after_seconds": 86400,
"published_at": "2026-09-12T08:00:00Z"
},
"request_id": "req_xxx",
"server_time": "2026-09-12T08:10:00Z"
}
```
### 3.1 响应字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `current_version` | string | 客户端传入的当前版本 |
| `latest_version` | string | 后台发布的最新版本 |
| `min_supported_version` | string | 最低可继续使用的版本 |
| `has_update` | boolean | 是否存在更高版本 |
| `force_update` | boolean | 是否必须完成更新后才能继续使用 |
| `update_type` | string | `none`、`recommended`、`force` |
| `release_notes` | string | 更新说明 |
| `store_url` | string | iOS App Store 地址 |
| `package_url` | string | Android APK 的 OSS/CDN HTTPS 下载地址 |
| `checksum` | string | Android APK 的 SHA-256,小写十六进制 |
| `remind_after_seconds` | number | 推荐更新关闭后再次提醒的间隔 |
| `published_at` | string | 发布时间,UTC RFC 3339 |
## 4. 更新判断规则
客户端以服务端返回结果为准,不自行重新计算强制更新规则。
| 条件 | 客户端行为 |
| --- | --- |
| `has_update=false` | 不弹更新窗口;手动检查时提示已是最新版本 |
| `has_update=true, force_update=false, update_type=recommended` | 弹推荐更新窗口,允许取消或稍后更新 |
| `has_update=true, force_update=true` | 弹强制更新窗口,不允许关闭,不允许进入主业务 |
| `update_type=none` | 不主动弹窗,可展示版本信息 |
当 `current_version < min_supported_version` 时,服务端无条件返回强制更新:
```json
{
"has_update": true,
"force_update": true,
"update_type": "force"
}
```
## 5. Android APK 与 OSS
### 5.1 OSS 对象路径
建议使用独立于聊天附件的发布目录:
```text
app-releases/android/{channel}/{version}/meetingim-{version}.apk
```
示例:
```text
app-releases/android/stable/1.3.0/meetingim-1.3.0.apk
```
### 5.2 下载地址要求
1. `package_url` 必须使用 HTTPS。
2. 地址必须长期稳定,至少覆盖该版本的完整支持周期。
3. 可以使用 OSS 公共读地址,或绑定 OSS/CDN 的固定下载域名。
4. 不得把有效期只有几分钟的 OSS 签名 URL 保存到版本表。
5. 如果 Bucket 必须私有,应提供固定公开下载接口,由接口实时生成签名并返回 302;版本表保存该固定接口地址。
6. APK 响应建议设置 `Content-Type: application/vnd.android.package-archive`。
7. 文件名、对象路径和 URL 必须包含版本号,禁止覆盖旧版本对象。
聊天附件接口 `/v0/file/s/{file_id}` 需要用户鉴权,不作为 Android 安装包的默认下载地址。
### 5.3 发布步骤
1. Android 构建正式签名 APK。
2. 计算 APK 的 SHA-256。
3. 上传到 OSS 的 `app-releases/android/{channel}/{version}/` 目录。
4. 使用浏览器或 `curl -I` 验证 HTTPS 地址可以下载。
5. 在 Admin 的“IM 全局版本更新”中编辑 Android 策略。
6. 填写最新版本、最低支持版本、更新类型、更新说明、`package_url` 和 SHA-256。
7. 保存后由 Kafka `app.release.updated` 事件通知 IM Server 清理缓存。
8. 使用旧版本号请求检查接口,确认下载地址、校验值和强更状态。
计算 SHA-256 示例:
```bash
shasum -a 256 meetingim-1.3.0.apk
```
## 6. Android 客户端处理
1. 调用版本检查接口。
2. `has_update=false` 时结束检查。
3. 根据 `force_update` 展示推荐更新或不可关闭的强更弹窗。
4. 使用系统下载组件请求 `package_url`,支持进度展示和失败重试。
5. 下载完成后计算本地文件 SHA-256,并与 `checksum` 比较。
6. 校验 APK 包名和签名证书必须与已安装应用一致。
7. 通过 Android `FileProvider` 发起安装,不使用 `file://` URI。
8. 未授予“安装未知应用”权限时,引导用户进入系统授权页。
9. 强更下载或安装失败时保留重试、网络设置和退出应用入口,不能绕过进入主业务。
客户端不得在 SHA-256 或应用签名校验失败后继续安装。
## 7. iOS 客户端处理
1. iOS 只读取 `store_url`,忽略 `package_url` 和 `checksum`。
2. `store_url` 必须是 `https://apps.apple.com/...` 地址。
3. 推荐更新允许取消;强制更新不得进入主业务。
4. 点击更新后使用系统能力打开 App Store。
## 8. 错误响应
缺少或错误的 `current_version`:
```json
{
"code": 30000,
"reason": "MALFORMED_REQUEST",
"message": "malformed request",
"details": {
"field": "current_version"
}
}
```
| HTTP 状态 | 原因 |
| --- | --- |
| 400 | 缺少 `X-IM-Schema: 1` |
| 400 | 缺少或错误的 `current_version` |
| 400 | `platform` 不是 `ios`、`android` 或 `web` |
| 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 |
## 9. 当前测试环境
```text
Base URL: https://api.118im.com
版本接口: /v1/im/app/releases/latest
API Key: AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy
协议版本: X-IM-Schema: 1
默认渠道: stable
```
当前测试库已配置 iOS、Android、Web `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。
@@ -0,0 +1,747 @@
# IM HotGo 注册短信开关与邀请码管理改造交接文档
> 文档状态:已按数据库直连方案实现,作为现状与后续 Kafka 演进说明
> 对应实施顺序:注册改造计划第 4 项(HotGo 后台)
> HotGo 项目:`/Users/youshunlai/go_workspace/im_manager`
> IM 服务项目:`/Users/youshunlai/go_workspace/im_server`
> 编写日期:2026-08-18
## 1. 文档用途
本文供新的开发会话直接接手 HotGo 项目使用。阅读本文后,应能明确:
1. 为什么要在 HotGo 中增加注册短信开关和邀请码管理。
2. 哪些数据属于 HotGo 控制库,哪些数据属于 IM 租户业务库。
3. HotGo 后端、前端、菜单权限、统一租户身份、审计和配置需要修改什么。
4. HotGo 与 IM 如何通过租户业务库协作,以及后续如何迁移到 Kafka。
5. 如何验证实现没有破坏租户隔离,也没有泄露邀请码明文。
本文指导 HotGo 项目改造。HotGo 负责邀请码生成和管理;IM 仍独占客户端注册、短信验证码和邀请码使用记录事务。
## 2. 开发前必须阅读
在修改 `/Users/youshunlai/go_workspace/im_manager` 前,先阅读以下文档:
1. `/Users/youshunlai/go_workspace/im_server/docs/IM-注册、邀请码与认证数据模型改造需求文档.md`
2. `/Users/youshunlai/go_workspace/im_server/docs/IM-前后端对接文档.md` 中 1.4、1.5 注册章节
3. `/Users/youshunlai/go_workspace/im_server/docs/IM-后台需求文档.md` 中租户权限、用户管理和审计章节
4. `/Users/youshunlai/go_workspace/im_server/docs/IM-独立库多租户改造方案.md`
规则冲突时按以下优先级处理:
1. 本文针对 HotGo 的落地边界和接口约定。
2. 《IM 注册、邀请码与认证数据模型改造需求文档》。
3. 《IM 独立库多租户改造方案》。
4. 其他较早的需求和设计文档。
`IM-服务端多租户改造方案.md` 已废弃,不得作为实现依据。当前多租户方案是“一套 IM 服务、一个 HotGo 控制库、每租户一个独立 IM 业务库”。
## 3. 需求前因后果
### 3.1 注册页面最终规则
客户端注册页面同时展示用户名、手机号、短信验证码、密码和邀请码,但用户名与手机号是二选一关系:
1. 手机号非空时,以手机号方式为准,忽略用户名输入。
2. 手机号方式注册后,`im_users.username` 和 `im_users.phone` 都保存规范化后的 `+86` 手机号。
3. 手机号为空时使用普通用户名,用户名必须匹配 `^[a-z0-9]{1,10}$`,此时 `phone=NULL`。
4. 普通用户名方式不能自助找回密码,只能由企业管理员重置。
5. 邀请码固定为 5 位 ASCII 大小写英文字母,区分大小写、租户内唯一,有效期内可供多人使用。
### 3.2 为什么需要短信开关
短信服务在不同部署环境中可能暂未启用,因此需要平台级开关:
| 场景 | 短信开关开启 | 短信开关关闭 |
| --- | --- | --- |
| 点击获取验证码 | IM 服务真实调用短信服务商 | IM 服务返回正常挑战和倒计时,但不调用服务商 |
| 手机号注册 | 必须验证短信;邀请码不能绕过 | 必须提供有效邀请码,可无验证码注册 |
| 用户名注册 | 不发送短信,必须提供有效邀请码 | 不发送短信,必须提供有效邀请码 |
| 客户端是否知道开关 | 不直接暴露 | 不直接暴露 |
HotGo 只负责管理开关值。是否调用短信服务商、是否允许注册,全部由 IM 服务端判定。
### 3.3 为什么当前改为 HotGo 直接访问租户库
邀请码属于 IM 租户业务数据:
1. 每个租户有独立业务库。
2. 邀请码批次和主表位于 `hotgo` 控制库,使用明细位于对应租户业务库。
3. HotGo 邀请码管理直接访问控制库;密码重置等租户业务功能仍通过 `im_tenant.id` 和服务端保存的 DSN 选择租户库。
4. 当前部署中 HotGo 到 IM 的内部 HTTP 地址不可达,继续维护 HMAC 管理 API 会增加一套没有必要的调用链。
因此当前采用“控制库主数据 + 租户库使用事实”的混合边界:HotGo 负责邀请码生成、查询、导出和撤销;IM 从控制库校验邀请码,并在租户库记录使用关系和 outbox 事件。两端各自只写自己负责的数据。
密码重置等需要 IM 立即感知的操作写入 `im_business_events_outbox`。当前 IM 轮询消费,后续改为 Kafka 时保留事件契约,只替换传输层。
## 4. 系统边界
```mermaid
flowchart LR
A["HotGo 管理后台浏览器"] -->|HotGo 登录 Token| B["HotGo 后端"]
B -->|读写全局开关| C[("hotgo.hg_sys_config")]
B -->|读取管理员 tenant_id| D[("hotgo.im_tenant")]
B -->|管理邀请码| G[("hotgo.im_registration_invite_batches")]
B -->|管理邀请码| H[("hotgo.im_registration_invite_codes")]
B -->|其他租户业务按服务端 DSN| F[("租户 IM 业务库")]
F --> I["im_registration_invite_uses"]
J["IM 注册与发码接口"] -->|短时缓存读取| C
J -->|按 tenant_id 校验| H
J -->|记录使用关系与事件| F
B -->|写 security.password_reset| K["im_business_events_outbox"]
F --> K
K -->|当前轮询,后续 Kafka| J
```
### 4.1 HotGo 负责
1. 在 `hg_sys_config` 保存全局注册短信开关。
2. 只允许平台超级管理员修改开关。
3. 为邀请码提供管理页面、表单、权限和审计入口。
4. 从当前登录管理员身份解析可信的 IM `tenant_id`。
5. 通过 `imtenant.DB()` 直连所选租户业务库,不接受浏览器传入 DSN。
6. 对邀请码明文查询、查看和导出执行独立权限校验。
### 4.2 IM 服务负责
1. 读取并缓存短信开关。
2. 实际发送或抑制短信。
3. 注册时大小写敏感地校验邀请码,并记录邀请码与新用户的使用关系。
4. 邀请码使用后不改变 active 状态。
5. 消费 `security.password_reset` 事件并清退在线会话。
6. 登录时以 `auth_version` 拒绝密码重置前签发的旧 Token。
7. 消费 `registration.invite_used`,将租户库使用统计幂等投影到控制库。
### 4.3 明确禁止
1. 浏览器直接连接租户数据库。
2. 浏览器通过 Header、Query 或 Body 自报可信 `tenant_id`。
3. HotGo 后端根据浏览器传入的 `db_name` 或 `db_dsn` 连接数据库。
4. HotGo 修改 `im_registration_invite_uses`;该表只允许 IM 注册事务写入。
5. HotGo 绕过 outbox 直接修改在线会话状态。
6. 在普通访问日志、错误日志、埋点或 URL 中记录完整邀请码。
## 5. 当前 HotGo 项目现状
目标仓库 `/Users/youshunlai/go_workspace/im_manager` 基于 GoFrame 2、Vue 3 和 Naive UI。
已确认的现状:
1. 通用配置后端位于 `server/api/admin/config`、`server/internal/controller/admin/sys/config.go`、`server/internal/logic/sys/config.go`。
2. 通用配置前端位于 `web/src/views/system/config` 和 `web/src/api/sys/config.ts`。
3. `hg_sys_config` 支持 `group/key/type/value/default_value`,前端不能通过通用接口新增不存在的配置项。
4. `sys_switch` 字典实际定义为“开启=`1`、关闭=`2`”。
5. HotGo 管理接口权限按“路由路径 + HTTP Method”使用 Casbin 校验,前端按钮权限也使用路由路径,例如 `hasPermission(['/notice/edit'])`。
6. `hotgo.im_tenant` 是全系统唯一租户注册表,由 HotGo 维护,不存在另一套需要映射的“IM 租户”。
7. 后台管理员通过 `hg_admin_member.tenant_id` 直接引用 `im_tenant.id`,登录身份必须携带该字段;不再使用独立租户绑定表。
8. `hg_sys_log` 会保存 GET Query 和 POST Body;现有逻辑只自动掩码名称包含 `password` 的 POST 字段,邀请码查询参数会泄漏,必须补充脱敏。
## 6. 建议开发顺序
### 阶段 A:先完成 HotGo 基础边界
1. 增加全局短信开关配置种子和专用读写接口。
2. 增加 `hg_admin_member.tenant_id` 外键与登录身份解析,统一使用 `im_tenant.id`。
3. 增加租户库直连、Snowflake ID 和数据库事件写入能力。
4. 增加邀请码相关菜单、路由权限和后台代理接口。
5. 增加邀请码管理页面。
6. 增加敏感字段日志脱敏和操作审计。
### 阶段 B:与 IM 服务联调
1. 核对 IM 租户库迁移已执行且表结构一致。
2. 核对服务凭证、租户上下文和操作人上下文可被 IM 验证。
3. 联调单个生成、批量生成、分页查询、撤销、详情和导出。
4. 联调短信开关在 IM 服务缓存时间内生效。
IM 管理接口未完成时,可以通过接口 Client 抽象和测试 Stub 开发页面,但不得用 HotGo 直写租户数据库作为临时方案。
## 7. 全局注册短信开关
### 7.1 数据定义
在 HotGo 控制库 `hg_sys_config` 中增加且只保留一条配置:
```text
group = im
name = 注册短信发送
type = bool
key = register_sms_enabled
value = 1
default_value = 1
status = 1
is_default = 1
```
建议的 MySQL 幂等种子:
```sql
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT
'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10,
'控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM hg_sys_config
WHERE `group` = 'im' AND `key` = 'register_sms_enabled'
);
```
同时为 `(group, key)` 增加唯一约束。加索引前先检查并清理重复配置,不能让 IM 服务随机读到多条值。
```sql
ALTER TABLE hg_sys_config
ADD UNIQUE KEY uk_hg_sys_config_group_key (`group`, `key`);
```
新装数据库种子、实际部署迁移及项目保留的 MySQL/PostgreSQL/SQLite 初始化脚本应按项目发布策略同步,不能只改一份示例 SQL。
### 7.2 `sys_switch` 映射陷阱
`sys_switch` 字典值与真实配置值不相同:
| UI 字典值 | UI 含义 | `hg_sys_config.value` |
| --- | --- | --- |
| `1` | 开启 | `1` |
| `2` | 关闭 | `0` |
实现要求:
1. 页面加载时将后端 bool 值映射为字典值:`true -> 1`,`false -> 2`。
2. 页面提交时反向映射:`1 -> true`,`2 -> false`。
3. 不得把字典值 `2` 原样写入 bool 配置;GoFrame 的 bool 转换可能把非零值当成 true。
4. 数据库只允许保存可解释为 `1/0` 的值。
### 7.3 后端接口
不要直接让页面复用通用 `/config/update`。新增专用接口,以便强制执行平台管理员校验:
| 方法 | HotGo 路径 | 权限 |
| --- | --- | --- |
| GET | `/imRegistration/config/get` | 平台超级管理员 |
| POST | `/imRegistration/config/update` | 平台超级管理员 |
读取响应:
```json
{
"registerSmsEnabled": true,
"updatedAt": "2026-08-18T16:00:00+08:00"
}
```
更新请求:
```json
{
"registerSmsEnabled": false
}
```
后端必须在业务逻辑层再次调用 `service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))`,不能只依赖菜单是否可见。更新时只允许修改固定的 `group=im/key=register_sms_enabled`,不接受前端传 group 或 key。
### 7.4 页面位置
建议在 `web/src/views/system/config/system.vue` 增加“IM 注册”设置项,并新增:
```text
web/src/views/system/config/ImRegistrationSetting.vue
```
页面只展示一个“注册短信发送”开关和保存按钮。选项通过 `dict.loadOptions(['sys_switch'])` 加载,并按 7.2 做显式映射。
### 7.5 IM 服务读取约定
HotGo 不需要额外暴露公开配置接口。IM 服务直接从控制库读取:
```sql
SELECT value
FROM hg_sys_config
WHERE `group` = 'im'
AND `key` = 'register_sms_enabled'
AND status = 1
LIMIT 1;
```
约定:
1. 种子默认值为开启。
2. IM 服务缓存建议为 30 秒,缓存时长可配置。
3. 临时读取失败时优先使用最近一次成功值。
4. 进程启动后无缓存且配置缺失、停用或非法时,按“开启”处理,防止邀请码绕过短信验证,并记录告警。
5. HotGo 更新后不承诺立即生效,验收标准为 30 秒内生效。
## 8. 统一租户身份
### 8.1 唯一租户主键
`hotgo.im_tenant.id` 是 HotGo 和 IM 服务共同使用的唯一 `tenant_id`。HotGo 负责维护租户注册信息和业务库路由,IM 服务根据该 ID 选择租户业务库。不得再创建 `im_admin_tenant_binding` 或其他租户映射表。
### 8.2 管理员租户归属
后台管理员记录直接引用统一租户:
```sql
ALTER TABLE hg_admin_member
ADD COLUMN tenant_id BIGINT NULL COMMENT '统一租户ID,引用 im_tenant.id',
ADD INDEX idx_hg_admin_member_tenant (tenant_id),
ADD CONSTRAINT fk_hg_admin_member_im_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id);
```
平台账号的 `tenant_id` 可以为空。租户管理员及其创建的下级后台账号必须继承同一个 `im_tenant.id`。只有平台超级管理员可以为后台账号选择或修改租户,租户域管理员提交的 `tenantId` 必须被后端忽略并以当前登录身份为准。
### 8.3 解析规则
租户管理员请求邀请码接口时:
1. 从 HotGo 登录上下文取得 `member_id`、`tenant_id` 和部门类型。
2. 仅允许合法的租户级管理员角色进入;普通用户和商户角色不得管理邀请码。
3. `tenant_id` 必须大于 0,并且对应的 `im_tenant` 必须存在且为 active。
4. 将该 `tenant_id` 写入 IM 服务调用上下文。
5. 忽略并拒绝浏览器提交的 `tenantId`。
平台超级管理员可以跨租户查看邀请码;列表和导出未选择租户时查询全部。创建、批次查看、撤销及其他写操作必须先选择一个 `im_tenant`。此选择只对 `VerifySuperId` 通过的管理员开放,HotGo 后端仍要检查目标租户存在且 active。
后台用户管理应提供仅平台超级管理员可见的租户选择控件。租户管理员未关联 `im_tenant.id` 时返回明确错误“当前管理员未关联租户”,不能回退到默认租户。
## 9. 租户库直连与事件通道
### 9.1 数据库选择
HotGo 从可信登录上下文取得 `im_tenant.id`。邀请码批次和邀请码主表直接通过 `imtenant.RegistryDB()` 访问 HotGo 控制库;只有密码重置等租户业务操作才通过 `imtenant.DB()` 打开租户业务库。浏览器传入的 `tenantId` 只允许平台超级管理员选择,租户管理员始终使用登录关系中的租户 ID;任何请求都不能提交或覆盖 DSN。
若配置了 `database.imRegistry`,租户注册表从该连接读取;未配置时回退到 HotGo 默认库。业务操作始终使用按租户动态建立的连接组。
### 9.2 Snowflake 配置
HotGo 直接新增 IM 表记录时使用与 IM 相同位布局的 Snowflake ID:
```yaml
imDirectDb:
snowflakeWorkerId: 1023
```
也可由 `IM_DIRECT_DB_SNOWFLAKE_WORKER_ID` 覆盖。每个 HotGo 写库实例必须使用唯一 worker ID,并且不能与 IM 节点重叠。当前单实例默认预留 `1023`,多实例部署前必须分配 worker ID。
### 9.3 写入职责
HotGo 可以写:
1. HotGo 控制库中的 `im_registration_invite_batches`、`im_registration_invite_codes`。
2. 管理员重置密码时的 `im_auth.secret` 和 `im_user_security`。
3. 管理操作产生的 `im_business_events_outbox`。
IM 独占写入:
1. 客户端注册事务及用户创建。
2. 租户业务库中的 `im_registration_invite_uses` 以及同事务的 `registration.invite_used` outbox 事件。
3. 客户端登录、改密、短信找回等认证事务。
### 9.4 Kafka 演进
管理员重置密码在数据库事务内写入:
```json
{
"event_type": "security.password_reset",
"payload": {
"user_id": 198000000000000001,
"auth_version": 2,
"operator_id": 10001,
"request_id": "..."
}
}
```
当前 IM 在注册写入 outbox 后立即触发一次消费,并每秒按在线会话涉及的租户轮询 pending 事件。`security.password_reset` 消费后清退该用户会话;`registration.invite_used` 消费时从租户使用明细重新聚合 `COUNT/MAX(used_at)`,覆盖 HotGo 控制库的 `usage_count/last_used_at`,成功后标记 delivered。离线用户不依赖密码重置轮询,后续登录时由 `auth_version` 直接拒绝旧 Token。
邀请码使用事件契约:
```json
{
"event_type": "registration.invite_used",
"payload": {
"tenant_id": 1,
"invite_code_id": 198000000000000001,
"user_id": 198000000000000002,
"request_id": "acc-request-id",
"used_at": "2026-08-19T10:00:00.000Z"
}
}
```
迁移 Kafka 时,生产者继续在业务事务内写 outbox,由独立投递器发布同一事件;消费者保持事件类型与 payload 不变。Kafka 投递确认后再标记 outbox delivered,避免数据库提交成功但消息丢失。
## 10. 邀请码接口契约
### 10.1 数据表契约
HotGo 在控制库直接维护带 `tenant_id` 的 `im_registration_invite_batches` 和 `im_registration_invite_codes`。邀请码状态仅为 `active/expired/revoked`,使用后仍为 active;`im_registration_invite_uses` 只由 IM 注册事务写入租户库。控制库中的 `usage_count/last_used_at` 由 outbox 消费器幂等投影,HotGo 列表不跨租户库做联表聚合。
平台超级管理员在列表和导出接口中可不传 `tenantId` 以查询全部租户;创建、批次查看、撤销等写操作必须明确选择一个 active 租户。租户管理员只能查询和操作登录关系绑定的 `im_tenant.id`。
邀请码明文不得放入 URL。查询完整邀请码时必须使用 HotGo POST Body,普通请求日志需对 `inviteCode` 脱敏。
### 10.2 HotGo 对浏览器接口
沿用 HotGo 现有命名风格,建议增加:
| 方法 | HotGo 路径 | 说明 |
| --- | --- | --- |
| POST | `/imInvite/list` | 分页查询,过滤条件放在 JSON Body |
| POST | `/imInvite/create` | 单个生成 |
| POST | `/imInvite/batchCreate` | 批量生成 |
| GET | `/imInvite/batchView` | 批次详情 |
| POST | `/imInvite/revoke` | 单个撤销 |
| POST | `/imInvite/batchRevoke` | 批量撤销 |
| POST | `/imInvite/export` | 明文导出,过滤条件放在 JSON Body |
HotGo controller 只做 DTO 转换、调用 service 和包装响应。租户解析、平台管理员判断、权限复核、审计和租户库访问放在 logic/service 层。
### 10.3 单个生成
HotGo 请求:
```json
{
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"idempotencyKey": "7be3de87-994f-46c6-86bd-844ffb54f83f"
}
```
规则:
1. `expiresAt` 必填,必须晚于服务端当前时间。
2. `remark` 最长 255 字符。
3. 推荐单个生成也传幂等键,避免提交成功但响应超时时重复创建。
4. 邀请码由 HotGo 使用 `crypto/rand` 生成并依靠租户库唯一索引兜底,HotGo 不接受用户自定义 code。
响应数据:
```json
{
"id": 198000000000000001,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"createdBy": 10001,
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.4 批量生成
HotGo 请求:
```json
{
"quantity": 100,
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "2026年8月新员工入职",
"idempotencyKey": "4dc7f13e-448c-4b49-b056-57a2fde18f95"
}
```
规则:
1. `quantity` 为 1 到 1000,由 HotGo 校验。
2. 同一个提交动作必须复用同一个幂等键,直到得到明确成功响应。
3. 前端可在打开弹窗时生成 UUID,失败重试时复用;成功或关闭弹窗后废弃。
4. HotGo 在批次表保存幂等键;相同参数重试返回原批次,不重复生成。
响应数据:
```json
{
"batchId": 198000000000000100,
"quantity": 100,
"activeCount": 100,
"usageCount": 0,
"revokedCount": 0,
"expiredCount": 0,
"expiresAt": "2026-09-01T00:00:00+08:00",
"createdAt": "2026-08-18T16:15:00+08:00"
}
```
### 10.5 分页查询
列表请求使用 POST JSON Body,支持字段:
```text
page, perPage, inviteCode, status, batchId, createdBy,
createdAtStart, createdAtEnd, expiresAtStart, expiresAtEnd
```
要求:
1. `inviteCode` 仅支持完整 5 位精确查询,不做模糊查询,不转换大小写。
2. `status` 为 `active/expired/revoked`,邀请码使用后仍保持 `active`。
3. HotGo 查询前将 `expires_at <= now` 的 active 记录更新为 expired,不依赖定时任务。
4. 默认按 `created_at DESC, id DESC` 排序。
5. `perPage` 设置合理上限,建议不超过 100。
列表字段至少包括:
```json
{
"id": 198000000000000001,
"batchId": 198000000000000100,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"usageCount": 12,
"lastUsedAt": "2026-08-20T09:30:00+08:00",
"createdBy": 10001,
"remark": "新员工入职",
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.6 撤销
1. 只有 `active` 且未过期的邀请码可撤销;已有使用记录不影响撤销。
2. 已过期、已撤销的邀请码不能恢复为 active。
3. 批量撤销只撤销批次内仍可撤销的码,并返回各状态统计。
4. 页面操作前显示确认框,确认内容不必重复显示完整邀请码。
5. 最终状态由租户库条件更新结果决定,不以前端状态为准。
### 10.7 导出
1. 导出必须具备独立 `/imInvite/export` 权限。
2. 导出过滤条件与列表一致。
3. HotGo 后端分页读取租户库并流式写出,不把整份文件加载到内存。
4. 建议输出带 UTF-8 BOM 的 CSV,字段至少包含邀请码、状态、批次、到期时间、累计使用次数、最后使用时间、创建人、创建时间和备注。
5. 文件名建议为 `registration-invite-codes-YYYYMMDD-HHmmss.csv`。
6. 每次导出必须写审计,但审计详情不得包含邀请码明文列表。
## 11. 页面和交互
建议页面路径:
```text
web/src/views/im/registrationInvite/index.vue
web/src/views/im/registrationInvite/model.ts
web/src/views/im/registrationInvite/CreateModal.vue
web/src/views/im/registrationInvite/BatchCreateModal.vue
web/src/views/im/registrationInvite/BatchViewModal.vue
web/src/api/im/registrationInvite.ts
```
页面放在“IM 管理 / 注册邀请码”菜单下。使用现有 `BasicForm`、`BasicTable`、`TableAction`、`usePermission` 和 Naive UI 组件。
页面必须具备:
1. 完整邀请码、状态、批次 ID、创建人、创建时间和到期时间筛选。
2. 单个生成按钮。
3. 批量生成按钮。
4. 导出按钮。
5. 列表展示完整邀请码、状态、到期时间、批次、使用情况、创建信息和备注。
6. active 行提供撤销操作,其他状态不显示撤销操作。
7. 批次详情展示总数、active、used、expired、revoked 统计。
8. 所有日期时间明确按本地时区展示,提交时发送带时区的 RFC 3339 时间。
9. 加载、空数据、接口错误、重复提交和无权限状态处理完整。
邀请码区分大小写,任何输入组件、复制操作和查询参数都不得自动转为大写或小写。
## 12. 菜单与权限
HotGo 的权限标识使用接口路径。建议创建以下按钮权限:
```text
/imRegistration/config/get
/imRegistration/config/update
/imInvite/list
/imInvite/create
/imInvite/batchCreate
/imInvite/batchView
/imInvite/revoke
/imInvite/batchRevoke
/imInvite/export
```
权限分配:
| 能力 | 平台超级管理员 | 租户管理员 | 其他角色 |
| --- | --- | --- | --- |
| 查看/修改短信开关 | 允许 | 禁止 | 禁止 |
| 选择任意 IM 租户管理邀请码 | 允许 | 禁止 | 禁止 |
| 管理当前绑定租户邀请码 | 允许 | 按权限允许 | 禁止 |
| 查看邀请码明文 | 允许 | 需独立权限 | 禁止 |
| 导出邀请码明文 | 允许 | 需独立权限 | 禁止 |
服务端 Casbin、业务逻辑层租户范围校验、前端按钮隐藏三层都要实现。前端隐藏按钮不是安全边界。
## 13. 日志脱敏与审计
### 13.1 必须修复现有访问日志泄露
`server/internal/logic/sys/log.go` 当前会保存 Query 和 Body,且只掩码 password。至少增加以下字段名的大小写不敏感脱敏:
```text
inviteCode
invite_code
serviceSecret
service_secret
authorization
```
建议抽成统一的递归敏感字段过滤器,同时处理 Query、JSON Body、Form 和 Header。Header 中已有简化逻辑也必须确认不会保存内部服务凭证。
对 `/imInvite/list` 的明文精确查询,邀请码只能放在 POST Body,`hg_sys_log.post_data` 中只能出现 `******`。`hg_sys_log.get_data`、Nginx URL 和其他访问日志中都不能出现真实邀请码。HTTP Client 的 debug 日志同样不得打印完整 URL、Query、请求体或响应体。
### 13.2 业务审计
仅靠普通访问日志不够。建议在 HotGo 控制库增加独立审计表,或者接入项目已有的等价不可变审计设施。最低字段为:
```sql
CREATE TABLE im_admin_operation_audit (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
tenant_id BIGINT NULL,
operator_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NULL,
result VARCHAR(16) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
detail_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_im_admin_audit_tenant_time (tenant_id, created_at),
KEY idx_im_admin_audit_operator_time (operator_id, created_at),
UNIQUE KEY uk_im_admin_audit_request_action (request_id, action)
);
```
必须审计:
1. 短信开关修改,记录旧值和新值。
2. 单个生成和批量生成,记录 code ID 或 batch ID、数量和到期时间,但不记录 code 明文。
3. 邀请码明文查询和详情查看,记录查询行为,但不记录搜索明文。
4. 单个撤销和批量撤销。
5. 明文导出,记录过滤条件摘要和导出数量,不记录明文内容。
6. 后台管理员租户归属变更。
IM 服务也应写自己的业务审计。HotGo 审计记录后台操作者,IM 审计记录实际租户库操作,两者通过 `request_id` 关联。
## 14. 错误处理
HotGo 应将数据库错误稳定映射为后台可理解的错误,不向浏览器返回 DSN、SQL、堆栈或内部配置。
至少处理:
| 场景 | HotGo 行为 |
| --- | --- |
| 当前管理员未关联租户 | 返回明确业务错误,不使用默认租户 |
| 目标租户停用 | 拒绝操作 |
| 无查看/导出权限 | 403 |
| 邀请码已过期或已撤销 | 显示不可撤销,不本地修改状态 |
| 幂等键重复且请求相同 | 返回原结果 |
| 幂等键重复但参数不同 | 返回幂等冲突 |
| 租户库连接失败 | 返回“租户业务库暂不可用”,保留 Request ID |
| 数据库写入失败 | 事务回滚,返回统一后台错误并记录脱敏日志 |
| 导出数据过大 | 使用服务端限制或异步导出,不耗尽内存 |
## 15. 建议代码落点
遵循现有 HotGo 分层,建议新增或修改:
```text
server/api/admin/imregistration/
server/api/admin/iminvite/
server/internal/controller/admin/admin/im_registration.go
server/internal/controller/admin/admin/im_invite.go
server/internal/model/input/adminin/im_registration.go
server/internal/model/input/adminin/im_invite.go
server/internal/service/im.go
server/internal/logic/admin/im_registration.go
server/internal/logic/admin/im_invite.go
server/internal/library/imtenant/db.go
server/internal/library/imtenant/snowflake.go
server/internal/router/admin.go
server/internal/logic/sys/log.go
web/src/api/im/registrationInvite.ts
web/src/views/im/registrationInvite/
web/src/views/system/config/ImRegistrationSetting.vue
web/src/views/system/config/system.vue
```
如果新增 `im_admin_operation_audit`,按 HotGo 现有 GoFrame DAO 生成流程生成 `dao/do/entity`,不要手工修改标有 “Code generated” 的文件。禁止新增 `im_admin_tenant_binding`。
同时提交:
1. 生产数据库幂等迁移。
2. 新装数据库初始化数据。
3. 菜单和按钮权限种子。
4. 默认角色授权或明确的部署授权步骤。
5. Snowflake worker 配置示例,但不提交真实 DSN。
## 16. 测试清单
### 16.1 短信开关
1. 无配置时 IM 按开启处理并告警。
2. 平台超级管理员可查看和修改。
3. 租户管理员即使手工调用接口也不能修改。
4. UI 选择“关闭”后数据库保存 `0`,不是 `2`。
5. UI 重新加载时 `0` 正确显示为“关闭”。
6. IM 服务在 30 秒内读取到新值。
7. 开关审计包含操作者、旧值、新值和 Request ID。
### 16.2 租户隔离
1. 租户 A 管理员只能看到租户 A 邀请码。
2. 修改 Query、Body 或 Header 中的 tenant ID 不能访问租户 B。
3. 未绑定管理员不能回退到 default 租户。
4. 停用租户不能生成、查询、撤销或导出。
5. 平台超级管理员选择租户后只能操作所选租户,切换时列表立即清空并重新请求。
### 16.3 邀请码
1. 单个生成返回恰好 5 位 `[A-Za-z]`。
2. 批量生成数量正确,最大值限制生效。
3. `AbCdE` 和 `abcde` 可作为不同邀请码精确查询。
4. 重复提交相同幂等键不产生第二个批次。
5. active 可撤销,expired/revoked 不可撤销。
6. 同一 active 邀请码可供多个不同用户注册,累计使用次数正确增长且状态保持 active。
7. 列表、详情和导出权限相互独立。
8. 导出文件数量、过滤条件和明文字段正确。
### 16.4 安全和日志
1. `hg_sys_log.get_data/post_data/header_data` 中找不到真实邀请码和服务 secret。
2. 应用日志中找不到邀请码明文、内部 Token 和 Authorization。
3. 每个敏感操作都有 HotGo 审计和 IM 审计,并可通过 Request ID 对应。
4. 无权限接口返回 403,不能只依赖前端按钮隐藏。
5. 数据库连接和 SQL 错误不泄露内部实现信息。
## 17. 完成标准
满足以下条件才能认为 HotGo 第 4 项完成:
1. 全局注册短信开关可由平台超级管理员配置,关闭值正确保存为 `0`。
2. IM 服务能在约定缓存时间内读取开关,缺失和异常时采用安全默认值。
3. HotGo 管理员身份能可靠映射到 `im_tenant.id`,不存在默认租户回退。
4. 邀请码单个、批量、查询、详情、撤销和导出全部通过所选租户库完成。
5. 平台与租户权限、跨租户隔离和服务端复核全部通过测试。
6. 邀请码明文只出现在授权页面和授权导出中,不进入普通日志。
7. 配置修改和邀请码敏感操作均有可关联的审计记录。
8. 后端 Go 测试、前端类型检查/构建和核心页面联调通过。
## 18. 新会话可直接使用的任务说明
在新的开发会话中可直接发送:
```text
请修改 HotGo 项目 /Users/youshunlai/go_workspace/im_manager。
先完整阅读 /Users/youshunlai/go_workspace/im_server/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md,
再阅读其中列出的上游 IM 文档。按照交接文档的开发顺序完成 HotGo 的注册短信全局开关、
统一租户身份、租户库直连、邀请码管理后台、菜单权限、日志脱敏和审计。
只允许通过服务端 `im_tenant` 注册信息选择租户库,不要信任浏览器自报 tenant_id 或 DSN,
不要修改 `im_registration_invite_uses`,不要把邀请码明文写入普通日志。
开始前先检查 im_manager 当前工作区已有改动,并保留与本任务无关的用户修改。
```
+955
View File
@@ -0,0 +1,955 @@
# IM Kafka 接入方案
> 日期:2026-08-26
> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 目标:明确 Admin 后台、IM 服务、HotGo 控制库、租户业务库之间的 Kafka 接入边界、查询方式、鉴权方式和首批落地范围。
## 0. 与范围识别文档的关系
[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md) 是本方案的前置输入,负责按前后端对接文档逐章识别哪些业务适合 Kafka、哪些仍应走 WebSocket/Redis/MySQL/OSS。本方案在该识别结果基础上,进一步确定服务边界、库访问边界、前后台交互模型、内部查询接口、鉴权方式和首批落地步骤。
两份文档分工如下:
| 文档 | 作用 |
| --- | --- |
| [IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md) | 回答“哪些业务该用 Kafka,哪些不该用”。 |
| 本方案 | 回答“Kafka 怎样接入,Admin 与 IM 怎样交互,服务和库怎样隔离”。 |
实施时以范围识别文档的逐章清单确定业务覆盖面,以本方案确定技术实现方式。若后续新增需求,先补充范围识别,再同步调整本方案的 Topic、Command、内部接口和鉴权规则。
## 1. 核心原则
系统按“服务和库一一对应”设计,不允许不同服务直连对方数据库。
| 服务 | 只能直接访问 | 说明 |
| --- | --- | --- |
| Admin/HotGo 服务 | `hotgo` 控制库 | 管理后台、租户主数据、后台配置、审核、操作单。 |
| IM 服务 | 当前租户业务库 | 用户、好友、群、消息、文件引用、工作台业务数据。 |
| Redis | IM 实时状态与缓存 | 在线状态、短状态、热点消息、缓存分层。 |
| Kafka | Admin 与 IM 的命令、事件、结果桥 | 后台写操作、异步任务、业务事件、审计、缓存失效。 |
| OSS | 文件对象存储 | 上传下载字节流不进入 Kafka。 |
最终边界:
1. Admin 不直连租户库。
2. IM 不直连 `hotgo` 库。
3. Admin 写租户业务操作统一通过 Kafka Command。
4. IM 写后台业务状态不直写 `hotgo`,通过 Kafka Result/Event 返回 Admin。
5. Admin 查询租户业务数据通过 IM 内部只读 API。
6. IM 查询租户配置、字典、路由通过 Admin 内部只读 API,或消费 Kafka 配置事件维护本地副本。
7. 在线实时投递继续走 Redis + WebSocket,Kafka 不替代 WS 主链路。
## 2. 总体架构
```text
Admin/HotGo 服务
-> 写 hotgo 操作单
-> 生产 Kafka Command
-> IM 服务消费 Command
-> 写租户业务库
-> 生产 Kafka Result/Event
-> Admin 消费 Result/Event
-> 更新 hotgo 操作状态
Admin 查询租户业务数据
-> 调 IM 内部只读 API
-> IM 查租户业务库
-> 返回 Admin
IM 查询租户配置/字典/路由
-> 优先读本地缓存/本地副本
-> 缓存缺失时调 Admin 内部只读配置 API
-> Admin 查 hotgo
-> 返回 IM
```
## 3. Kafka 使用范围
| 场景 | 是否使用 Kafka | 说明 |
| --- | --- | --- |
| Admin 后台群操作通知 C 端 | 是 | 后台写 hotgo 操作单,投 Kafka,IM 执行租户库变更并 WS 下发。 |
| Admin 后台用户封禁、解封、重置密码 | 是 | IM 消费后更新租户库、踢在线、通知客户端。 |
| Admin 后台租户配置、字典、功能开关变更 | 是 | Admin 写 hotgo 后投配置事件,IM 刷新缓存或本地副本。 |
| 企业注册、实名、举报、反馈审核结果 | 是 | Admin 审核后投 Kafka,IM 生成通知或更新租户侧状态。 |
| 工作台会议、日报、月报、信箱通知 | 是 | 状态变更、提醒、红点更新走 Kafka。 |
| 私聊/群聊消息异步落库 | 是 | WS 收消息后完成鉴权、幂等和 seq,再投 Kafka 异步持久化。 |
| 文件安全检测、恶意文件检测 | 是 | OSS 上传完成后投检测任务,消费者调用第三方并更新状态。 |
| 离线 Push | 是 | 消息或业务事件落地后投 Push 任务。 |
| 在线消息实时 fanout | 不作为主链路 | 继续 Redis Pub/Sub/Stream + WS。 |
| 文件上传下载字节流 | 否 | 走 OSS/文件服务;Kafka 只处理上传完成后的事件。 |
| 验证码、Action Token、限流、在线状态 | 否 | 继续 Redis。 |
| 普通查询接口 | 否 | 走内部只读 API 或本服务数据库。 |
## 4. 前后台交互模型
### 4.1 后台写租户业务
Admin 后台所有会影响租户业务库或 C 端展示的写操作,统一走 Kafka Command。
```text
Admin
1. 校验后台管理员权限
2. 写 hotgo.admin_command,状态 pending
3. 发送 Kafka command
IM
4. 消费 command
5. 校验租户、对象、版本、权限和幂等
6. 写租户业务库
7. 必要时通过 WS 通知在线 C 端
8. 发送 Kafka result
Admin
9. 消费 result
10. 更新 hotgo.admin_command 状态
```
典型操作:
| 操作 | Command | IM 执行动作 | C 端通知 |
| --- | --- | --- | --- |
| 群资料修改 | `group.profile.update` | 更新群表和 Topic 资料 | `group.profile.updated` |
| 设置/删除管理员 | `group.admin.set` / `group.admin.remove` | 更新成员角色 | `group.role.updated` |
| 禁言/解禁 | `group.member.mute` / `group.member.unmute` | 更新禁言表 | `group.mute.updated` |
| 踢人 | `group.member.remove` | 更新成员状态和订阅 | `group.member.removed` |
| 解散群 | `group.delete` | 解散 Topic、撤销权限 | `group.deleted` |
| 用户封禁 | `user.disable` | 更新用户状态、踢在线 | `user.disabled` |
| 重置密码 | `user.password.reset` | 更新密码和 `auth_version` | 可选安全通知 |
### 4.2 后台查询租户业务
Kafka 不适合做普通查询。Admin 需要实时查看租户业务数据时,调用 IM 提供的内部只读 API。
只读 API 只允许 `GET`,不提供通用 SQL 或万能查询接口。
| 接口 | 用途 |
| --- | --- |
| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 查询租户用户详情。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 查询群详情。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 查询群成员。 |
| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq}` | 查询消息受控快照。 |
| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 查询文件引用和审核状态。 |
| `GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target` | 查询举报目标快照。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | 查询 IM 租户库 Kafka 死信列表。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | 查询单条死信详情和原始 envelope。 |
后台列表、统计、高频页面优先使用 `hotgo` 摘要表。摘要由 IM 通过 Kafka 事件同步给 Admin,Admin 查询自己的库。
推荐摘要表:
| 摘要表 | 来源 |
| --- | --- |
| `admin_im_user_snapshot` | IM 用户资料变更事件。 |
| `admin_im_group_snapshot` | IM 群资料、成员数、状态变更事件。 |
| `admin_im_group_member_stat` | 群成员统计事件。 |
| `admin_im_file_audit_snapshot` | 文件审核和引用状态事件。 |
| `admin_im_workbench_snapshot` | 工作台红点、会议、日报/月报状态事件。 |
### 4.3 IM 查询后台配置
IM 不直连 `hotgo`。IM 需要租户路由、租户状态、字典、第三方配置时,使用两种方式:
1. 常态:Admin 修改配置后发送 Kafka 配置事件,IM 消费后更新进程内缓存或本地副本。
2. 兜底:IM 缓存缺失或启动预热时,调用 Admin 内部只读配置 API。
| 接口 | 用途 |
| --- | --- |
| `GET /internal/im-config/tenants/{tenant_code}` | 查询租户状态、名称、路由标识。 |
| `GET /internal/im-config/tenants/{tenant_code}/limits` | 查询人数、群数、上传大小等限制。 |
| `GET /internal/im-config/tenants/{tenant_code}/features` | 查询功能开关。 |
| `GET /internal/im-config/dictionaries?types=oss,file_security,sms,jpush,agora` | 查询字典配置。 |
| `GET /internal/im-config/app/releases/latest` | 查询 App 版本配置。 |
Admin 内部配置 API 只返回 IM 运行需要的字段,不返回后台管理员账号、菜单权限、审核私有字段。
## 5. Topic 规划
| Topic | 用途 |
| --- | --- |
| `im.admin.command` | Admin 发给 IM 的租户业务写命令。 |
| `im.admin.command.result` | IM 回传命令执行结果。 |
| `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 |
| `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 |
| `im.message.persist` | 私聊消息异步落库。 |
| `im.group.message.persist` | 群聊消息异步落库。 |
| `im.message.push` | 离线 Push 任务。 |
| `im.file.security` | 文件内容安全、恶意文件检测任务。 |
| `im.cache.invalidate` | 跨节点缓存失效。 |
| `im.audit.event` | IM 与 Admin/HotGo 的跨系统审计事件。 |
| `im.dead.letter` | 消费失败死信。 |
首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。
## 6. 消息结构
统一 Kafka Envelope:
```json
{
"event_id": "evt_01K...",
"trace_id": "req_01K...",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im",
"event_type": "group.member.mute",
"biz_type": "group",
"biz_id": "grp...",
"idempotency_key": "idem_01K...",
"version": 3,
"occurred_at": "2026-08-26T10:00:00.000Z",
"actor": {
"type": "admin",
"id": "admin_1",
"display_name": "管理员"
},
"payload": {}
}
```
Command Result:
```json
{
"event_id": "evt_result_01K...",
"trace_id": "req_01K...",
"tenant_code": "default",
"command_id": "cmd_01K...",
"command_event_id": "evt_01K...",
"status": "succeeded",
"reason": "",
"message": "",
"result": {},
"occurred_at": "2026-08-26T10:00:01.000Z"
}
```
失败时 `status=failed`,`reason` 使用稳定错误码,例如 `GROUP_VERSION_CONFLICT`、`FORBIDDEN`、`RESOURCE_NOT_FOUND`、`COMMAND_DUPLICATED`。
## 7. 幂等、事务与重试
### 7.1 生产端
Admin 写操作必须先落 `hotgo.admin_command`,再发送 Kafka。推荐使用 outbox 模式:
1. 同一事务写 `admin_command` 和 `admin_outbox`。
2. outbox poller 发送 Kafka。
3. Kafka 发送成功后标记 outbox 已发送。
4. 发送失败按退避重试。
IM 内部业务事件也按同样方式写租户库 outbox,避免“数据库成功、事件丢失”。
### 7.2 消费端
每个消费者维护消费幂等记录:
| 字段 | 说明 |
| --- | --- |
| `event_id` | Kafka 事件唯一 ID。 |
| `consumer_group` | 消费组。 |
| `status` | `processing/succeeded/failed/dead`。 |
| `retry_count` | 重试次数。 |
| `last_error` | 最近失败原因。 |
| `created_at/updated_at` | 时间。 |
消费前先插入或锁定 `event_id + consumer_group`。已成功的事件直接跳过;处理中超时可重新抢占。业务表必须再用唯一键兜底,例如 `tenant + command_id`、`tenant + client_mid`、`tenant + topic + seq`。
### 7.3 重试与死信
| 类型 | 策略 |
| --- | --- |
| 临时失败 | 指数退避重试。 |
| 版本冲突 | 不自动重试,回传 failed,由 Admin 刷新后重新发命令。 |
| 权限失败 | 不重试。 |
| 对象不存在 | 不重试,除非明确是延迟同步。 |
| 第三方失败 | 可重试,超过次数写死信和失败表。 |
### 7.4 消费失败处理流程
消费者收到消息后按固定流程处理:
```text
收到 Kafka 消息
-> 校验 Envelope 格式、签名字段、租户和事件类型
-> 抢占 event_id + consumer_group 幂等记录
-> 已 succeeded:提交 offset,跳过
-> processing 超时:允许重新抢占
-> 执行业务逻辑
-> 成功:标记 succeeded,提交 offset
-> 可重试失败:retry_count + 1,按退避策略延迟重试
-> 超过最大重试:写入死信,标记 dead,提交 offset
-> 不可重试失败:标记 failed 或 dead,必要时回传 command result,提交 offset
```
重试建议参数:
| 参数 | 建议值 | 说明 |
| --- | --- | --- |
| `max_retry_count` | 5~10 | 第三方接口可取高值,权限/参数错误不重试。 |
| `retry_backoff` | 10s、30s、1m、5m、15m | 指数退避,可加随机抖动。 |
| `processing_timeout` | 5m~30m | 超过后允许其他消费者重新抢占。 |
| `dead_letter_retention` | 30~90 天 | 便于排障和人工补偿。 |
### 7.5 死信队列处理
进入死信表示该事件已超过自动恢复能力,系统不能再无限重试。死信必须形成“可查看、可诊断、可重放、可关闭”的闭环。
死信写入两处:
1. Kafka `im.dead.letter` Topic:保留原始事件,便于流式订阅、告警和后续自动补偿。
2. 数据库失败表:便于 Admin 后台查询、人工处理和审计。
推荐失败表字段:
| 字段 | 说明 |
| --- | --- |
| `dead_id` | 死信记录 ID。 |
| `event_id` | 原 Kafka 事件 ID。 |
| `source_topic` | 原 Topic。 |
| `consumer_group` | 失败消费组。 |
| `tenant_code` | 租户编码。 |
| `event_type` | 事件类型。 |
| `biz_type` / `biz_id` | 业务对象。 |
| `payload` | 原始 Envelope。 |
| `retry_count` | 已重试次数。 |
| `last_reason` | 稳定失败原因。 |
| `last_error` | 最近错误摘要,敏感字段脱敏。 |
| `status` | `dead/pending_replay/replayed/ignored/resolved`。 |
| `replay_count` | 人工重放次数。 |
| `created_at/updated_at` | 时间。 |
| `resolved_by/resolved_at` | 人工处理人和处理时间。 |
| `resolve_note` | 处理备注。 |
死信来源分类:
| 分类 | 示例 | 默认处理 |
| --- | --- | --- |
| 临时资源失败超过上限 | DB 短暂不可用、第三方超时 | 修复环境后允许重放。 |
| 业务状态冲突 | 群版本冲突、对象已被删除 | 不自动重放,Admin 刷新状态后重新发命令。 |
| 权限失败 | 管理员无权限、租户停用 | 不重放,标记 ignored 或 failed result。 |
| 参数错误 | Envelope 缺字段、payload 非法 | 不重放,修复生产方后关闭。 |
| 第三方永久失败 | 文件违规、Push token 无效 | 按业务结果落状态,不重放或仅重放特定步骤。 |
Admin 后台需要提供死信处理页:
| 操作 | 说明 |
| --- | --- |
| 查看详情 | 展示事件、租户、业务对象、错误原因、重试次数和 trace_id。 |
| 重放 | 将原事件或修正后的事件重新投递到原 Topic,生成新的 `event_id`,保留 `parent_event_id`。 |
| 忽略 | 确认该事件无需继续处理,状态改为 `ignored`。 |
| 标记已处理 | 线下补偿完成后改为 `resolved`。 |
| 重新生成命令 | 对版本冲突类命令,不直接重放旧事件,而是基于最新状态创建新 Command。 |
重放规则:
1. 重放必须生成新的 `event_id`,原 `event_id` 保留在 `parent_event_id`。
2. 重放前重新校验租户状态、对象是否存在、操作者权限和业务版本。
3. 参数错误、权限失败、版本冲突默认禁止直接重放。
4. 文件安全检测、Push、审计写入等可重试任务允许重放。
5. 每次重放都写审计,记录处理人、原因、原事件和新事件。
死信告警:
| 指标 | 建议 |
| --- | --- |
| 单 Topic 死信数 | 5 分钟内超过阈值告警。 |
| 同一 `event_type` 连续失败 | 可能是代码或配置问题,立即告警。 |
| 同一租户大量失败 | 检查租户库、配置或权限状态。 |
| 死信积压时间 | 超过 24 小时未处理提醒。 |
对 Admin Command 类事件,进入死信后必须回传 `im.admin.command.result`,状态为 `failed` 或 `dead`,让后台操作单结束等待状态。否则后台会长期显示处理中。
## 8. 内部接口鉴权
内部 API 鉴权非常关键,必须同时做网络层、服务层和业务层控制。
### 8.1 网络层
1. `/internal/*` 只允许内网网关或内网地址访问。
2. 禁止公网直接暴露内部接口。
3. 有条件时启用 mTLS。
### 8.2 服务签名
内部请求统一携带:
| Header | 说明 |
| --- | --- |
| `X-Service-Name` | 调用方服务名,例如 `hotgo-admin` 或 `im-server`。 |
| `X-Timestamp` | Unix 秒或毫秒时间戳。 |
| `X-Nonce` | 随机串,短期内不可重复。 |
| `X-Request-ID` | 请求追踪 ID。 |
| `X-Signature` | HMAC 签名。 |
签名内容:
```text
METHOD + "\n" +
PATH_WITH_QUERY + "\n" +
BODY_SHA256 + "\n" +
X-Timestamp + "\n" +
X-Nonce
```
校验规则:
1. 时间窗建议 5 分钟。
2. `nonce` 写 Redis,TTL 5 分钟,防重放。
3. 每个服务独立 `service_id/service_secret`。
4. 每个服务配置允许访问的内部接口白名单。
### 8.3 业务授权
Admin 调 IM 查询时,签名 payload 或 Header 中必须包含后台操作者信息:
| 字段 | 说明 |
| --- | --- |
| `admin_user_id` | 后台用户 ID。 |
| `admin_role` | 后台角色。 |
| `admin_permissions` | 本次已校验过的权限列表。 |
| `tenant_code` | 被查询租户。 |
| `reason` | 查询原因或工单号。 |
IM 仍需根据 `tenant_code + permission + resource` 做二次校验,并写内部查询审计。
IM 调 Admin 配置接口时,Admin 只校验服务身份和接口白名单,不返回超出 IM 运行需要的字段。
## 9. 文件与工作台
### 9.1 文件
文件上传下载不走 Kafka,OSS 是文件字节流主链路。当前只有消息落库使用 Kafka;文件生命周期改为同步处理:
1. `im.file.security`:仍可用于内容安全、恶意文件检测。
2. 消息附件引用绑定在消息落库 consumer 内同步执行。
3. 群文件登记/删除由对应 HTTP 业务接口同步落库。
### 9.2 工作台
工作台查询不走 Kafka,状态变化和提醒当前也改为同步处理:
1. 会议创建、更新、取消、提醒、响应:业务接口同步生成通知/红点事件。
2. 日报/月报提交、撤回、重开、催办:业务接口同步生成通知/红点事件。
3. 总经理信箱提交、处理结果:业务接口同步处理。
4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。
## 10. 首批落地建议
| 阶段 | 内容 |
| --- | --- |
| 第一步 | 增加 Kafka 配置、生产者、消费者、健康检查、统一 Envelope。 |
| 第二步 | 增加消费幂等表、outbox 表、死信表或死信 Topic。 |
| 第三步 | 接入 `im.admin.command` 和 `im.admin.command.result`,先覆盖后台群治理操作。 |
| 第四步 | 增加 IM 内部只读查询 API,供 Admin 查用户、群、消息、文件快照。 |
| 第五步 | 增加 Admin 内部配置 API,供 IM 查询租户配置、字典、第三方参数。 |
| 第六步 | 接入文件安全检测、离线 Push、工作台事件。 |
| 第七步 | 消息异步落库接入 `im.message.persist`,完善幂等和重试。 |
## 11. 分端任务拆分
### 11.1 Admin/HotGo 侧任务
Admin 侧只访问 `hotgo` 库,负责后台操作入口、配置主数据、命令生产、结果消费和死信处理。
| 模块 | 任务 | 产出 |
| --- | --- | --- |
| Kafka 基础 | 增加 Kafka producer/consumer 配置、健康检查、统一 Envelope 组装。 | 可发送 `im.admin.command`、消费 `im.admin.command.result`。 |
| 操作单 | 新增 `admin_command`、`admin_outbox` 表和状态机。 | `pending/running/succeeded/failed/dead` 操作状态。 |
| Outbox | 后台写操作单与 outbox 同事务落库,poller 发送 Kafka。 | 防止 hotgo 写成功但 Kafka 丢事件。 |
| Command 生产 | 群治理、用户封禁/解封、重置密码、审核结果、工作台配置等后台操作生成 Command。 | `im.admin.command` 事件。 |
| Result 消费 | 消费 IM 回传结果并更新操作单。 | 后台页面可展示最终成功/失败原因。 |
| 配置事件 | 租户配置、字典、功能开关、第三方配置变更后发送 `im.config.event`。 | IM 刷新缓存或本地副本。 |
| 配置查询 API | 提供 `/internal/im-config/*` 只读接口。 | IM 启动预热和缓存缺失兜底查询。 |
| 摘要表 | 建立用户、群、文件、工作台等后台摘要表。 | 后台列表统计优先查 hotgo。 |
| 死信后台 | 提供死信查看、重放、忽略、标记已处理、重新生成命令。 | 运维闭环。 |
| 内部鉴权 | 实现服务签名校验、nonce 防重放、接口白名单、审计。 | 保护 Admin 内部配置 API。 |
| 审计 | 记录后台操作者、租户、资源、Command、Result、死信处理。 | 可追踪后台操作链路。 |
Admin 首批优先级:
1. Kafka 基础配置、Envelope、producer。
2. `admin_command` + `admin_outbox`。
3. 群治理 Command 生产。
4. `im.admin.command.result` 消费。
5. `/internal/im-config/*` 配置查询 API。
6. 死信管理页。
### 11.2 IM Server 侧任务
IM Server 侧只访问租户业务库,负责执行 Admin Command、维护租户业务状态、通过 WS 通知 C 端,并向 Admin 回传结果。
| 模块 | 任务 | 产出 |
| --- | --- | --- |
| Kafka 基础 | 增加 Kafka producer/consumer 配置、健康检查、统一 Envelope 解析。 | 可消费 Command、生产 Result/Event。 |
| 消费幂等 | 新增租户库消费幂等表或统一消费日志。 | `event_id + consumer_group` 幂等。 |
| Command 消费 | 消费 `im.admin.command`。 | 后台命令进入 IM 执行链路。 |
| 群治理执行器 | 执行群资料、管理员、禁言、踢人、解散、群主转让等命令。 | 更新租户库和 Topic 状态。 |
| 用户治理执行器 | 执行封禁、解封、重置密码、踢在线等命令。 | 更新用户状态和 `auth_version`。 |
| Result 生产 | 执行成功/失败后发送 `im.admin.command.result`。 | Admin 操作单闭环。 |
| WS 通知 | Command 成功后生产 C 端业务事件并投递在线客户端。 | `info.what="im|group"` 下发。 |
| 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 |
| 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 |
| 内部只读 API | 提供 `/internal/admin-query/*`。 | Admin 实时查询租户业务数据。 |
| 文件任务 | 上传完成、附件绑定、群文件登记/删除同步处理。 | 文件审核、引用绑定、回收。 |
| 消息异步落库 | 接入 `im.message.persist`、`im.group.message.persist`。 | 私聊/群聊消息削峰落库。 |
| Push 任务 | 生产 `im.message.push`。 | 离线 Push 异步投递。 |
| 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 |
| 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 |
IM Server 首批优先级:
1. Kafka 基础配置、Envelope、consumer/producer。
2. 消费幂等表和死信写入。
3. `im.admin.command` 群治理消费执行。
4. `im.admin.command.result` 回传。
5. WS 业务事件下发。
6. `/internal/admin-query/*` 只读查询 API。
### 11.3 双方联调契约
| 契约 | Admin 负责 | IM Server 负责 |
| --- | --- | --- |
| Envelope 字段 | 生产完整字段,保证 `event_id/trace_id/tenant_code/event_type/idempotency_key`。 | 校验字段完整性和事件类型白名单。 |
| Command 状态 | 创建操作单,展示状态。 | 回传成功、失败、死信结果。 |
| 幂等 | 同一后台操作复用 `command_id/idempotency_key`。 | 同一 Command 重复消费只执行一次。 |
| 错误码 | 展示 IM 返回的稳定 `reason`。 | 返回稳定 `reason`,不让 Admin 解析错误文本。 |
| 查询接口 | 带管理员身份、权限、原因和签名。 | 校验签名、权限和租户资源,写审计。 |
| 配置接口 | 提供最小必要配置字段。 | 不直连 hotgo,使用缓存、事件或内部 API。 |
| 死信 | 提供处理入口和人工操作审计。 | 写死信、回传 Command dead/failed 状态。 |
| Trace | 生成并传递 `trace_id`。 | 日志、Kafka、WS 下发沿用同一 `trace_id`。 |
### 11.4 首批联调用例
| 用例 | 预期 |
| --- | --- |
| Admin 禁言群成员 | Admin 操作单 pending,IM 消费后更新租户库,C 端收到 `group.mute.updated`,Admin 状态 succeeded。 |
| Admin 重复提交同一禁言操作 | IM 只执行一次,Result 返回同一最终状态。 |
| Admin 删除管理员但群版本冲突 | IM 返回 `GROUP_VERSION_CONFLICT`,Admin 操作单 failed,不自动重试。 |
| IM 消费时临时 DB 失败 | 自动退避重试,未超限不进入死信。 |
| IM 消费超过重试上限 | 写 `im.dead.letter`,Admin 操作单进入 dead/failed。 |
| Admin 查询群成员 | Admin 调 `/internal/admin-query/*`,IM 校验签名和权限后返回只读数据。 |
| IM 启动加载租户配置 | IM 优先读缓存,缺失时调 `/internal/im-config/*`,Admin 只返回必要配置。 |
### 11.5 IM Server 已接入的首批 Admin Command
IM Server 首批消费 `im.admin.command`,执行后回传 `im.admin.command.result`。所有命令必须带 `tenant_code`,`payload.actor_user_id` 必须是租户内 IM 用户 ID,用于落群事件和 C 端通知。
| Command `event_type` | 说明 | 必填 payload |
| --- | --- | --- |
| `group.profile.update` | 修改群名称、头像、简介。 | `command_id,actor_user_id,topic,profile,version` |
| `group.governance.update` | 修改进群审批、全员禁言、成员邀请等治理开关。 | `command_id,actor_user_id,topic,governance,version` |
| `group.admin.set` | 设置一个或多个管理员。 | `command_id,actor_user_id,topic,user_ids` |
| `group.admin.remove` | 删除管理员,降为普通成员。 | `command_id,actor_user_id,topic,user_id` |
| `group.member.mute` | 禁言成员。 | `command_id,actor_user_id,topic,user_id,reason,expires_at` |
| `group.member.unmute` | 解除禁言。 | `command_id,actor_user_id,topic,user_id` |
| `group.member.remove` | 移出群成员。 | `command_id,actor_user_id,topic,user_id` |
| `group.owner.transfer` | 转让群主。 | `command_id,actor_user_id,topic,target_user_id,version` |
当前 IM Server 消费 `im.admin.command` 时已经接入 `im_kafka_consume_log`:
1. 按 `event_id + consumer_group` 抢占消费。
2. 已成功事件直接跳过,不重复执行业务。
3. 处理中且未超时的事件跳过,避免并发重复执行。
4. 失败或处理超时的事件允许重新抢占。
5. 不可重试错误或超过重试次数后写入 `im_kafka_dead_letters`,并投递 `im.dead.letter`。
6. Admin Command 进入死信时回传 `im.admin.command.result`,`status=dead`。
### 11.6 IM Server 已接入的配置事件
IM Server 首批消费 `im.config.event`,用于 Admin 修改 HotGo 配置后通知 IM 刷新进程内缓存。当前实现以“缓存失效”为主,收到事件后清除对应缓存,下次业务读取时重新加载。
| Config `event_type` | 说明 | IM 行为 |
| --- | --- | --- |
| `tenant.updated` | 租户基础信息变更。 | 清理租户 code/id 缓存。 |
| `tenant.status.changed` | 租户启停、状态变化。 | 清理租户 code/id 缓存。 |
| `tenant.limits.updated` | 租户人数、群数、上传限制等变化。 | 清理租户 code/id 缓存。 |
| `tenant.features.updated` | 租户功能开关变化。 | 清理租户 code/id 缓存。 |
| `dictionary.updated` | 字典配置变化。 | 清理第三方/字典配置缓存。 |
| `third_party.updated` | OSS、文件安全、短信、极光、声网等第三方配置变化。 | 按 group 清理第三方配置缓存。 |
| `registration.config.updated` | 注册、短信开关变化。 | 清理注册配置缓存。 |
事件示例:
```json
{
"event_id": "evt_01K_config",
"trace_id": "req_01K",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im-server",
"event_type": "third_party.updated",
"biz_type": "config",
"biz_id": "im_third_party",
"occurred_at": "2026-08-26T10:05:00.000Z",
"payload": {
"tenant_code": "default",
"group": "im_third_party",
"keys": ["oss.endpoint", "file_security.enabled"]
}
}
```
禁言命令示例:
```json
{
"event_id": "evt_01K_mute",
"trace_id": "req_01K",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im-server",
"event_type": "group.member.mute",
"biz_type": "group",
"biz_id": "grp...",
"idempotency_key": "cmd_01K_mute",
"occurred_at": "2026-08-26T10:00:00.000Z",
"payload": {
"command_id": "cmd_01K_mute",
"actor_user_id": "usr_admin",
"topic": "grp...",
"user_id": "usr_target",
"reason": "刷屏",
"expires_at": null
}
}
```
### 11.7 IM Server 已接入的内部只读查询 API
为满足“Admin 不直连租户库”的边界,IM Server 提供内部只读查询接口给 HotGo/Admin 调用。接口默认关闭,通过 `internal_api.enabled=true` 开启。
请求统一鉴权头:
| Header | 说明 |
| --- | --- |
| `X-Service-Name` | 调用方服务名,如 `hotgo-admin`。 |
| `X-Timestamp` | Unix 秒级时间戳,默认 300 秒时间窗。 |
| `X-Nonce` | 一次性随机串,用于防重放。 |
| `X-Request-ID` | 调用链路 ID。 |
| `X-Signature` | HMAC-SHA256 签名,支持 hex 或 base64,可带 `sha256=` 前缀。 |
nonce 防重放优先使用 Redis 短状态 `SETNX + TTL`,Redis 未配置时退化为单节点进程内防重放;生产多节点必须配置 Redis。
签名明文:
```text
METHOD + "\n" +
PATH_WITH_QUERY + "\n" +
SHA256_HEX(BODY) + "\n" +
X-Timestamp + "\n" +
X-Nonce
```
当前首批接口:
| 接口 | 说明 |
| --- | --- |
| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 查询租户内用户基础资料。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 查询群资料和治理配置。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 查询群成员分页快照,支持 `limit/cursor/role/eligible_role/q`。 |
| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 查询单条私聊或群聊消息,`seq_id` 为 `BIGINT`。 |
| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 查询文件元信息和存储位置。 |
配置示例:
```json
{
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"services": {
"hotgo-admin": {
"secret": "replace-with-random-secret",
"allow": ["admin_query"]
}
}
}
}
```
Docker 环境变量:
| 环境变量 | 说明 |
| --- | --- |
| `INTERNAL_API_ENABLED` | 是否开启内部接口,默认 `false`。 |
| `INTERNAL_API_TIME_WINDOW_SECONDS` | 签名时间窗。 |
| `INTERNAL_API_HOTGO_SERVICE_NAME` | Admin 服务名,默认 `hotgo-admin`。 |
| `INTERNAL_API_HOTGO_SECRET` | Admin 与 IM 共享签名密钥。 |
### 11.8 IM 访问 Admin 配置查询接口
为满足“IM 服务不直连 hotgo 库”的目标,IM Server 增加 Admin 内部配置查询客户端。该客户端默认关闭,开启后 `store.Tenants`、注册短信开关、第三方配置读取会优先请求 Admin 内部接口,失败时仍可回落旧数据库适配器,便于灰度迁移。
配置示例:
```json
{
"admin_internal_api": {
"enabled": true,
"base_url": "http://hotgo-admin:8080",
"service_name": "im-server",
"secret": "replace-with-random-secret",
"timeout_ms": 3000
}
}
```
接口约定:
| 接口 | 用途 |
| --- | --- |
| `GET /internal/im-config/tenants/by-code/{tenant_code}` | 按企业码获取租户路由信息,需返回 `id/code/name/state/db_name/db_dsn`。 |
| `GET /internal/im-config/tenants/by-id/{tenant_id}` | 按租户 ID 获取租户路由信息。 |
| `GET /internal/im-config/tenants?state=active&limit=1000` | 获取活跃租户列表,用于 Kafka Outbox 扫描。 |
| `GET /internal/im-config/registration/sms` | 获取注册短信开关,返回 `{"enabled": true}`。 |
| `GET /internal/im-config/third-party/{group}?keys=a,b` | 获取第三方配置,返回 `{"values": {"a": "..."}}`。 |
鉴权沿用内部接口 HMAC 方案,请求头为 `X-Service-Name`、`X-Timestamp`、`X-Nonce`、`X-Signature`;签名文本为 `METHOD + "\n" + RequestURI + "\n" + BodySHA256 + "\n" + Timestamp + "\n" + Nonce`。
Docker 环境变量:
| 环境变量 | 说明 |
| --- | --- |
| `ADMIN_INTERNAL_API_ENABLED` | 是否启用 IM 到 Admin 的配置查询客户端,默认 `false`。 |
| `ADMIN_INTERNAL_API_BASE_URL` | Admin 内部接口根地址。 |
| `ADMIN_INTERNAL_API_SERVICE_NAME` | IM 服务名,默认 `im-server`。 |
| `ADMIN_INTERNAL_API_SECRET` | Admin 与 IM 共享签名密钥。 |
| `ADMIN_INTERNAL_API_TIMEOUT_MS` | 请求超时时间。 |
### 11.9 IM Server 已接入的消息异步落库
IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终消息落库解耦。
当前处理方式:
1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。
2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
4. 私聊和群聊使用独立 Topic、独立消费组和独立物理表:私聊批量写入租户分表 `im_%03d_messages`,群聊按租户和月份批量写入 `im_%03d_group_messages_YYYYMM`。
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
6. 消费者最多聚合 `message_batch_size` 条消息,在 `message_batch_wait_ms` 时间窗内形成批次并执行一条多值 `INSERT`。
7. 批量写入失败后逐条降级;每条执行首次写入和 `message_single_retry_count` 次重试,默认共尝试 4 次。
8. 单条仍失败时,完成 `im.dead.letter` 投递和 `im_kafka_dead_letters` 终态写入后才提交该批 offset;提交失败会持续重试,避免跳过失败消息。
消息批量落库参数:
| 配置项 | 默认值 | 说明 |
| --- | ---: | --- |
| `message_batch_size` | `100` | 单批最大消息数,上限 500。 |
| `message_batch_wait_ms` | `10` | 从取得第一条消息开始的批次聚合等待时间。 |
| `message_single_retry_count` | `3` | 批量失败后单条额外重试次数,不含首次执行。 |
### 11.10 IM Server 已接入的缓存失效消费
IM Server 已订阅 `im.cache.invalidate`,用于接收 Admin 或其他节点发出的通用缓存失效事件。消费侧复用 `im_kafka_consume_log` 做幂等,失败按统一退避重试,超过上限进入死信。
事件体示例:
```json
{
"event_id": "cache:tenant:default:1720000000",
"event_type": "cache.tenant.invalidate",
"tenant_code": "default",
"payload": {
"tenant_code": "default",
"tenant_id": 1,
"namespace": "tenant"
}
}
```
支持的失效范围:
| `event_type` / `namespace` | 处理方式 |
| --- | --- |
| `cache.tenant.invalidate` / `tenant` | 清理租户注册表与租户配置进程内缓存。 |
| `cache.registration.invalidate` / `registration` | 清理注册短信开关缓存。 |
| `cache.third_party.invalidate` / `third_party` | 按 `group` 清理第三方配置缓存。 |
| `cache.dictionary.invalidate` / `dictionary` | 按 `group` 清理字典/配置类缓存。 |
| `cache.redis_object.invalidate` / 任意 Redis `namespace` | 按 `keys` 删除用户、群、关系、权限、未读等 Redis 对象缓存。 |
| `cache.group_hot_messages.invalidate` / `group_hot_messages` | 按 `topic` 和可选 `ranges` 清理群热消息缓存。 |
### 11.11 IM Server 已接入的死信处理闭环
IM Server 已将死信从“只写表”补齐为“可查询、可忽略、可解决、可重放”的处理闭环。Admin 不直连租户库,列表和详情通过 IM 内部只读 API 查询;写操作仍通过 `im.admin.command` 投递给 IM。
Admin 查询接口:
| 接口 | 说明 |
| --- | --- |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters?status=dead&limit=50&cursor=` | 死信分页列表,支持 `status/biz_type/biz_id/event_type` 过滤。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | 单条死信详情,包含原始 Kafka envelope。 |
Admin 处理命令:
| `event_type` | 说明 | Payload 关键字段 |
| --- | --- | --- |
| `kafka.dead_letter.replay` | 删除原消费日志并按原 `source_topic` 重投 envelope,成功后标记 `replayed`。 | `dead_id`、`actor_user_id`、`note` |
| `kafka.dead_letter.ignore` | 人工确认无需处理,标记 `ignored`。 | `dead_id`、`actor_user_id`、`note` |
| `kafka.dead_letter.resolve` | 线下修复后标记 `resolved`。 | `dead_id`、`actor_user_id`、`note` |
重放规则:
1. IM 先把 `im_kafka_dead_letters.status` 改为 `pending_replay`,并删除对应 `im_kafka_consume_log`。
2. IM 按死信记录中的 `source_topic` 重新投递原 envelope。
3. 投递成功后把死信标记为 `replayed`,并累计 `replay_count`。
4. 如果重放后仍失败,普通消费者会再次写入新的 dead 记录,保留完整处理痕迹。
### 11.12 IM Server 已接入的用户治理命令
IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin 侧写 hotgo 操作单并发送 Kafka Command,IM 消费后修改租户库用户状态、踢在线连接、清理缓存并回传 `im.admin.command.result`。
支持命令:
| `event_type` | 行为 | Payload 关键字段 |
| --- | --- | --- |
| `user.ban` | 封禁用户,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.unban` | 解封用户,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.freeze` | 冻结账号,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.unfreeze` | 恢复账号,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.device.kick` | 踢指定设备在线连接,并删除设备推送记录。 | `actor_user_id`、`user_id` 或 `target_user_id`、`device_id`、`reason` |
| `user.logout_all` | 踢用户所有在线连接,不修改账号状态。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.account.cancel` | 注销账号,默认软删除;`hard=true` 时硬删除。 | `actor_user_id`、`user_id` 或 `target_user_id`、`hard`、`reason` |
| `user.account.delete` | 删除账号,按硬删除处理。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
命令示例:
```json
{
"event_id": "admin:user:ban:default:usrabc:001",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im-server",
"event_type": "user.ban",
"biz_type": "user_governance",
"biz_id": "usrabc",
"idempotency_key": "admin-operation-id",
"occurred_at": "2026-08-26T10:10:00.000Z",
"payload": {
"command_id": "admin-operation-id",
"actor_user_id": "usrAdmin",
"user_id": "usrabc",
"reason": "违规处理"
}
}
```
处理说明:
1. 封禁/冻结共用 Tinode 用户状态 `StateSuspended`;解封/恢复共用 `StateOK`。
2. 状态变更后 IM 会清理用户缓存,并向本节点 Hub 投递用户状态变更。
3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。
4. 全端登出只踢在线连接,不修改用户状态。
5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。
6. 命令执行成功后统一回传 `im.admin.command.result`;跨系统审计通过 `im.audit.event` 投递给 Admin/HotGo 同步落表。
7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。
事件示例:
```json
{
"event_id": "msg.persist.1.grpabc.10086",
"tenant_code": "default",
"source_service": "im-server",
"target_service": "im-server",
"event_type": "message.persist.group",
"biz_type": "message",
"biz_id": "grpabc:10086",
"idempotency_key": "client-mid-001",
"version": 10086,
"occurred_at": "2026-08-26T10:10:00.000Z",
"actor": {
"type": "user",
"id": "usr..."
},
"payload": {
"message_kind": "group",
"read_by_sender": true,
"attachment_urls": [],
"message": {
"SeqId": 10086,
"Topic": "grpabc",
"From": "abcdef",
"Head": {
"x-im-client-mid": "client-mid-001"
},
"Content": {
"txt": "hello"
}
}
}
}
```
### 11.9 IM Server 已移除 Kafka DB Outbox
IM Server 不再维护租户库 `im_kafka_outbox`,Kafka 事件统一由发送入口直接生产。生产侧使用 Kafka writer 的 `max_retry_count` 控制发送重试;消费侧使用 `im_kafka_consume_log` 做处理幂等和失败重试,超过上限后进入 `im.dead.letter`。
本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。
### 11.10 IM Server 文件生命周期同步处理
IM Server 不再生产或消费 `im.file.lifecycle`。文件相关的小流量动作改为同步执行。
当前事件:
| `event_type` | 生产时机 | 消费行为 |
| --- | --- | --- |
| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前无需 Kafka 消费。 |
| `file.attachments.bind` | 消息落库 consumer 成功后,如果消息携带附件。 | 同步写 `im_filemsglinks` 或 `im_group_files`。 |
| `group.file.created` | 群文件 API 登记成功后。 | HTTP 业务接口同步完成。 |
| `group.file.deleted` | 群文件 API 删除成功后。 | HTTP 业务接口同步完成。 |
说明:
1. 消息附件绑定在消息落库 consumer 内同步完成。
2. Kafka 关闭时,仍沿用原同步消息落库与附件绑定路径,兼容本地环境。
3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。
4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。
### 11.11 IM Server 工作台事件消费处理
工作台拆分为独立 `im_workbench` 服务后,工作台业务变更由 `im_workbench` 生产 `im.workbench.event`,IM Server 新增独立 Kafka consumer 消费该 Topic,并转为系统通知和 C 端业务实时事件。
说明:IM Server 当前保留旧工作台 HTTP 代码和同步处理函数,仅作为迁移过渡兼容。待 `im_workbench` 全部接口稳定后,再统一删除 IM Server 内的旧工作台业务代码。
当前生产范围:
| 模块 | 事件 |
| --- | --- |
| 会议 | `meeting.created`、`meeting.updated`、`meeting.canceled`、`meeting.reminded`、`meeting.response.updated` |
| 日报 | `daily_report.draft.created`、`daily_report.draft.saved`、`daily_report.submitted`、`daily_report.reminded` |
| 月报 | `monthly_report.draft.created`、`monthly_report.draft.saved`、`monthly_report.submitted`、`monthly_report.reopened` |
| 总经理信箱 | `mailbox.submitted` |
当前消费行为:
1. `im_workbench` 直接发送 JSON 事件到 `im.workbench.event`,Topic 配置键为 `workbench_external`。
2. IM Server 按 `event_id + consumer_group` 做消费幂等。
3. 按 `target_user_ids` 为非发布人生成系统通知。
4. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
5. 消费失败按统一 Kafka 重试逻辑处理,超过最大重试次数后进入 `im.dead.letter`。
### 11.12 IM Server 已接入的审计事件
IM Server 对跨系统审计仍生产 `im.audit.event`。该 topic 由 Admin/HotGo 消费并同步写审计表;IM Server 本地不消费该 topic,也不通过它做内部异步落库。
当前审计范围:
| 类型 | 事件 |
| --- | --- |
| 工作台 | 会议、日报、月报、总经理信箱的状态变更。 |
| Admin Command | 后台群治理命令执行成功。 |
| Kafka 死信 | `kafka.dead_letter.created`。 |
| 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 |
审计事件当前不在 IM 内部通过 Kafka 落审计表,只作为 IM -> Admin/HotGo 的跨系统写事件。
## 12. 风险与约束
1. Kafka 是异步链路,Admin 后台命令不能默认立即成功,只能显示 `pending/running/succeeded/failed`。
2. 查询不能通过 Kafka 常规化实现,高频实时查询必须走内部只读 API 或 hotgo 摘要表。
3. 内部 API 不能暴露公网,签名、nonce、防重放和审计必须首批完成。
4. 配置事件可能丢失或延迟,IM 必须保留启动预热和缓存缺失时的 Admin 配置查询兜底。
5. Kafka 消费可能重复,所有消费者和业务写入都必须幂等。
6. Admin 和 IM 不跨库直连后,排障要依赖 `trace_id` 串联 hotgo 操作单、Kafka 事件、IM 执行日志和 WS 下发日志。
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
# IM Kafka 接入范围识别
> 日期:2026-08-26
> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 目标:从前后端对接文档逐章识别适合接入 Kafka 的链路,明确 Kafka 与 WebSocket、Redis、MySQL、OSS 的边界。
## 1. 总体边界
Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败补偿、后台操作通知 C 端和审计流水;不替代 WebSocket 的在线实时连接,不替代 Redis 的短状态和热读缓存,也不替代 MySQL 的最终状态。
| 类型 | 建议承载 | 说明 |
| --- | --- | --- |
| 在线实时消息投递 | Redis Pub/Sub/Stream + WebSocket | 低延迟 fanout,节点收到后直接写 WS。 |
| 异步落库、重试、补偿 | Kafka | 适合消息持久化、通知、Push、审核、审计等可重放任务。 |
| 短状态、限流、验证码、在线状态 | Redis | TTL 明确,不需要 Kafka。 |
| 最终业务状态 | MySQL | Kafka 消费成功后写入或更新最终状态。 |
| 文件对象存储 | OSS | Kafka 只处理文件元数据、安全检测和生命周期事件。 |
## 2. 推荐 Topic
| Topic | 用途 | 典型生产方 | 典型消费方 |
| --- | --- | --- | --- |
| `im.message.persist` | 私聊消息异步落库 | IM WS 节点 | 消息持久化消费者 |
| `im.group.message.persist` | 群聊消息异步落库 | IM WS 节点 | 群消息持久化消费者 |
| `im.message.push` | 离线 Push 提醒 | 消息落库消费者、业务事件生产方 | 极光/厂商 Push 消费者 |
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 | IM HTTP、Admin 后台 | IM 节点事件投递消费者 |
| `im.group.admin.event` | Admin 后台群操作投递到 C 端 | Admin 后台 | IM 节点 WS 投递消费者 |
| `im.cache.invalidate` | 跨节点缓存失效 | 业务写接口、后台配置 | IM 节点缓存消费者 |
| `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 |
| `im.file.lifecycle` | 已不采用 | 文件生命周期小流量动作同步处理 | 无 |
| `im.audit.event` | 跨系统审计事件 | IM Server、Admin 后台 | Admin/HotGo 审计消费者 |
| `im.workbench.event` | 工作台拆分后的业务事件、红点、系统通知、WS 下发 | im_workbench | IM Server 工作台事件消费者 |
| `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 |
## 3. 幂等与重试
Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、`biz_id`、`action`、`version`、`occurred_at` 和 `payload`。消费端按 `event_id + consumer_group` 做幂等记录,业务表也必须保留唯一键兜底。
消息发送链路使用 `x-im-client-mid` 或 HTTP `Idempotency-Key` 作为业务幂等来源;Kafka 只负责可靠投递,不能作为唯一幂等判断。消费者失败按指数退避重试,超过上限写 `im.dead.letter` 或失败表,由后台补偿。
## 4. 逐章识别
| 章节 | 适合 Kafka 的范围 | 不建议 Kafka 的范围 |
| --- | --- | --- |
| 0. 公共对接约定 | 产品业务事件 outbox 可改为 Kafka 投递;消息异步落库、系统通知、离线 Push、跨节点缓存失效使用 Kafka;`x-im-client-mid` 幂等结果可作为生产 Kafka 前置校验。 | WebSocket 建连、`hi/login/sub/get/note` 本身不走 Kafka;在线状态继续 Redis。 |
| 1. 账号注册、登录与身份安全 | 企业注册申请、企业入口反馈、实名提交、密码重置成功、账号封禁/解封、管理员重置密码、审核结果通知、审计日志可走 Kafka。营业执照和反馈截图上传完成后投 `im.file.security`。 | 登录鉴权、验证码校验、Action Token、限流不走 Kafka;这些必须同步返回或走 Redis。 |
| 2. 消息首页、全局导航与系统通知 | 系统通知创建、已读异步统计、入口红点变更、离线 Push 提醒、消息摘要更新可走 Kafka。 | 首页查询、会话列表拉取、客户端断线重连流程不走 Kafka。 |
| 3. 单聊会话、会话建立与消息状态 | 私聊消息异步落库、好友申请/通过/拒绝/删除事件、P2P 订阅建立后的通知、媒体消息文件绑定事件、离线 Push 可走 Kafka。 | 在线 P2P 实时投递主链路不建议 Kafka;关系判断和发送权限校验必须同步完成。 |
| 4. 消息输入、媒体内容与消息操作 | @提醒 Push、消息撤回/编辑事件、收藏批量创建、转发任务、forward bundle 生成、消息搜索索引更新、附件 ACL 绑定可走 Kafka。 | 输入框、草稿、录音状态、上传进度和客户端 pending 队列不走 Kafka。 |
| 5. 通讯录、好友关系与黑名单 | 好友申请事件、好友关系变更、黑名单变更、二维码使用审计、资料快照更新、搜索索引更新、系统通知生成可走 Kafka。 | 好友列表查询、用户搜索同步结果、二维码解析鉴权不走 Kafka。 |
| 6. 群聊创建、资料维护与成员治理 | 群创建、成员加入/退出、邀请/审批、公告更新、资料更新、角色变更、禁言、群主转让、解散、群文件登记/删除、Admin 后台群操作投递到 C 端都适合 Kafka。Admin 后台写库后生产 `im.group.admin.event`,IM 节点消费后用 WS 下发给在线客户端。 | 群内在线消息 fanout 主链路继续 Redis + WS;群成员权限校验、成员数上限校验必须同步事务完成。 |
| 7. 文件下载、收藏管理与聊天记录检索 | 文件安全检测、恶意文件扫描、文件生命周期、收藏快照异步补全、收藏转发、搜索索引更新、合规删除事件可走 Kafka。文件下载本身不走 Kafka。 | OSS 上传/下载字节流、Range 续传、下载进度不走 Kafka;这些走文件服务和客户端任务管理。 |
| 8. 实时语音与视频通话 | 通话记录落库、未接来电 Push、通话结束审计、后台计费或统计、异常通话风控可走 Kafka。 | WebRTC offer/answer/ICE、接听、挂断等实时信令不建议 Kafka 主链路;仍走 WS。 |
| 9. 企业工作台与办公协同 | 工作台红点更新、会议创建/更新/取消/提醒/响应、日报/月报提交和催办、总经理信箱提交、工作台系统通知、后台配置变更、审计都适合 Kafka。 | 工作台模块列表、组织架构查询、报表详情查询不走 Kafka;普通查询走 MySQL/Redis 缓存。 |
| 10. 个人资料、通用设置、隐私与青少年模式 | 资料变更事件、隐私配置变更、青少年模式开启/关闭、多设备退出、Push Token 变更审计、App 发布配置缓存失效可走 Kafka。头像上传后走文件安全检测。 | 本地清缓存、系统权限引导、密码验证、Action Token 不走 Kafka。 |
| 11. 反诈安全、举报与意见反馈 | 风控确认审计、举报受理、证据文件安全检测、意见反馈、诊断包处理、后台审核通知、合规处置事件可走 Kafka。 | 风险弹窗同步拦截结果、举报原因查询、反馈配置查询不走 Kafka。 |
| 12. 联调实施清单 | Kafka 需要覆盖消息幂等、系统事件可信生产、文件安全检测、后台群操作到 C 端、离线 Push、失败重试和死信补偿验收。 | 前端基础联调请求不直接依赖 Kafka,除非该接口声明为异步任务。 |
## 5. 文件链路结论
文件上传、下载、Range、ETag、OSS 对象读写不走 Kafka。Kafka 只接入文件完成后的业务事件:
1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。
2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。
3. 消息附件引用绑定、群文件登记等小流量动作同步落库,不再投递 `im.file.lifecycle`。
4. 业务撤回、删除、合规处置或临时文件 TTL 回收如需异步化,另行评估专门任务队列。
## 6. 工作台链路结论
工作台“查询类接口”不需要 Kafka,状态变化和提醒类事件当前也同步处理:
1. 会议创建、更新、取消、提醒、参会响应同步生成系统通知、红点并通过 WS 通知在线客户端。
2. 日报/月报提交、撤回、重开、管理员催办同步生成红点、催办 Push 和审计。
3. 总经理信箱提交同步处理,后台处理结果再回推 C 端。
4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。
## 7. 已补充:缓存失效与 Admin 配置查询
1. `im.cache.invalidate` 已接入 IM 消费端,支持租户、注册短信、第三方配置、字典配置、Redis 对象缓存、群热消息缓存失效。
2. IM 已增加 Admin 内部配置查询客户端,开启 `admin_internal_api.enabled=true` 后优先通过 Admin 查询 hotgo 侧配置,旧数据库读取作为灰度兜底。
3. Admin 侧需要补齐 `/internal/im-config/*` 只读接口,并与 IM 使用同一套 HMAC 签名密钥。
4. Kafka 死信已补齐查询、忽略、解决、重放闭环:查询走 IM 内部 API,处理走 `im.admin.command`。
5. 用户治理类后台动作已接入 `im.admin.command`:封禁/解封、冻结/恢复、踢指定设备、全端登出、注销/删除账号。
## 8. 首批建议实现
1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。
2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。
3. 接入 `im.message.persist` 和 `im.group.message.persist`,将私聊、群聊消息在 IM 内部异步落库和幂等重试闭环。
4. 接入 `im.file.security`,把 OSS 上传完成后的文件安全检测异步化。
5. 工作台事件保持 IM 内部同步处理;跨系统审计仍通过 `im.audit.event` 投递给 Admin/HotGo。
@@ -0,0 +1,597 @@
# IM Redis 三层数据分层改造方案
日期:2026-08-25
## 1. 背景与目标
当前 IM 服务需要接入 Redis,但不希望出现“进程内缓存一份、Redis 缓存一份、MySQL 再存一份”的重复状态,避免后续一致性维护困难。
本方案按业务数据特征拆成三层:
- 进程内:只放低频变更配置和本节点运行态。
- Redis:放小体量、高频读取、需要跨节点一致的热数据。
- MySQL:放最终持久化数据、事务数据、审计数据和大体量列表。
第一阶段不把消息正文作为 Redis 的长期存储。万人大群可以把 24 小时内热消息放入 Redis 作为读加速,私聊消息最终来源是租户库 `im_messages`,群聊消息最终来源是租户库 `im_group_messages`。
## 2. 核心原则
1. 同一类业务状态只确定一个主读取层,不在进程内和 Redis 同时维护两套可变状态。
2. 进程内缓存只承载低频配置或本机连接态,不承载跨节点业务状态。
3. 在线、离线、最近活跃等跨节点状态统一从 Redis 查询。
4. MySQL 仍是需要持久化、事务和审计的数据最终来源。
5. Redis 缓存 MySQL 数据时采用 cache-aside 模式,写入以 MySQL 事务成功为准。
6. Redis key 必须带租户标识,虽然业务库是一租户一库,但 Redis 是共享基础设施,需要避免不同租户数据串扰。
## 3. 三层数据分类
| 层级 | 数据类型 | 典型数据 | 读写特点 | 一致性要求 |
| --- | --- | --- | --- | --- |
| 进程内 | 静态或低频配置、本机运行态 | 系统配置、字典、租户配置、OSS/短信/内容安全/声网配置、本机 WebSocket 连接、topic channel | 启动加载,定时刷新;连接态只在本机维护 | 配置允许秒级延迟;连接态只代表本节点 |
| Redis | 小体量热数据、跨节点状态、短期状态 | 用户在线状态、用户基础信息、群基础信息、群成员集合、好友/黑名单集合、24 小时热消息、验证码、临时 token、限流计数 | 高频读,TTL 或主动失效 | 跨节点查询一致,允许短暂最终一致 |
| MySQL | 持久化和事务数据 | 消息、文件记录、群申请审批、举报、审核任务、操作日志、账务类数据、大分页列表 | 事务写入,分页查询,审计追溯 | 最终权威来源 |
## 4. 进程内层设计
### 4.1 适合放进程内的数据
- 系统配置:后台 hotgo 中的系统参数。
- 字典配置:`hg_sys_dict_type`、`hg_sys_dict_data` 等字典项。
- 租户配置:租户启停、基础配置、默认策略。
- 第三方配置:OSS、阿里云内容安全、短信、声网等接入参数。
- 本节点运行态:当前进程内的 WebSocket session、local topic channel、本机订阅关系、本机连接数。
### 4.2 刷新策略
- 服务启动时加载一次配置。
- 定时刷新,建议 60 秒。
- 后台配置变更后,后续可补充主动失效或广播刷新。
- 配置读取失败时保留旧值,并打印告警日志。
### 4.3 边界说明
进程内层不保存用户在线状态的全局结果。进程内只知道“本节点有哪些连接”,跨节点判断用户是否在线必须查 Redis。
## 5. Redis 层设计
### 5.1 第一阶段必须接入
#### 用户在线状态
在线状态是 Redis 第一优先级改造内容。原因是 WebSocket 长连接固定落在某个节点上,本机内存可以管理本机连接,但其他节点无法直接知道该用户是否在线。
建议 key:
```text
im:{tenant_id}:presence:user:{uid}
im:{tenant_id}:presence:user:{uid}:sessions
im:{tenant_id}:presence:node:{node_id}:users
```
`presence:user:{uid}` 建议使用 hash:
```text
online = 1
node_id = 当前节点 ID
session_count = 当前会话数
last_active_at = 最近活跃时间
last_seen_at = 最近可见时间
device = 设备类型
ua = 客户端 UA
```
生命周期:
1. WebSocket 登录成功后写入 Redis 在线状态。
2. 心跳或业务包刷新 TTL。
3. 断开连接时扣减 session 数,最后一个 session 断开后标记离线。
4. 节点异常退出时依赖 TTL 自动过期,避免永久在线。
5. 其他节点查询用户在线状态时只查 Redis。
TTL 建议:
- 在线状态 hash:60 到 120 秒。
- session 集合:60 到 120 秒,随心跳刷新。
- node 用户集合:可不强依赖 TTL,用于辅助排查和节点清理。
当前代码已经接入基础 PresenceStore:
- 登录成功后写入 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。
- 客户端每次上行业务包刷新 `last_active_at` 和 TTL。
- session 清理时移除当前 session,最后一个 session 离线后将 `online` 置为 `0`。
- 对外提供 `store.GetUserPresence` 作为后续用户在线状态查询入口。
- 群 Topic 在线成员已同步维护 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`,订阅/离开时更新,客户端心跳或任意上行业务包会刷新已订阅群的在线成员 TTL。
- 订阅列表中的用户在线状态已改为 Redis 优先读取;Redis 未启用或读取失败时,回退到原有进程内在线判断。
#### 短期状态
以下数据适合放 Redis,减少 MySQL 压力并天然支持过期:
- 注册验证码、登录验证码。
- 邀请码短期校验结果。
- 企业码登录临时 token。
- 登录失败次数、风控计数、接口限流计数。
- 幂等 key、短期操作锁。
### 5.2 第二阶段建议接入
#### 用户基础信息
适合缓存:
- 用户昵称、头像、状态签名。
- 用户公开资料。
- 用户账号到 UID 的映射。
不建议缓存:
- 密码 hash。
- 高敏认证字段。
- KYC 原始材料。
读写规则:
1. 查询先读 Redis。
2. 未命中读 MySQL。
3. MySQL 命中后写入 Redis。
4. 用户资料变更时先提交 MySQL,再删除或更新 Redis。
#### 群相关数据
群数据体量相对可控,且聊天过程中读取频繁,适合逐步放入 Redis:
- 群基础信息:群名、头像、简介、人数、群类型。
- 群配置:入群审批、禁言策略、邀请权限、公告权限。
- 群成员集合:成员 UID 集合。
- 群成员角色:群主、管理员、普通成员。
- 群成员状态:禁言、移出、黑名单。
建议 key:
```text
im:{tenant_id}:group:{group_id}:profile
im:{tenant_id}:group:{group_id}:settings
im:{tenant_id}:group:{group_id}:members
im:{tenant_id}:group:{group_id}:admins
im:{tenant_id}:group:{group_id}:muted
```
#### 关系数据
好友、黑名单、关注类关系适合用 Redis set 承载热读:
```text
im:{tenant_id}:relation:friends:{uid}
im:{tenant_id}:relation:blocklist:{uid}
```
用于快速判断:
- 是否好友。
- 是否被拉黑。
- 是否允许发起会话。
- 是否允许查看在线状态或最近在线时间。
### 5.3 第三阶段可选接入
- 首页 bootstrap 聚合结果:短 TTL 缓存,减少多表聚合查询。
- 群成员分页快照深度优化:基础 HTTP 结果快照已接入,后续大群可继续补 sorted set、稳定游标和主动失效。
- 未读数深度优化:基础总未读 Redis 热缓存已接入,后续可继续补 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。
### 5.4 万人大群实时推送专项
万人大群的实时推送需要和 Redis 改造一起考虑。核心目标是避免普通小群模式下的全量 fanout 压垮服务端,同时保证在线用户实时可达、离线用户通过极光 Push 提醒、客户端重连后可按 seq 补齐消息。
该部分作为 Redis 改造的近期实现项处理,不再仅作为未实现增强项记录。
#### 数据分层
| 层级 | 承载内容 | 说明 |
| --- | --- | --- |
| 进程内 | 本节点在线连接、本节点 topic 订阅、本节点待发送队列 | 只负责当前节点连接和发送,不保存全局群成员在线状态。 |
| Redis | 群在线成员索引、用户所在节点、群分片、原子 seq 发号器、24 小时热消息、短期去重 key | 用于判断哪些在线用户需要实时推送、原子获取群消息 seq,以及近期高频消息读取加速。 |
| MySQL 租户库 | `im_group_messages` 群消息正文、群消息 seq 结果、消息索引、离线同步游标、审计数据 | 作为群消息最终存储,保存 Redis 分配后的 seq,不负责实时发号。 |
| 极光 Push | 离线提醒 | 只做提醒,不承载消息正文最终一致性。 |
#### 建议 Redis key
```text
im:{tenant_id}:presence:user:{uid}
im:{tenant_id}:topic:{topic}:online_members
im:{tenant_id}:topic:{topic}:node:{node_id}:online_members
im:{tenant_id}:topic:{topic}:shard:{shard_id}:members
im:{tenant_id}:topic:{topic}:last_seq
im:{tenant_id}:topic:{topic}:hot_msg_seqs
im:{tenant_id}:topic:{topic}:hot_msg:{seq}
im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id}
im:{tenant_id}:push:dedup:{topic}:{seq}:{uid}
```
说明:
- `topic:online_members` 保存当前群在线成员集合,可由用户上线、订阅群、退订群、断开连接维护。
- `topic:node:{node_id}:online_members` 用于跨节点推送时按节点拆分,减少广播范围。
- `topic:shard:{shard_id}:members` 可用于万人群成员分片,后续支持分批扫描和补偿。
- `topic:last_seq` 是群消息 seq 发号器,使用 Redis `INCR` 或 Lua 脚本原子获取下一个 seq。
- `topic:hot_msg_seqs` 使用 sorted set 保存 24 小时内热消息 seq,score 为 seq 或发送时间。
- `topic:hot_msg:{seq}` 保存单条热消息正文快照,TTL 建议 24 小时。
- `topic:uniq:msg:{from_uid}:{client_msg_id}` 保存群消息发送唯一 key,用于客户端重试幂等和服务端去重。
- `push:dedup` 用于极光 Push 或实时推送去重,TTL 建议按分钟级设置。
#### 唯一 key 与幂等规则
Redis 唯一 key 用于挡住短时间内的重复提交,MySQL 唯一索引作为最终兜底。不能只依赖 Redis 做永久唯一性,因为 Redis key 会过期,也可能被运维清理。
群消息发送唯一 key:
```text
im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id}
```
建议 value:
```json
{
"status": "pending|done|failed",
"seq": 12345,
"message_id": 987654321,
"created_at": "2026-08-25T12:00:00.000+08:00"
}
```
处理规则:
1. 客户端必须传 `client_msg_id`,同一发送人、同一 Topic 内唯一。
2. 服务端收到群消息后先用 `SET key value NX EX ttl` 抢占唯一 key,初始状态为 `pending`。
3. 抢占成功才继续 Redis `INCR` 获取群 `seq`。
4. 抢占失败时读取 key:
- `done`:直接返回已生成的 `seq/message_id`。
- `pending`:返回处理中或短暂等待后重试,避免重复发号。
- `failed`:允许客户端用同一个 `client_msg_id` 重试,或要求重新生成 `client_msg_id`。
5. 消息写入 `im_group_messages` 成功后,将唯一 key 更新为 `done`,记录 `seq/message_id`。
6. 消息写入失败时,将唯一 key 更新为 `failed` 并设置较短 TTL;已经获取的 `seq` 不回退,允许空洞。
7. `im_group_messages` 仍保留 `UNIQUE(topic, from_user, client_msg_id)`,防止 Redis key 过期后重复落库。
TTL 建议:
- `pending`:30 到 120 秒。
- `done`:24 小时到 7 天,按客户端重试窗口决定。
- `failed`:5 到 30 分钟。
- Push 去重 key:5 到 30 分钟。
#### Seq 发号规则
- 群消息 seq 由 Redis 维护,不从 MySQL 查询最大 seq。
- 每个 Topic 独立一个 `topic:last_seq`,使用 `INCR im:{tenant_id}:topic:{topic}:last_seq` 原子获取。
- 只要求单 Topic 内单调递增,不要求全局连续。
- 如果取到 seq 后消息持久化失败,该 seq 可以形成空洞;客户端按 seq 补拉时允许跳过服务端确认不存在的 seq。
- Topic 创建或 Redis key 丢失时,当前代码使用 topic 内存态 `lastID` 作为 Redis 初始化基线;该值来自 topic 加载结果或当前进程已确认的最新 seq,然后再执行原子 `INCR`。
- Redis 不可用时不能用数据库临时发号继续写大群消息,应返回可重试错误,避免多源发号造成 seq 冲突。
#### 发送流程
1. 服务端收到群消息后,先完成权限校验和禁言校验。
2. 使用 Redis 唯一 key 抢占 `topic + from_uid + client_msg_id`,防止客户端重试重复发送。
3. 抢占成功后,使用 Redis `INCR` 或 Lua 脚本从 `topic:last_seq` 原子获取下一个群 `seq`。
4. 消息正文带着该 `seq` 写入租户库 `im_group_messages`。
5. 落库成功后将唯一 key 更新为 `done`,记录 `seq/message_id`。
6. 对万人大群或高热群,将消息快照写入 `topic:hot_msg:{seq}`,并把 seq 加入 `topic:hot_msg_seqs`,TTL 保持 24 小时。
7. 查询 Redis 中该群在线成员,按 `node_id` 分组。
8. 本节点在线成员直接从进程内连接表推送。
9. 其他节点在线成员通过 Redis Pub/Sub 通知目标节点推送;当前代码使用 `im:0:group_fanout` 广播精简后的 `ServerComMessage`,订阅节点收到后投递到本机 `hub.routeSrv`,只发送给本节点已加载 Topic 的在线连接。
10. 不在线成员不做服务端全量离线消息 fanout,只更新离线游标或依赖消息 seq 补拉,并触发极光 Push。
11. 客户端重连后按 Topic seq 拉取缺失消息,优先读 Redis 热消息,未命中再回源租户库 `im_group_messages`。
#### 读取流程
客户端消息读取仍以 seq 为准,Redis 只优化 24 小时内的热读:
1. 在线实时收到消息后,客户端本地落库并更新本地最大 seq。
2. 重连、切换设备或发现 seq 不连续时,调用历史消息接口按 `topic + after_seq` 补拉。
3. 服务端先按 seq 范围读取 Redis 热消息。
4. Redis 命中的消息直接返回;缺失、过期或不连续的 seq 回源租户库 `im_group_messages`。
5. 回源命中后可按策略重新写入 Redis,保持 24 小时 TTL。
6. 极光 Push 唤醒后,客户端仍走正常登录和 seq 补拉链路。
#### 热消息缓存边界
- 只缓存 24 小时内高频读取的群消息,普通群可不开启或按群规模阈值开启。
- Redis 中的消息快照不作为审计和历史同步最终来源。
- 删除、撤回、编辑消息后,必须同步删除或更新对应 `topic:hot_msg:{seq}`,避免客户端读到旧正文。
- 大消息、文件二进制、音视频内容不直接放 Redis,只缓存消息元数据和 OSS object key。
- Redis 容量按租户和群设置上限,超过阈值时优先淘汰低热群或旧 seq。
- 当前代码只在 Redis 能完整命中一页群消息时直接返回;如果 Redis 缺失、过期、不连续或读取带有用户软删除视角,则回源 MySQL,避免缓存缺口导致消息缺失或读到用户已删除内容。
#### 离线 Push 口子
极光 Push 只用于离线提醒,建议 payload 只放最小必要信息:
```json
{
"type": "group_message",
"topic": "grp_xxx",
"seq": 12345,
"tenant_id": "tenant_xxx"
}
```
不建议在 Push payload 中放完整消息正文,避免隐私泄露、厂商通道截断和多端一致性问题。
#### 一致性边界
- Redis 的在线成员集合允许短暂不准,最终由心跳 TTL 和订阅变更修正。
- Redis 唯一 key 负责短期幂等和去重,MySQL 唯一索引负责最终唯一性兜底。
- seq 是否已分配以 Redis `topic:last_seq` 为准;群消息是否真实存在,以租户库 `im_group_messages` 为准。
- Redis 发号成功但消息持久化失败时,允许 seq 空洞,不回退 `topic:last_seq`。
- 实时推送失败不影响消息持久化,通过客户端 seq 补拉恢复;Redis 热消息缺失时回源租户库 `im_group_messages`。
- 极光 Push 失败不影响消息存储,只影响离线提醒,需要记录失败并可观测。
- Redis Pub/Sub 只负责在线实时投递,不作为可靠消息队列;节点短暂断线错过实时消息时,客户端仍以 Topic seq 补拉恢复。
## 6. MySQL 层设计
### 6.1 必须保留在 MySQL 的数据
- 私聊消息正文、私聊消息索引、离线同步游标。
- 群消息正文、群消息索引、群消息审计数据。
- 文件记录、OSS object key、文件安全审核结果。
- 群邀请、进群申请、审批记录。
- 群公告、公告确认、公告历史。
- 用户认证、KYC 记录、企业注册资料。
- 举报、投诉、内容安全审核任务。
- 操作日志、审计日志、管理后台变更记录。
- 需要事务一致性的业务状态。
### 6.2 消息表拆分
当前 `im_messages` 后续只作为私聊、文件助手等非群聊消息的主存储。群聊消息新增租户库业务表 `im_group_messages`,避免所有消息挤在一张表里,也方便后续针对大群按 `topic + seq`、时间、热度做分区、归档和缓存。
建议表结构:
```sql
CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(64) NOT NULL COMMENT '群 Topic,如 grp_xxx',
group_id BIGINT NOT NULL COMMENT '业务群 ID,便于分区、治理和统计',
seqid BIGINT NOT NULL COMMENT '群内消息序号,由 Redis 原子发号',
from_user BIGINT NOT NULL COMMENT '发送人用户 ID',
client_msg_id VARCHAR(64) NULL COMMENT '客户端幂等消息 ID',
msg_type VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等',
status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked',
content JSON NULL COMMENT '消息正文或 Drafty 内容',
head JSON NULL COMMENT '消息头,包含 @、引用、客户端能力等',
mentioned_user_ids JSON NULL COMMENT '@ 用户列表,便于提醒和检索',
mention_all TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 @ 全体',
reply_seqid BIGINT NULL COMMENT '回复的群消息 seq',
root_seqid BIGINT NULL COMMENT '线程或合并上下文根消息 seq',
file_count INT NOT NULL DEFAULT 0 COMMENT '附件数量',
content_size INT NOT NULL DEFAULT 0 COMMENT '正文 JSON 序列化后的字节数',
has_risk TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否命中风控或内容安全',
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject',
version INT NOT NULL DEFAULT 1 COMMENT '编辑版本',
recalled_at DATETIME(3) NULL,
recalled_by BIGINT NULL,
deleted_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL COMMENT '可选保留期,便于过期归档或清理',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_group_messages_group_seq (group_id, seqid),
UNIQUE KEY uk_im_group_messages_topic_seq (topic, seqid),
UNIQUE KEY uk_im_group_messages_topic_sender_client_mid (topic, from_user, client_msg_id),
KEY idx_im_group_messages_group_created (group_id, created_at),
KEY idx_im_group_messages_group_status_seq (group_id, status, seqid),
KEY idx_im_group_messages_topic_created (topic, created_at),
KEY idx_im_group_messages_topic_from_seq (topic, from_user, seqid),
KEY idx_im_group_messages_audit (audit_status, created_at),
KEY idx_im_group_messages_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
设计说明:
- `seqid` 使用 `BIGINT`,避免大群长期高频消息导致 `INT` 不够用。
- `group_id + seqid` 和 `topic + seqid` 都保持唯一,业务治理优先用 `group_id`,协议补拉仍可用 `topic`。
- `client_msg_id` 用于发送幂等,索引带上 `topic`,避免不同群复用客户端消息 ID 时互相冲突。
- `status` 标记普通、撤回、删除、拦截状态,拉历史时可以返回占位或过滤。
- `mentioned_user_ids`、`mention_all` 单独存,方便 @ 提醒、离线 Push 和后台治理,不必每次解析正文。
- `audit_status`、`has_risk` 为内容安全和风控留字段,高风险消息可先 pending 后再放行。
- `reply_seqid`、`root_seqid` 为引用回复、线程、合并转发上下文预留。
- 文件二进制不进表,表内只保存消息内容和 OSS object key 等元数据;群消息附件会同步登记到 `im_group_files`,并记录 `source_topic/source_seq` 追溯来源消息。
- 超大规模租户后续可按 `group_id` hash 分表,或按 `created_at/expires_at` 做归档分区。
读写规则:
1. 私聊消息继续写 `im_messages`。
2. 群聊消息写 `im_group_messages`。
3. 群消息发号由 Redis `topic:last_seq` 原子递增完成,`im_group_messages.seqid` 只保存结果。
4. Redis 24 小时热消息命中时直接返回;未命中按 `topic + seqid` 回源 `im_group_messages`。
5. 撤回、编辑、删除群消息时,以 `im_group_messages` 为准,并同步更新或删除 Redis 热消息。
### 6.3 消息正文说明
第一阶段不把消息正文作为 Redis 长期存储。万人大群和高热群允许缓存 24 小时内热消息,作为客户端重连补拉、最近历史翻页和多端同步的读加速。
消息正文主要读取场景:
- 新设备登录后的历史消息同步。
- 离线用户重新上线后的消息补拉。
- 客户端本地消息缺失后的重拉。
- 举报、风控、内容安全复查。
- 管理后台审计或客服处理。
这些场景的最终来源仍是 MySQL 的消息表。私聊回源 `im_messages`,群聊回源 `im_group_messages`。Redis 热消息只覆盖短期高频读取,命中则减少数据库压力,未命中必须回源。
## 7. 读写一致性规则
### 7.1 进程内配置
- 进程启动加载配置。
- 定时刷新配置。
- 配置读取失败时使用旧值。
- 配置变更允许短时间延迟生效。
### 7.2 Redis 运行态
- 在线状态以 Redis 为跨节点查询来源。
- 本机内存只用于管理本机连接。
- Redis 写入失败时,在线状态降级为未知或离线,不阻断基础连接能力。
- 心跳持续刷新 TTL,节点异常时依赖 TTL 自动回收。
### 7.3 Redis 缓存 MySQL 热数据
- 读:先 Redis,未命中再 MySQL,最后回填 Redis。
- 写:先 MySQL 事务提交,再删除或更新 Redis。
- 消息 seq:群聊先通过 Redis `INCR` 或 Lua 脚本原子获取 seq,再带 seq 写租户库 `im_group_messages`;私聊仍按现有 `im_messages` 链路处理。
- 消息热缓存:消息持久化成功后,再写 Redis 24 小时热消息;热消息写失败不影响消息发送成功。
- Redis 故障:直接回源 MySQL,记录降级日志和指标。
- 不允许因为 Redis 写失败回滚已经成功的 MySQL 业务事务,除非该业务本身强依赖 Redis 状态。
### 7.4 MySQL 持久化
- 需要审计、事务、历史追溯的数据直接写 MySQL。
- Redis 和进程内缓存只做读取加速或运行态存储。
- 私聊消息使用 `im_messages`,群聊消息使用 `im_group_messages`,避免单表无限膨胀。
## 8. 租户隔离
当前方案是一租户一库,业务表中不再保留 `tenant_id` 字段。但 Redis、进程内配置、日志指标仍属于共享运行环境,必须保留租户维度。
Redis key 统一包含租户标识:
```text
im:{tenant_id}:...
```
如果请求早期只有企业码或租户编码,应先解析到租户 ID 或租户库配置,再进入业务缓存读取。
## 9. 代码改造建议
### 9.1 基础设施
- 增加 Redis 配置:地址、密码、DB、连接池、超时、key 前缀。
- 增加 Redis client 初始化和健康检查。
- 增加统一 key builder,避免业务代码手写 key。
- 增加缓存指标和日志。
当前代码将 Redis 配置挂在 `store_config.redis` 下;不配置则不启用 Redis 群消息发号和唯一 key,群消息仍兼容旧的本机 `lastID + 1` 发号。
群消息物理表按自然月拆分时,落库路由、跨月查询、幂等和迁移方案见:[IM-群消息按月分表改造方案](./IM-群消息按月分表改造方案.md)。
配置示例:
```json
{
"store_config": {
"redis": {
"addr": "127.0.0.1:6379",
"username": "",
"password": "",
"db": 0,
"key_prefix": "im",
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
"presence_ttl_seconds": 120,
"cache_ttl_seconds": 300,
"short_state_ttl_seconds": 600,
"group_fanout_enabled": true
}
}
}
```
`group_fanout_enabled` 默认关闭。启用后,群消息本节点投递完成后会发布 Redis Pub/Sub,其他节点订阅后只向本节点已在线的同群连接推送;如果节点没有加载该 Topic,则读取 Redis 本节点群在线成员集合做兜底直投,本地会话订阅表作为兼容兜底,实时投递失败再由客户端 seq 补拉恢复。
当前第二阶段已接入:
- 用户资料单对象缓存:`store.Users.Get` 优先读 Redis,用户更新/删除后失效。
- Topic 单对象缓存:`store.Topics.Get` 优先读 Redis,Topic 更新、转让、删除后失效。
- 单条订阅关系缓存:`store.Subs.Get` 优先读 Redis,订阅更新/删除后 active/any 两种缓存同时失效。
- 群治理资料缓存:群设置 `GroupBusiness.GetSettings`、群成员 `GroupBusiness.GetMember` 优先读 Redis,更新、离群、角色变更后刷新或失效。
- 群成员分页快照:`GET /v1/im/groups/{topic}/members` 按租户、Topic、操作者权限、筛选条件、cursor 和 limit 缓存响应快照,未命中回源订阅列表。
- 用户总未读热缓存:`store.Users.GetUnreadCount` 先读 Redis,未命中回源 MySQL 并回填;在线用户 unread updater 更新内存计数时同步写 Redis。
- 好友/黑名单 Redis Set 热缓存:关系上下文判断先查 `friends:{uid}`、`blocklist:{uid}` 正向 set,命中后减少 MySQL 查询;未命中仍回源 MySQL,新增/删除好友、拉黑/解除拉黑会同步更新 set。
- 验证码短状态缓存:验证码创建、查询、验证、失败、消费会同步 Redis,`GetActive` 使用 `scene + phone_hash` 的 active key 做重发窗口热读。
- Redis 频控:验证码创建按 `tenant + scene + phone_hash` 使用 Redis 原子 `INCR + EXPIRE` 做 1 小时 5 次限制,手机号只使用 hash,不写明文。
- 动作 token 短状态缓存:创建时写 Redis,消费成功后删除缓存;最终消费仍以 MySQL 状态机为准,保留审计一致性。
- SeqId 收口:消息、Topic 游标、订阅读/收游标、消息范围、收藏/举报/群文件引用、通话消息引用、JSON/gRPC 协议结构均改为 `int64`;MySQL 新建表 schema 中对应字段改为 `BIGINT`。
- 进程内配置缓存:租户解析、注册短信开关、第三方配置读取已加本地 TTL 缓存,减少 HotGo 控制库频繁查询;缓存过期后按下一次读取刷新。
### 9.2 接口抽象
建议新增接口:
```go
type PresenceStore interface {
MarkOnline(ctx context.Context, tenantID string, uid string, sessionID string, meta PresenceMeta) error
Refresh(ctx context.Context, tenantID string, uid string, sessionID string) error
MarkOffline(ctx context.Context, tenantID string, uid string, sessionID string) error
Get(ctx context.Context, tenantID string, uid string) (Presence, error)
}
type ConfigCache interface {
GetDict(ctx context.Context, key string) (DictValue, bool)
GetTenantConfig(ctx context.Context, tenantID string) (TenantConfig, bool)
Refresh(ctx context.Context) error
}
type ReadModelCache interface {
GetUserProfile(ctx context.Context, tenantID string, uid string) (UserProfile, bool)
SetUserProfile(ctx context.Context, tenantID string, profile UserProfile) error
DeleteUserProfile(ctx context.Context, tenantID string, uid string) error
}
```
### 9.3 业务接入顺序
1. 先接入 Redis client 和配置。
2. 接入在线状态 PresenceStore。
3. 群消息接入 Redis `SET NX` 唯一 key 与 `INCR` 原子 seq 发号,群消息正文写入 `im_group_messages`。
4. WebSocket 登录、心跳、断开链路写入 PresenceStore。
5. 用户在线状态查询接口改为读 Redis。
6. 配置和字典改为进程内 TTL 缓存刷新。
7. 再逐步接入用户、群、好友关系热数据缓存。
## 10. 故障降级
| 场景 | 降级策略 |
| --- | --- |
| Redis 不可用,查询在线状态 | 返回未知或离线,记录告警 |
| Redis 不可用,读取用户/群缓存 | 回源 MySQL |
| Redis 不可用,验证码或短期 token | 涉及安全的接口应失败关闭 |
| Redis 不可用,万人大群发消息 | 无法原子获取群 seq,应返回可重试错误;不使用 MySQL 临时发号,避免 seq 冲突 |
| 配置刷新失败 | 使用进程内旧配置 |
| MySQL 不可用 | 持久化业务失败,不依赖 Redis 兜底 |
## 11. 监控指标
- Redis 连接状态。
- Redis 命令耗时。
- Redis 缓存命中率。
- 在线用户 key 数量。
- 心跳刷新失败次数。
- Redis 回源 MySQL 次数。
- 大群在线成员集合大小。
- 大群按节点分发耗时。
- 大群实时推送成功率和补拉率。
- 极光 Push 触发数、成功数和失败数。
- 配置刷新成功和失败次数。
- Presence TTL 过期导致的离线数量。
## 12. 第一阶段落地范围
第一阶段建议只做以下内容:
- Redis 基础配置和 client 初始化。
- 用户在线、离线、心跳刷新、跨节点查询。
- 验证码、临时 token、限流计数等短期状态迁移到 Redis。
- 系统配置、字典、租户配置、第三方配置改为进程内 TTL 缓存刷新。
- 万人大群实时推送纳入 Redis 改造近期实现范围,先完成 Redis key、在线成员索引、节点分片和跨节点实时分发链路。
- 明确消息正文不进入 Redis 长期存储;万人大群 24 小时热消息可进入 Redis 做读加速。
- 群成员分页快照和用户总未读 Redis 热缓存已完成基础接入。
后续再逐步推进好友/黑名单全量预热、群成员分页快照、未读数等缓存改造。
@@ -0,0 +1,446 @@
# IM bootstrap 入口与未读数改造需求文档
## 1. 背景
当前 `GET /v1/im/bootstrap` 已返回登录后的功能开关、权限、工作台模块、系统 Topic 和安全摘要。前端同事在接入时提出以下问题:
1. 新朋友未处理数、群列表、文件助手和系统消息入口是否都由 bootstrap 控制显示。
2. 未读数字段分别从哪里读取。
3. 工作台是否需要总未读数。
4. 文件助手和系统消息是否完全依赖 WebSocket Topic 推送,还是 bootstrap 也要返回入口和初始未读。
5. 文档需要给出完整响应示例,避免入口字段和里层结构继续口头确认。
现有前后端对接文档已有基础约定:
1. 登录后客户端必须执行 `sub me`,从 `meta.sub` 获取每个会话 Topic 的 `seq/read/recv`。
2. 普通聊天会话未读数使用 `max(seq - read, 0)`。
3. `bootstrap` 返回固定入口、功能开关、权限和系统 Topic。
4. `workbench.modules[].unread_count` 已在结构中定义,但当前实现固定为 0。
缺口在于:bootstrap 没有明确返回通讯录入口、系统消息入口、文件助手入口、工作台总未读和业务 badge;文档也没有明确哪些未读来自 Topic,哪些未读来自业务表统计。
## 2. 目标
1. 明确 `bootstrap` 是登录后首屏入口配置和初始 badge 的聚合接口。
2. 明确 Topic 类未读和业务类未读的分工。
3. 为前端提供稳定响应结构,减少后续沟通成本。
4. 保持聊天实时链路轻量:普通聊天、群聊、文件助手和系统消息的实时增量仍通过 Tinode WebSocket Topic 更新。
5. 为后续工作台 badge、系统通知和好友申请的实时事件留出兼容空间。
## 3. 非目标
1. 不把所有会话列表塞入 bootstrap;普通会话列表仍通过 `sub me` 和会话搜索/列表能力获取。
2. 不用 bootstrap 替代 WebSocket 增量更新。
3. 不让客户端根据本地按钮状态绕过服务端权限校验。
4. 不要求本期实现复杂实时聚合服务;可先返回准确初始值,再通过已有 Topic 或系统事件增量更新。
## 4. 概念定义
| 名称 | 含义 |
| --- | --- |
| 入口 entry | 登录后首页、通讯录、工作台等固定可见入口配置 |
| Topic 未读 | 由 Tinode Topic 的 `seq/read/recv` 推导出的消息未读 |
| 业务 badge | 不能用 Topic `seq/read` 表达的业务待处理数量,例如新朋友待处理申请 |
| 工作台模块未读 | `modules[].unread_count`,表示单个工作台模块的业务未读 |
| 工作台总未读 | 所有可见工作台模块未读汇总,供底部 Tab 或一级入口展示 |
| 系统消息 | 服务端可信系统通知,展示页可查 HTTP 列表,实时通知可走系统 Topic 或系统事件 |
| 文件助手 | 用户自己的固定会话入口,用于收藏/文件转发/个人文件消息;若实现为 Topic,则未读遵循 Topic 规则 |
## 5. 未读数来源规则
### 5.1 Topic 类未读
以下未读原则上来自 Tinode Topic:
1. 单聊会话。
2. 群聊会话。
3. 系统消息入口,如果系统消息以 `system_topic.topic` 作为可信 Topic 承载。
4. 文件助手入口,如果文件助手以固定 Topic 承载。
计算规则:
```text
topic_unread_count = max(seq - read, 0)
```
客户端登录后必须执行:
```text
WebSocket Upgrade -> hi -> token login -> sub me -> get sub/data
```
初始会话列表从 `meta.sub` 获取,后续通过 `{data}`、`{meta}`、`{pres}`、`{info}` 和 `note read/recv` 更新本地状态。
### 5.2 业务类未读
以下未读不适合用 Topic `seq/read` 计算,应由 HTTP 业务接口或 bootstrap 返回:
1. 新朋友待处理申请数。
2. 工作台模块待处理数,例如会议、日报、月报、总经理信箱。
3. 未来的审批、公告、任务等非聊天会话类 badge。
业务类未读的真源在业务表,bootstrap 返回登录后的初始聚合值;后续增量可通过系统事件通知前端刷新或局部更新。
## 6. bootstrap 响应结构扩展
### 6.1 顶层新增字段
`GET /v1/im/bootstrap` 的 `data` 建议新增:
```json
{
"entry_badges": {
"contacts_total_unread": 2,
"new_friends_pending_count": 2,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": 0,
"workbench_total_unread": 5
},
"entries": {
"new_friends": {
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": 2
},
"groups": {
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0
},
"file_assistant": {
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic"
},
"system_messages": {
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": 0,
"unread_source": "business"
}
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `entry_badges.contacts_total_unread` | int | 通讯录一级入口红点总数;本期可等于 `new_friends_pending_count` |
| `entry_badges.new_friends_pending_count` | int | 当前用户待处理入站好友申请数 |
| `entry_badges.groups_unread_count` | int | 群入口业务未读;若只是群聊消息未读,则可为 0,由会话列表负责 |
| `entry_badges.file_assistant_unread_count` | int | 文件助手入口初始未读 |
| `entry_badges.system_unread_count` | int | 系统消息入口初始未读 |
| `entry_badges.workbench_total_unread` | int | 工作台可见模块未读总和 |
| `entries.*.enabled` | bool | 是否展示入口 |
| `entries.*.route` | string | 前端内部路由,只允许白名单内部路径 |
| `entries.*.topic` | string | 若入口对应 Topic,则返回 Topic 名;没有 Topic 时省略 |
| `entries.*.unread_count` | int | 当前入口初始未读或待处理数 |
| `entries.*.unread_source` | string | `topic` 或 `business`,用于前端判断后续更新来源 |
### 6.2 unread_source 约定
| unread_source | 含义 | 后续更新方式 |
| --- | --- | --- |
| `topic` | 未读来自 Topic `seq/read` | WebSocket Topic 增量和 `note read/recv` |
| `business` | 未读来自业务表统计 | 业务 HTTP 刷新或系统事件 |
| `none` | 入口无未读概念 | 不展示 badge |
`new_friends.unread_source` 可省略,默认按 `business` 处理。若为了统一,也可以显式返回:
```json
{
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": 2,
"unread_source": "business"
}
```
## 7. 工作台 unread 规则
当前 `modules` 结构保留:
```json
{
"code": "meetings",
"name": "会议通知",
"icon_file_id": "",
"route": "/meetings",
"unread_count": 3,
"enabled": true,
"permission": "enabled"
}
```
新增规则:
1. `modules[].unread_count` 是模块级业务未读。
2. `entry_badges.workbench_total_unread` 是所有可见且 `enabled=true` 模块的未读总和。
3. 隐藏或无权限模块不计入总未读。
4. 后续增量事件使用 `workbench.badge.updated`:
```json
{
"type": "system_event",
"event_type": "workbench.badge.updated",
"event_id": "evt_...",
"created_at": "2026-08-20T12:00:00.000Z",
"params": {
"module_code": "meetings",
"unread_count": 4,
"workbench_total_unread": 6,
"workbench_version": 1787153039
}
}
```
前端收到后只更新对应模块和工作台总未读;如果 `workbench_version` 大于本地版本,应重新调用 `GET /v1/im/workbench` 或 `GET /v1/im/bootstrap`。
## 8. 系统消息规则
系统消息有两个视角:
1. 列表查询:`GET /v1/im/system-notifications?cursor=` 返回历史通知列表。
2. 实时增量:通过服务端可信系统事件推送。
bootstrap 返回:
```json
{
"system_topic": {
"topic": "sys",
"type": "system"
},
"entries": {
"system_messages": {
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": 0,
"unread_source": "business"
}
}
}
```
实现要求:
1. 普通用户不能向系统 Topic 发布可信系统消息。
2. 客户端不能把普通用户伪造的 `system_event` 渲染为可信通知。
3. 本期系统消息未读使用 `im_system_notifications.read_at` 统计,`unread_source` 固定为 `business`,并由服务端返回准确 `system_unread_count`。
本期约定:系统消息入口保留 `topic:"sys"` 作为入口标识和后续事件通道标识,初始未读数以系统通知业务表为准。若后续确认普通用户不能订阅 `sys`,应改为每用户系统通知 Topic 或纯 HTTP 通知列表 + business badge。
## 9. 文件助手规则
文件助手需要先明确产品实现形态:
### 9.1 Topic 形态
如果文件助手是用户自己的固定 Topic,例如 `slf` 或专用 `file_assistant_topic`:
```json
{
"file_assistant": {
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic"
}
}
```
未读通过 `sub me` 中该 Topic 的 `seq/read` 计算。
### 9.2 纯入口形态
如果文件助手只是本地入口,不对应消息 Topic:
```json
{
"file_assistant": {
"enabled": true,
"route": "/files/assistant",
"unread_count": 0,
"unread_source": "none"
}
}
```
此时不展示消息未读,只展示入口。
本期建议采用 Topic 形态,便于复用消息、文件、转发、未读和多端同步能力。
## 10. 通讯录入口规则
通讯录页包含以下固定入口:
1. 新朋友。
2. 群聊。
3. 文件助手。
4. 系统消息。
bootstrap 控制入口展示和初始 badge。具体列表仍由各自接口负责:
| 入口 | 展示控制 | 初始 badge | 列表数据 |
| --- | --- | --- | --- |
| 新朋友 | `entries.new_friends.enabled` | `new_friends_pending_count` | `GET /v1/im/friend-requests` |
| 群聊 | `entries.groups.enabled` | 群业务 badge,群消息未读走 Topic | 群列表接口或 `sub me` 中群 Topic |
| 文件助手 | `entries.file_assistant.enabled` | Topic 或 none | Topic 消息或文件助手专用接口 |
| 系统消息 | `entries.system_messages.enabled` | Topic 或 business | `GET /v1/im/system-notifications` |
## 11. 完整 bootstrap 示例
```json
{
"code": 0,
"data": {
"features": {
"create_group": true,
"message_edit": true,
"message_recall": true,
"scan_qr": true,
"system_notifications": true,
"user_search": true
},
"permissions": {
"create_group": true,
"scan_qr": true,
"user_search": true
},
"entry_badges": {
"contacts_total_unread": 2,
"new_friends_pending_count": 2,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": 1,
"workbench_total_unread": 5
},
"entries": {
"new_friends": {
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": 2,
"unread_source": "business"
},
"groups": {
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0,
"unread_source": "business"
},
"file_assistant": {
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic"
},
"system_messages": {
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": 1,
"unread_source": "business"
}
},
"modules": [
{
"code": "organization",
"enabled": true,
"icon_file_id": "",
"name": "组织架构",
"permission": "enabled",
"route": "/organization",
"unread_count": 0
},
{
"code": "meetings",
"enabled": true,
"icon_file_id": "",
"name": "会议通知",
"permission": "enabled",
"route": "/meetings",
"unread_count": 3
}
],
"workbench_version": 1787153039,
"system_topic": {
"topic": "sys",
"type": "system"
},
"message_policy": {
"edit_window_seconds": 300,
"recall_window_seconds": 120,
"supported_actions": ["copy", "quote", "delete", "recall", "edit"]
},
"security": {
"realname": {
"allow_skip": true,
"remind_after": "24h",
"required": false,
"status": "unverified"
}
}
},
"request_id": "req_xxx",
"server_time": "2026-08-20T12:00:00.000Z"
}
```
## 12. 前端处理规则
1. bootstrap 成功后,先渲染固定入口和初始 badge。
2. 随后完成 WebSocket `sub me`,用 Topic `seq/read` 覆盖 Topic 类入口未读。
3. `unread_source=topic` 的入口以后以 Topic 状态为准。
4. `unread_source=business` 的入口以后以业务接口刷新或系统事件为准。
5. 切租户、退出登录、切账号时必须清空本地入口和 badge 缓存。
6. 前端不得把 `tenant_id`、内部数据库 ID 或未在白名单中的 route 写入跳转。
## 13. 服务端实现要求
1. `serveIMBootstrap` 增加 `entry_badges` 和 `entries`。
2. `serveIMBootstrap` 的入口数据必须按当前 Token 的租户和用户权限裁剪。
3. `route` 必须走服务端白名单,不能从数据库任意透传外部 URL。
4. 新朋友待处理数按当前租户、当前用户、入站、待处理状态统计。
5. 工作台 `modules[].unread_count` 从业务统计或模块 unread provider 获取,不再固定为 0。
6. `workbench_total_unread` 只汇总当前用户可见模块。
7. 文件助手和系统消息的 `topic` 必须是当前租户和当前用户有权限访问的 Topic。
8. 如果某入口当前未实现,仍可返回 `enabled:false`,前端隐藏入口。
## 14. 后端建议实现顺序
1. 扩展 bootstrap 响应结构,先返回固定入口和 0 badge,确保前端结构稳定。
2. 增加新朋友待处理数统计。
3. 增加工作台模块 unread provider 和总未读。
4. 明确文件助手 Topic,补齐入口 topic 和未读来源。
5. 明确系统消息未读来源;若 `sys` 不适合普通用户订阅,则改为业务 unread。
6. 增加系统事件 `workbench.badge.updated` 和入口 badge 更新事件。
7. 更新前后端对接文档,把本需求中的响应示例合并到 bootstrap 章节。
## 15. 验收用例
1. 新用户登录调用 bootstrap,返回 `entries`、`entry_badges`、`modules`、`workbench_version`。
2. 有 2 条待处理好友申请时,`new_friends_pending_count=2`,`entries.new_friends.unread_count=2`。
3. 没有待处理好友申请时,新朋友入口仍展示但 unread 为 0。
4. 工作台有两个可见模块未读分别为 3 和 2 时,`workbench_total_unread=5`。
5. 隐藏模块未读不计入 `workbench_total_unread`。
6. `unread_source=topic` 的入口在 `sub me` 返回后可用 Topic `seq/read` 覆盖初始 unread。
7. 切换租户后 bootstrap 返回另一租户入口和 badge,本地旧 badge 不复用。
8. 普通用户构造外部 route 或系统消息字段不能影响 bootstrap 返回。
9. 未实现入口返回 `enabled:false` 时前端隐藏入口。
10. 接口响应字段保持向后兼容,旧客户端忽略新增字段仍可正常工作。
## 16. 待确认问题
1. 文件助手最终使用 `slf` Topic,还是新增专用文件助手 Topic。
2. 普通用户系统消息入口是否继续使用 `sys`,还是改为每用户系统通知 Topic 或纯 HTTP 通知列表。
3. 群聊入口是否需要业务未读,还是完全由群 Topic 会话列表承担。
4. 工作台每个模块的 unread provider 由 IM 直接查库,还是由 HotGo/业务库提供统计。
5. 是否需要新增统一 badge 增量事件,例如 `entry.badge.updated`。
@@ -0,0 +1,343 @@
# IM 个人/企业注册登录前后端对接说明
## 1. 目标
当前客户端没有独立的企业码进入页面,登录页和注册页由用户选择“个人用户”或“企业用户”。前端提交登录和注册时必须显式带类型,后端不能再通过企业码是否为空来推断个人/企业。
核心规则:
1. 前端新增字段 `registration_type`,类型为 int,取值只能是 `0` 或 `1`。
2. `registration_type=1` 时,后端按个人注册处理,内部租户使用 `default`,数据写入 `default` 租户业务库。
3. `registration_type=0` 时,前端必须提交企业码,后端必须校验企业存在、启用且允许注册。
4. 登录新增字段 `login_type`,类型为 int,取值同样是 `0` 企业、`1` 个人。
5. 企业登录或企业注册都不允许使用 `default`。
6. `default` 是系统保留的个人租户码,不允许作为企业码输入。
一句话规则:**注册类型由 `registration_type` 决定,登录类型由 `login_type` 决定;个人落 `default`,企业必须校验企业码。**
## 2. 前端注册字段
注册页提交发码和最终注册时都带同一组注册上下文字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `registration_type` | int | 是 | `0` 表示企业注册,`1` 表示个人注册 |
| `tenant_code` | string | 企业注册必填 | 企业码;个人注册不填或传空字符串 |
前端规则:
| `registration_type` | `tenant_code` | 后端处理 |
| --- | --- | --- |
| `1` | 空 | 允许提交,后端内部使用 `default` |
| `1` | 非空 | 后端拒绝,避免误入库 |
| `0` | 空 | 后端拒绝,提示请输入企业码 |
| `0` | `default`、`Default`、`DEFAULT` | 后端拒绝 |
| `0` | 其他值 | 后端在注册链路中校验 |
用户在企业注册模式输入 `default` 时,建议提示:
```text
default 是系统保留企业码。个人注册请选择个人用户,企业注册请输入企业提供的企业码。
```
## 3. 企业码校验
不新增单独的企业码校验接口,也不要求前端在注册前调用 `/v1/im/tenants/resolve`。
后端在注册发码和最终注册链路中校验:
1. `registration_type=1`
- 不要求企业码。
- 即使请求 Header 或内部路由使用 `default`,也属于服务端内部租户,不是用户输入企业码。
- 创建用户时写入 `default` 租户业务库。
2. `registration_type=0`
- `tenant_code` 必填。
- `tenant_code` 大小写不敏感等于 `default` 时拒绝。
- 查询企业是否存在、启用、允许注册。
- 通过后在对应企业租户业务库创建用户。
企业不存在、停用或不允许注册时,客户端统一提示:
```text
企业不存在或不可用
```
客户端不要区分不存在、停用、禁用注册等内部原因。
`/v1/im/tenants/resolve` 只保留给服务发现、品牌配置、历史企业入口等场景使用;当前注册页不依赖它完成主流程。
## 4. 前端登录字段
登录页提交 WebSocket `{login}` 时带登录上下文字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `login_type` | int | basic 登录必填 | `0` 表示企业登录,`1` 表示个人登录 |
| `tenant_code` | string | 企业登录必填 | 企业码;个人登录不填、传空字符串或内部传 `default` |
登录规则:
| `login_type` | `tenant_code` / `hi.tenant` | 后端处理 |
| --- | --- | --- |
| `1` | `default` | 个人登录,允许 |
| `1` | 非 `default` | 拒绝,避免个人登录误进企业租户 |
| `0` | 空 | 拒绝,提示请输入企业码 |
| `0` | `default`、`Default`、`DEFAULT` | 拒绝,统一提示“企业不存在或不可用” |
| `0` | 其他值 | 企业登录,后端按该租户认证 |
企业用户如果在企业入口输入 `default`,后端返回策略错误,错误参数为 `what=tenant_code`、`reason=TENANT_CODE_FORBIDDEN`;客户端统一展示:
```text
企业不存在或不可用
```
个人登录 `{login}` 示例:
```json
{
"login": {
"id": "login-1",
"scheme": "basic",
"login_type": 1,
"tenant_code": "",
"secret": "base64(account:password)"
}
}
```
企业登录 `{login}` 示例:
```json
{
"login": {
"id": "login-1",
"scheme": "basic",
"login_type": 0,
"tenant_code": "acme",
"secret": "base64(account:password)"
}
}
```
## 5. 个人注册流程
个人注册必须显式提交 `registration_type=1`。
1. 用户选择个人注册。
2. 前端提交 `registration_type=1`,`tenant_code` 为空。
3. 注册发码、验证码校验、WebSocket `hi`、`acc` 注册链路使用服务端内部租户 `default`。
4. 服务端将用户创建到 `default` 租户对应的业务库。
发码示例:
```http
POST /v1/im/auth/verification-codes
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Tenant-Code: default
X-Request-ID: <uuid>
```
```json
{
"scene": "register",
"registration_type": 1,
"tenant_code": "",
"phone": "19122421333"
}
```
WebSocket 握手示例:
```json
{
"hi": {
"id": "hi-1",
"ver": "0.25",
"ua": "ios/1.0.0",
"platf": "ios",
"lang": "zh-CN",
"tenant": "default"
}
}
```
最终注册 `{acc}` 示例:
```json
{
"acc": {
"id": "acc-1",
"user": "new",
"scheme": "basic",
"registration_type": 1,
"tenant_code": "",
"username": "henry",
"phone": "19122421333",
"secret": "base64(henry:password)",
"login": true,
"cred": [
{
"meth": "tel",
"val": "19122421333",
"resp": "verification_token",
"params": {"challenge_id": "challenge-id"}
}
]
}
}
```
## 6. 企业注册流程
企业注册必须显式提交 `registration_type=0`。
1. 用户选择企业注册。
2. 用户填写企业码,例如 `acme`。
3. 前端直接提交用户输入的企业码,不做企业码存在性或保留字拦截。
4. 获取验证码、提交注册时都带 `registration_type=0` 和 `tenant_code=acme`。
5. 后端在注册链路中校验企业是否存在且可用。
6. 校验通过后,服务端在该企业租户业务库中创建用户;校验不通过时返回“企业不存在或不可用”。
注册发码请求:
```http
POST /v1/im/auth/verification-codes
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Tenant-Code: acme
X-Request-ID: <uuid>
```
```json
{
"scene": "register",
"registration_type": 0,
"tenant_code": "acme",
"phone": "19122421333"
}
```
WebSocket `hi`:
```json
{
"hi": {
"id": "hi-1",
"ver": "0.25",
"ua": "android/1.0.0",
"platf": "android",
"lang": "zh-CN",
"tenant": "acme"
}
}
```
最终注册 `{acc}` 示例:
```json
{
"acc": {
"id": "acc-1",
"user": "new",
"scheme": "basic",
"registration_type": 0,
"tenant_code": "acme",
"username": "henry",
"phone": "19122421333",
"secret": "base64(henry:password)",
"login": true,
"cred": [
{
"meth": "tel",
"val": "19122421333",
"resp": "verification_token",
"params": {"challenge_id": "challenge-id"}
}
]
}
}
```
## 7. 后端校验规则
后端负责注册链路的最终校验,前端不做企业码拦截。
1. `registration_type` 缺失或不是 `0|1`
- 拒绝注册。
- 返回 `MALFORMED_REQUEST`,提示客户端升级或检查参数。
2. `registration_type=1`
- 不从空企业码推断,而是由该字段明确进入个人注册。
- 内部租户码设置为 `default`。
- 拒绝用户提交的非空 `tenant_code`,避免误入库。
- 注册时写入 `default` 租户业务库。
3. `registration_type=0`
- `tenant_code` 必填。
- 先 trim 首尾空格。
- 如果大小写不敏感等于 `default`,拒绝注册。
- 否则查询租户是否存在、是否启用、是否允许注册。
- 校验通过后绑定对应租户。
建议错误:
```json
{
"code": 30000,
"reason": "MALFORMED_REQUEST",
"message": "invalid registration_type or tenant_code"
}
```
企业不可用时,客户端展示:
```text
企业不存在或不可用
```
## 8. 缓存和本地数据隔离
客户端本地缓存、Token、会话、草稿、附件缓存必须继续按 `tenant_code + user_id` 隔离。
个人注册用户登录后,缓存键中的 `tenant_code` 使用 `default`。企业用户登录后,使用对应企业码。切换个人/企业身份时必须清理当前连接并重新 `hi`,不能在同一个 WebSocket 中切换租户。
## 9. 验收用例
| 场景 | 预期 |
| --- | --- |
| `login_type=1`,`hi.tenant=default` | 个人登录成功进入 `default` 租户认证 |
| `login_type=1`,`hi.tenant=acme` | 拒绝登录 |
| `login_type=0`,企业码空 | 拒绝登录,提示请输入企业码 |
| `login_type=0`,企业码为 `default` | 拒绝登录,提示企业不存在或不可用 |
| `login_type=0`,企业码有效 | 在该企业租户认证 |
| `registration_type=1`,企业码空,手机号注册 | 成功进入 `default` 租户注册链路 |
| `registration_type` 缺失 | 拒绝注册,返回参数错误 |
| `registration_type=1`,企业码非空 | 拒绝或忽略,建议拒绝,避免误入库 |
| `registration_type=0`,企业码空 | 拒绝注册,提示请输入企业码 |
| `registration_type=0`,企业码为 `default` | 拒绝注册 |
| `registration_type=0`,企业码不存在 | 发码或注册失败,提示企业不存在或不可用 |
| `registration_type=0`,企业码已停用 | 统一提示企业不存在或不可用 |
| `registration_type=0`,企业码有效 | 注册写入该企业租户库 |
| 个人用户注册成功后登录 | `tenant_code=default`,可正常登录、收发消息 |
## 10. 对客户端的明确要求
1. 登录页和注册页都必须明确区分个人和企业。
2. basic 登录必须提交 `login_type`。
3. 发码和最终注册都必须提交 `registration_type`。
4. 个人登录/注册提交类型 `1`,不要让用户输入或看到 `default`。
5. 企业登录/注册提交类型 `0` 和用户输入的企业码。
6. 企业用户手动输入 `default` 也直接提交,由后端返回错误,客户端按后端错误展示提示。
7. 不需要为了注册主流程调用 `/v1/im/tenants/resolve`。
## 11. 对后端的明确要求
1. basic 登录链路必须以 `login_type` 作为个人/企业登录的判定来源。
2. 注册链路必须以 `registration_type` 作为个人/企业注册的判定来源。
3. 不允许仅根据企业码是否为空推断个人注册或个人登录。
4. 登录、注册、发码、验证码校验、WebSocket `hi/login/acc` 链路保持同一租户上下文。
5. 显式企业码 `default` 必须拒绝,防止用户绕过企业码校验。
6. 企业登录/注册的企业码必须校验存在、启用、允许登录/注册。
7. 日志中要能看出来源是 `personal_default` 还是 `enterprise_code`,方便排查误入库问题。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,852 @@
# IM 产品需求服务端改造分析报告
> 分析日期:2026-08-09
> 分析对象:`chat-0.25.3` 当前工作区源码
> 需求依据:[IM-产品原型需求文档](./IM-产品原型需求文档.md)
> 报告范围:服务端能力、数据模型、协议/API、安全、实施计划和验收建议
## 1. 报告结论
当前项目是一个以 Tinode 为核心的 Go 即时通讯服务端,已经具备稳定的实时通信底座:账号登录、单聊、群聊、订阅与访问控制、消息收发、已读/送达回执、输入状态、消息删除、文件上传、推送、点对点 WebRTC 信令,以及可扩展的 gRPC 插件机制。
原型中的基础聊天能力不需要推倒重做,建议继续复用现有 Topic、Subscription、Message 和 Session 模型。在此基础上增加产品业务层,负责好友关系、群治理、内容检索、企业组织、会议/日报/月报、实名认证、青少年模式、举报与安全审计等业务。
本次分析得到以下关键结论:
1. **现有服务端可以承担实时通信内核,但不能直接满足完整产品需求。** 当前代码主要解决消息路由、会话和 ACL,不包含完整的社交关系、企业协同和安全治理领域模型。
2. **应将“实时消息能力”和“产品业务能力”分层。** 聊天消息继续走 WebSocket;需要事务、查询、审批和管理的业务走 HTTP API;状态变化通过结构化系统消息回推 WebSocket。
3. **必须先确定企业租户模式。** 当前 User、Topic、Subscription、Message 和 FileDef 均没有 `tenant_id`。如果多个企业共用一套数据库,租户隔离必须作为所有功能之前的基础改造。
4. **消息幂等和文件授权是上线前必须修复的基础问题。** 发布消息处仍有“重复消息 ID 检查”的 TODO;文件下载只校验登录身份,没有验证用户是否有权访问对应消息或话题。
5. **仓库部分旧文档与当前源码存在漂移。** 旧文档提到的 `x-im-*` 扩展、`im_*` 业务表、数据库版本 119 和 `tools/wsmock`,在当前源码中均未发现。本报告分析时各数据库适配器版本为 116;多租户第一阶段已把 MySQL 提升到 118,其他适配器仍为 116。本报告以可执行源码为准,不把文档设想视为已实现能力。
建议按“基础安全与协议 → 好友和群治理 → 搜索与文件 → 通话与设置 → 企业工作台”的顺序实施。若团队和测试资源稳定,完整服务端改造建议拆成 4 个产品阶段、6 至 9 个迭代交付。
## 2. 分析范围与判定口径
### 2.1 需求范围
本报告覆盖原型文档的 11 个功能域:
1. 账号注册、登录与身份安全;
2. 消息首页、全局导航与系统通知;
3. 单聊会话、会话建立与消息状态;
4. 消息输入、媒体内容与消息操作;
5. 通讯录、好友关系与黑名单;
6. 群聊创建、资料维护与成员治理;
7. 文件下载、收藏管理与聊天记录检索;
8. 实时语音与视频通话;
9. 企业工作台与办公协同;
10. 个人资料、通用设置、隐私与青少年模式;
11. 反诈安全、举报与意见反馈。
### 2.2 能力分类
文中的需求按以下四类判定:
| 分类 | 含义 | 处理方式 |
| --- | --- | --- |
| 已支持 | 当前代码已有可用服务端能力 | 直接复用并补验收测试 |
| 扩展支持 | 现有核心模型可承载,但缺少业务字段、校验或接口 | 在现有链路中增加明确的领域逻辑 |
| 全新建设 | 当前没有对应模型和服务 | 新增业务模块、表、API 和通知桥接 |
| 客户端为主 | 服务端只需提供数据或开关,交互由 App 完成 | 避免在服务端重复实现 UI 状态 |
### 2.3 源码核查范围
重点核查了以下目录与文件:
- `server/session.go`:客户端消息分发、发布、订阅和元数据处理;
- `server/topic.go`:Topic 生命周期、ACL、消息广播、回执和元数据读写;
- `server/datamodel.go`:WebSocket 客户端/服务端协议模型;
- `server/calls.go`:WebRTC 呼叫信令;
- `server/hdl_files.go`:文件上传与下载;
- `server/plugins.go`:gRPC 插件和消息拦截;
- `server/store/store.go`、`server/store/types/types.go`:存储接口和核心实体;
- `server/db/mysql/adapter.go`、`server/db/mysql/schema.sql`:当前 MySQL 实现;
- `server/auth`、`server/validate`、`server/push`:认证、验证码和推送;
- 根目录与 `docs` 目录的 Markdown 文档。
## 3. 当前服务端真实能力
### 3.1 核心架构
当前服务端采用“Session 接收协议包 → Hub/Topic 路由 → Store 持久化 → Topic 广播/Push”的模型。
```mermaid
flowchart LR
C["移动端 / Web 客户端"] -->|"WebSocket / Long Poll / gRPC"| S["Session"]
S --> P["Plugin Firehose"]
P --> H["Hub"]
H --> T["Topic Actor"]
T --> ST["Store Adapter"]
ST --> DB["MySQL / PostgreSQL / MongoDB / RethinkDB"]
T --> PU["Push Handlers"]
T --> C
C -->|"HTTP"| F["File Handler"]
F --> ST
```
Topic 以串行事件循环处理同一话题内的状态和消息,适合维持顺序号、订阅状态和广播一致性。扩展时不应在 Topic 事件循环中执行耗时的远程 RPC、全文搜索或复杂审批事务,否则会阻塞该话题的全部消息。
### 3.2 已有能力清单
| 能力 | 当前实现 | 主要源码位置 | 复用判断 |
| --- | --- | --- | --- |
| 账号创建与登录 | `acc`、`login` 协议;basic、token、REST 等认证方式 | `server/session.go`、`server/auth` | 已支持基础能力 |
| 手机验证码 | 电话号码校验、验证码、重试控制,可配置 Twilio | `server/validate/tel/validate.go` | 可用于注册/找回密码,需补产品流程 |
| 单聊与群聊 | P2P、group、channel Topic | `server/topic.go` | 已支持 |
| 会话成员与 ACL | `ModeWant`、`ModeGiven`,包含 Join、Read、Write、Admin、Owner 等权限 | `server/topic.go`、`server/store/types` | 已支持底层权限 |
| 消息收发 | 消息持久化、Topic 内递增序号、广播、离线推送 | `server/topic.go:997`、`server/store/store.go` | 已支持 |
| 已读/送达/输入中 | `note` 的 `read`、`recv`、`kp` 等事件 | `server/datamodel.go:309`、`server/topic.go:1137` | 已支持 |
| 消息扩展内容 | `head` 和 `content` 为通用 JSON;支持 Drafty | `server/datamodel.go:265`、`server/store/types/types.go:1213` | 能承载,缺少产品级规范与校验 |
| 用户/群资料扩展 | Public、Trusted、Private、Aux、Tags | `server/datamodel.go`、`server/topic.go:2202` | 能承载少量扩展信息 |
| 收藏话题 | 存在 `slf` Topic 类别 | `server/topic.go`、`server/store/types` | 可复用,但缺少完整收藏模型与检索 |
| 文件 | HTTP 上传、元数据保存、附件与消息关联 | `server/hdl_files.go`、`server/store/store.go` | 基础可用,下载授权需修复 |
| 音视频信令 | 邀请、接听、Offer、Answer、ICE、挂断;单聊中维护一个当前呼叫 | `server/calls.go` | 可复用,需产品化扩展 |
| 推送 | 可插拔 Push Handler,包含消息和通话字段 | `server/push.go`、`server/push` | 已支持基础能力 |
| 外部风控/审核 | gRPC 插件可继续、丢弃、替换或直接响应协议包 | `server/plugins.go:353` | 适合接外部审核,不替代内部授权 |
### 3.3 当前核心数据模型
当前模型聚焦通信内核:
| 实体 | 关键字段 | 缺少的产品概念 |
| --- | --- | --- |
| User | 状态、访问权限、Public、Trusted、Tags、设备 | 企业、部门、好友关系、实名认证状态、青少年策略 |
| Topic | Owner、默认 ACL、Public、Trusted、Tags、Aux | 入群策略、群公告、禁言规则、群二维码、企业归属 |
| Subscription | Topic、User、ModeWant、ModeGiven、Private | 备注、置顶、通知策略、单独禁言、好友状态 |
| Message | Topic、SeqId、From、Head、Content | 客户端幂等 ID、消息类型索引、全文检索字段、审核状态 |
| FileDef | 文件位置、状态、上传用户等 | 资源级访问策略、业务用途、病毒扫描状态 |
MySQL 当前消息表只对 `(topic, seqid)` 建唯一索引,没有消息类型索引、客户端消息 ID 唯一索引或全文检索索引。分析基线中各适配器均为 schema 116;当前 MySQL 已因租户主表升级到 118,PostgreSQL、MongoDB、RethinkDB 仍为 116。
### 3.4 当前主要缺口
1. `MsgGetQuery` 只支持 desc、sub、data、del,消息查询只有序号范围和数量,没有关键词、媒体类型、发送人、时间区间等检索条件。
2. 发布消息时没有客户端消息幂等校验,`server/session.go:691` 仍保留重复消息 ID 检查 TODO。
3. 当前群成员模型能表达 ACL,但不能完整表达申请加入、审批记录、过期状态和审批理由。直接用 `ModeGiven` 表示待审批会与当前订阅逻辑冲突。
4. 当前群“静音”更接近通知/Presence 控制,不等同于禁止指定成员发言;服务端缺少群禁言策略。
5. 文件下载只校验 API Key 和登录用户,没有根据文件关联消息、Topic ACL、发送者或接收者判断访问权限。
6. WebRTC 只维护 P2P Topic 的一个当前呼叫,缺少稳定的业务 `call_id`、跨设备抢答、通话详单、失败原因规范和群通话能力。
7. 没有企业、组织、部门、会议、日报、月报、信箱等领域模型。
8. 没有实名认证审批、反诈策略、举报工单、反馈工单、青少年模式的服务端强制规则。
9. 没有租户字段;共享数据库的企业 SaaS 部署会有数据越界风险。
## 4. 总体改造原则
### 4.1 保留通信内核,新增产品业务层
不建议将会议、日报、实名认证等业务硬塞进 Topic 的 Public/Aux JSON。Aux 适合兼容性元数据,不适合作为需要索引、唯一约束、状态机、审批记录和审计日志的主数据。
推荐分为四层:
1. **Tinode 实时内核**:Session、Hub、Topic、ACL、Message、Push、WebRTC 信令;
2. **IM 产品策略层**:结构化消息解析、发消息前鉴权、好友/黑名单/群禁言/青少年模式校验;
3. **业务服务层**:好友申请、群审批、组织、会议、报告、实名认证、举报等 HTTP API;
4. **业务存储与事件层**:业务表、事务、Outbox、审计日志、搜索索引。
### 4.2 目标架构
```mermaid
flowchart TB
APP["iOS / Android / Web"]
subgraph Gateway["同一 Go 服务进程或同域网关"]
WS["现有实时协议入口"]
API["新增 /v1/im/* 业务 API"]
FILE["文件上传/下载"]
end
subgraph Core["通信与产品策略"]
SESSION["Session / Hub / Topic"]
POLICY["IM Policy Engine"]
CALL["Call Signaling"]
NOTIFY["Notification Bridge"]
end
subgraph Business["业务模块"]
REL["好友与黑名单"]
GROUP["群治理"]
ORG["组织与工作台"]
SAFETY["实名 / 青少年 / 举报 / 风控"]
SEARCH["消息与文件索引"]
end
subgraph Infra["存储与外部系统"]
DB["核心表 + im_* 业务表"]
OUTBOX["Outbox / 异步任务"]
MOD["外部审核与反诈服务"]
PUSH["Push / SMS / 邮件"]
MEDIA["对象存储 / 转码 / 病毒扫描"]
end
APP --> WS
APP --> API
APP --> FILE
WS --> SESSION
SESSION --> POLICY
POLICY --> DB
API --> Business
Business --> DB
Business --> OUTBOX
OUTBOX --> NOTIFY
NOTIFY --> SESSION
SESSION --> CALL
POLICY --> MOD
FILE --> MEDIA
SESSION --> PUSH
```
### 4.3 WebSocket 与 HTTP 的职责边界
| 场景 | 推荐通道 | 原因 |
| --- | --- | --- |
| 发消息、回执、输入中、在线事件、通话信令 | 现有 WebSocket | 低延迟、已有 Topic 路由和广播 |
| 好友申请/审批、群申请/审批 | HTTP 写操作 + WS 通知 | 需要幂等、状态机、审计和列表查询 |
| 组织架构、日报/月报、会议管理 | HTTP API | 强查询和事务型业务,不应阻塞 Topic |
| 消息关键词/类型搜索 | HTTP 或扩展后的 `get` | 需要独立索引、分页游标和查询权限 |
| 实名认证、举报、反馈、版本配置 | HTTP API | 涉及附件、敏感字段、工单和后台管理 |
| 文件上传/下载 | HTTP | 沿用当前链路,但补资源级授权 |
| 业务状态变化提醒 | 结构化系统消息/系统 Topic | 复用实时和推送能力 |
## 5. 11 类原型需求的服务端适配方案
### 5.1 账号注册、登录与身份安全
| 原型能力 | 现状 | 改造方案 | 优先级 |
| --- | --- | --- | --- |
| 企业码登录 | 无企业模型 | 新增企业码解析接口;企业码映射到企业、部署地址和登录策略 | P0 |
| 手机号/密码登录 | 认证框架已有 | 固化账号标识规范、失败次数、锁定时长、设备记录和安全审计 | P0 |
| 注册和验证码倒计时 | 电话验证器已有 | 新增注册编排接口;倒计时由客户端显示,服务端返回 `retry_after`、过期时间和重试上限 | P0 |
| 忘记密码 | 有认证与验证器,无完整流程 | 增加发送验证码、校验票据、重置密码三个阶段;票据一次性、短时有效 | P0 |
| 设置独立密码 | 可更新账号,但无产品流程 | 增加二次验证和密码策略;写审计日志并撤销旧 Token | P1 |
| 实名认证 | 无 | 新建实名申请、状态查询、审核回调;敏感字段加密,客户端仅返回脱敏结果 | P1 |
企业码有两种实现方式:
- **推荐第一阶段:一企一实例/一企一库。** 企业码只负责发现该企业的服务地址和认证方式。现有核心表无需立即加入 `tenant_id`,隔离边界清晰。
- **共享 SaaS:多企业共用服务和数据库。** 必须先完成第 6 节的全链路租户隔离,再开放企业码登录。
建议新增接口:
```text
POST /v1/im/auth/enterprise/resolve
POST /v1/im/auth/sms/send
POST /v1/im/auth/password/reset-ticket
POST /v1/im/auth/password/reset
POST /v1/im/identity/verifications
GET /v1/im/identity/verifications/current
```
不要把身份证号、证件照片地址写入 User.Public、User.Trusted 或消息内容。实名数据应单独加密存储,并限制后台角色、访问理由和留痕。
### 5.2 消息首页、全局导航与系统通知
| 原型能力 | 现状 | 改造方案 | 优先级 |
| --- | --- | --- | --- |
| 会话列表 | Subscription 已有未读、时间等信息 | 扩展每用户会话设置:置顶、免打扰、草稿、隐藏;草稿可优先本地保存 | P1 |
| 离线/重连 | Session 支持连接和队列 | 客户端指数退避;服务端增加连接状态指标、消息同步游标和限流 | P0 |
| 长按置顶/删除/免打扰 | 部分可由 Private/订阅状态承载 | 明确 `im_conversation_settings`,避免 Private JSON 无法查询和约束 | P1 |
| 系统消息 | 可创建系统 Topic/系统账号 | 建立系统消息类型字典、模板版本、跳转参数和去重键 | P1 |
系统消息应使用正常的 Message 存储和 Push 链路,而不是只发临时 `info` 包。这样用户离线后仍可拉取、已读和跨设备同步。
### 5.3 单聊会话、会话建立与消息状态
| 原型能力 | 现状 | 改造方案 | 优先级 |
| --- | --- | --- | --- |
| 单聊建立 | P2P Topic 已支持 | 在创建/订阅前检查好友、黑名单和隐私策略 | P0 |
| 在线、输入中 | Presence、`kp` 已支持 | 统一客户端节流和过期时间;按隐私设置决定是否透出 | P1 |
| 已发送、已送达、已读 | Topic 序号和 `recv/read` 已支持 | 定义产品状态映射;群聊展示规则单独定义 | P0 |
| 拉黑后限制沟通 | 无完整关系表 | 关系表作为真源;发布、建会话、呼叫、文件访问均执行同一策略 | P0 |
| 删除消息/撤回 | 有消息删除能力 | 区分“仅自己删除”和“全员撤回”;增加撤回时限、操作者和审计事件 | P1 |
| 名片消息 | 通用 JSON 可承载 | 定义 `contact_card` 类型,只保存稳定用户 ID 和展示快照,打开时重新鉴权 | P1 |
“拉黑”不能只修改客户端按钮或 Topic 私有字段。策略层至少要拦截:新建单聊、发送消息、发送好友请求、音视频呼叫、读取对方敏感资料。
### 5.4 消息输入、媒体内容与消息操作
当前 `head`、`content` 接受通用 JSON,技术上可以传文本、图片、语音、视频、文件、名片、合并转发等内容,但服务端没有类型白名单、字段校验、幂等、审核和索引。建议增加明确的消息信封:
```json
{
"pub": {
"id": "client-request-id",
"topic": "grp...",
"head": {
"x-im-schema": 1,
"x-im-type": "image",
"x-im-client-mid": "01J...",
"x-im-reply-to": 128
},
"content": {
"file_id": "f...",
"width": 1080,
"height": 1440,
"thumbnail_file_id": "f..."
}
}
}
```
服务端改造要求:
1. 在 `server/datamodel.go` 定义扩展字段常量和允许类型,不再由各客户端自由命名。
2. 在 `server/session.go` 发布入口解析 `x-im-client-mid`,按“租户 + 发送者 + client_mid”去重;重复请求返回第一次成功的 Topic/SeqId。
3. 在 `server/topic.go` 持久化前执行类型、大小、引用消息、附件所有权、好友/黑名单、禁言、青少年和风控策略。
4. 文本继续使用 Drafty;图片、视频、语音、文件只在消息里保存 `file_id` 和必要元数据,不信任客户端提交的任意下载 URL。
5. `@成员` 保存稳定用户 ID 列表;服务端校验被提醒者确实是当前群成员,并生成定向推送。
6. 合并转发生成不可变快照,保留原作者展示名、时间和内容类型,但不得泄露原会话 ID 或无权访问的附件。
7. 多选、键盘、表情面板、录音进度、上传进度属于客户端交互;服务端只负责资源、消息和失败状态。
推荐消息类型首批固定为:
```text
text, image, voice, video, file, contact_card,
location, merged_forward, system_notice, call_record
```
转发应区分两种语义:
- **逐条转发**:为每条内容创建新消息,原附件通过授权后重新建立引用;
- **合并转发**:创建一个 `merged_forward` 快照,子项限制数量和总大小,服务端清理敏感字段。
### 5.5 通讯录、好友关系与黑名单
当前 Subscription 表示“用户订阅了哪个 Topic”,不能替代双边好友关系和申请状态机。建议新增:
- `im_user_relations`:双方关系、备注、黑名单、免打扰等当前状态;
- `im_friend_requests`:申请、处理人、验证信息、状态、过期时间和幂等键;
- `im_privacy_settings`:允许通过手机号/ID/二维码查找、加好友验证方式等。
好友流程:
```mermaid
stateDiagram-v2
[*] --> Pending: 发起申请
Pending --> Accepted: 接受
Pending --> Rejected: 拒绝
Pending --> Cancelled: 发起人撤回
Pending --> Expired: 超时
Accepted --> Deleted: 任一方删除
Accepted --> Blocked: 任一方拉黑
Blocked --> Accepted: 取消拉黑且关系仍保留
```
实现要点:
1. 精准用户 ID 搜索必须受隐私设置、租户和账号状态限制;手机号搜索应使用规范化哈希进行匹配,禁止模糊枚举。
2. 接受好友申请和建立双方关系必须在同一数据库事务内完成,并通过 Outbox 异步创建/激活 P2P Topic、发送系统通知。
3. 删除好友是否删除 P2P 历史需产品确认。建议只解除关系,不物理删除历史;双方分别控制会话是否可见。
4. 拉黑属于单方策略,服务端立即生效。是否同时解除好友关系应作为明确产品规则,不由客户端猜测。
5. 关系变化必须使 ACL/策略缓存失效,避免短时间内继续发消息。
建议接口:
```text
GET /v1/im/contacts
GET /v1/im/users/exact-search?id=...
POST /v1/im/friend-requests
GET /v1/im/friend-requests
POST /v1/im/friend-requests/{id}/accept
POST /v1/im/friend-requests/{id}/reject
DELETE /v1/im/contacts/{user_id}
PUT /v1/im/contacts/{user_id}/block
DELETE /v1/im/contacts/{user_id}/block
GET /v1/im/blocks
```
### 5.6 群聊创建、资料维护与成员治理
现有群 Topic、Owner/Admin ACL 和所有者转移可以复用。需要新增群业务规则和审批数据。
| 原型能力 | 可复用基础 | 新增服务端能力 |
| --- | --- | --- |
| 创建群、群资料 | Group Topic、Public/Trusted/Aux | 资料字段规范、头像资源校验、敏感词审核 |
| 加入群聊 | Subscription/Join ACL | 入群方式、申请表、审批状态机、人数和频率限制 |
| 邀请进群 | Admin/Share ACL | 邀请记录、邀请权限、被邀请人确认、批量幂等 |
| 群公告 | 消息可承载 | 公告主表、版本、置顶、已读回执、发布权限 |
| 管理员 | Admin ACL | 管理员人数上限、操作审计、角色显示 |
| 禁言成员 | 无对应强制策略 | 单人/全员禁言表和发布前校验,管理员豁免规则 |
| 转让群主 | 已有 Owner 转移逻辑 | 二次确认、风险校验、审计和通知 |
| 群二维码 | 无 | 签名短 Token、有效期、次数限制、撤销和入群策略 |
| 群文件 | 文件与消息已有关联 | 群文件索引、上传者/类型/时间筛选、删除权限 |
当前订阅代码在新订阅者没有 Join 权限时会拒绝加入,因此不能仅用“`ModeGiven` 不含 Join”来保存待审批申请。正确做法是先写 `im_group_join_requests`,审批通过后再创建或更新 Subscription。
群禁言必须在消息持久化前检查,建议规则优先级为:
```text
账号停用 > 黑名单/关系限制 > 全员禁言 > 单人禁言 > Topic Write ACL > 允许发送
```
群二维码只应包含服务端生成的短 Token,不应直接暴露可永久加入的 Topic ID。Token 至少绑定群、创建人、过期时间、最大使用次数和当前入群策略,并使用服务端密钥签名或只保存其哈希。
### 5.7 文件下载、收藏管理与聊天记录检索
#### 文件访问安全
这是当前代码中最明确的安全缺口。`server/hdl_files.go` 的下载流程验证 API Key 和已登录用户后即可读取 URL 对应文件,没有验证该用户是否是上传者、是否属于关联消息的 Topic、该消息是否已撤回,或文件是否为公开资源。
建议新增统一的 `FileCanAccess(uid, fileID, action)`:
1. 查询 FileDef 和 file-message links;
2. 上传未完成的文件只允许上传者访问;
3. 已关联消息的文件要求用户对至少一个关联 Topic 具有 Read 权限;
4. 群文件删除要求上传者、群管理员或群主权限;
5. 转发附件时重新建立授权关联,不暴露底层存储永久地址;
6. 使用短时签名下载 URL,记录下载审计;
7. 对上传文件执行 MIME 嗅探、扩展名一致性、病毒扫描、图片/视频解码安全检查。
#### 收藏
`slf` Topic 可以继续承担“收藏内容跨设备同步”的消息容器,但建议增加 `im_favorites` 索引表,保存源消息、收藏消息、类型、创建时间和已清理的展示摘要。源消息删除后,收藏快照是否保留需按合规策略处理。
#### 聊天记录检索
当前 `MessageGetAll` 仅支持 Topic、序号范围和数量,不支持关键词或类型。建议新增 `im_message_index`:
| 字段 | 用途 |
| --- | --- |
| tenant_id / topic / seq_id | 权限过滤和定位原消息 |
| sender_id / message_type | 发送人和类型筛选 |
| sent_at | 时间范围和排序 |
| searchable_text | 文本、文件名、链接标题等可搜索文本 |
| file_id / mime_group | 文件、图片、视频分类 |
| deleted / moderation_state | 排除已删除或不可见内容 |
若仅支持 MySQL,可使用 InnoDB FULLTEXT 配合 `ngram` 分词;若需要更强中文检索、跨话题检索和高数据量,建议使用 OpenSearch/Elasticsearch。无论使用哪种索引,查询结果都必须再次以当前 Topic Read ACL、消息删除范围和租户进行过滤,不能把搜索索引当作授权来源。
需要提供全量回填任务和增量 Outbox 消费者,避免上线后只能搜索新消息。
### 5.8 实时语音与视频通话
现有 `server/calls.go` 已支持 P2P 呼叫的邀请、接听、Offer、Answer、ICE Candidate 和挂断,可作为首期基础。改造建议:
1. 为每次呼叫生成不可复用的 `call_id`,不要只用 Topic 和消息 SeqId 表示一次通话;
2. 增加 `voice/video` 类型、主叫/被叫设备、创建/响铃/接听/结束时间、结束方、标准结束原因;
3. 增加跨设备抢答:一台设备接听后,其他设备收到 `answered_elsewhere`;
4. 呼叫前执行好友、黑名单、隐私、账号状态和青少年策略;
5. 呼叫超时、拒接、忙线、取消分别生成一致的通话记录消息和推送;
6. 配置可用的 STUN/TURN,监控 ICE 成功率和呼叫建立耗时;
7. 麦克风、扬声器、摄像头开关和悬浮窗主要由客户端管理,服务端只同步必要的媒体状态事件;
8. 群音视频不应扩展当前单个 `videoCall` 结构硬做,建议另建会议房间并接入 SFU。
### 5.9 企业工作台与办公协同
该部分属于全新业务域,当前服务端没有组织、部门、会议、日报、月报或信箱代码。建议它们作为独立业务模块,通过 Tinode 系统消息发送提醒,而不是修改聊天核心协议承载所有 CRUD。
#### 组织架构
- `im_org_units`:企业、部门、层级、排序、负责人、状态;
- `im_org_members`:用户、部门、职位、主部门、在职状态和可见范围;
- 支持树形查询、按姓名/部门搜索、部门成员分页;
- 组织同步应有版本号,客户端可增量刷新;
- 离职用户需在一个事务编排中禁用账号、退出企业群、撤销 Token 和处理数据归属。
#### 会议通知
- `im_meetings`:主题、时间、地点/会议链接、发起人、提醒策略、状态;
- `im_meeting_participants`:参与人、回复状态、回复时间;
- 创建/变更/取消写 Outbox,由通知桥接器向参与者系统 Topic 发结构化消息;
- 定时提醒由任务调度器执行,任务必须使用幂等键避免重复推送;
- 若未来增加在线视频会议,会议业务 ID 与通话房间 ID 分离。
#### 日报与月报
- 统一使用 `im_work_reports`,字段包含类型、周期、作者、部门、正文、附件、状态、提交时间;
- 管理员列表以权限范围过滤,不以客户端传入部门为准;
- 提交后是否可编辑、补交和撤回需形成明确状态机;
- 汇总统计异步生成,不在请求中扫描所有报告。
#### 总经理信箱
- 使用 `im_mailbox_messages`,支持实名/匿名展示策略、正文、附件、处理状态和回复;
- “匿名”只能对业务查看者隐藏,安全审计角色仍可在合法授权下追溯;
- 附件走统一私有文件授权;
- 每次查看和导出敏感信件都写审计日志。
### 5.10 个人资料、通用设置、隐私与青少年模式
| 能力 | 服务端责任 | 客户端责任 |
| --- | --- | --- |
| 昵称、生日、签名、头像 | 校验、敏感词、可见范围、版本和审计 | 编辑和展示 |
| 个人二维码 | 生成短时签名 Token、隐私校验 | 展示、扫码 |
| 清空缓存 | 无需删除服务端消息 | 清理本地缓存和下载文件 |
| 版本更新 | 返回平台、渠道、最低版本、推荐版本、下载地址和更新文案 | 下载、安装、商店跳转 |
| 隐私设置 | 作为服务端授权条件强制执行 | 提供开关界面 |
| 青少年模式 | 服务端保存状态、PIN 哈希、策略版本并强制限制 | UI 切换和本地内容隐藏 |
| 推送/相机/麦克风权限 | 保存推送 Token 和提醒偏好 | 调用系统权限 API |
青少年模式不能只是客户端开关,否则卸载重装或换设备即可绕过。建议 `im_teen_mode` 保存启用状态、监护 PIN 的强哈希、失败锁定、允许时段、功能策略版本和更新时间。服务端在登录、搜索、加好友、发消息、通话和工作台入口统一检查策略。
版本接口示例:
```text
GET /v1/im/config/app-version?platform=android&channel=official&version=1.4.0
```
响应需区分 `latest`、`recommended`、`minimum_supported` 和 `force_update`。客户端系统权限文案和弹窗不需要服务端逐页参与,但可以通过配置中心提供版本化文案。
### 5.11 反诈安全、举报与意见反馈
建议形成三层控制:
1. **同步硬规则**:账号状态、黑名单、限频、群禁言、文件大小/类型等,必须在服务端本地快速判断;
2. **内容与风险审核**:通过现有 gRPC Plugin Firehose 或独立审核服务检查文本、图片和链接;
3. **事后处置**:举报工单、证据固化、审核、处罚、申诉和审计。
现有插件在协议分发之前同步执行,严格审核会直接增加消息延迟。外部审核调用必须设置较短超时、熔断、指标和明确的 fail-open/fail-closed 策略。高风险场景可以先将消息标为 `pending_review`,审核通过后再广播;低风险场景可先发后审并支持撤回。
新增数据模型:
- `im_reports`:举报对象、原因、描述、提交人、状态、处理人、结论;
- `im_report_evidence`:消息快照、文件引用、校验哈希;
- `im_feedback`:意见类型、正文、联系方式、附件、处理状态;
- `im_risk_events`:命中规则、风险等级、处置、模型/规则版本;
- `im_audit_logs`:操作者、动作、对象、结果、IP、设备和关联请求 ID。
举报消息时不要只保存可变的 Topic/SeqId,应在用户仍有读取权限时由服务端固化必要证据快照和内容哈希。后台读取证据需要单独权限和完整审计。
## 6. 企业租户隔离决策
### 6.1 当前事实
当前 User、Topic、Subscription、Message 和 FileDef 没有 `tenant_id`。用户标签和 Topic 标签也没有企业隔离维度。仅在登录入口识别企业码,不能保证后续搜索、消息、文件和后台查询不会跨企业。
### 6.2 推荐方案:第一阶段一企一实例/一企一库
企业码网关返回企业专属服务地址。每个实例使用独立数据库、对象存储前缀、推送配置和审核配置。这种方案最贴合当前架构,改造量和误配风险最低,也便于企业级数据迁移与删除。
需要新增:
- 企业码目录服务;
- 企业到服务地址/认证策略的映射;
- 配置和密钥隔离;
- 统一运维、监控和版本发布能力。
### 6.3 共享 SaaS 的必要改造
如果明确要求多个企业共用一套服务和数据库,则以下工作必须先于产品功能:
1. 为核心表和所有业务表增加 `tenant_id`;
2. 所有唯一索引包含租户维度,例如用户登录标识、Topic、客户端消息 ID;
3. Session 完成认证后固定 tenant,禁止客户端包覆盖;
4. Topic 查找、用户查找、订阅、消息读写、文件访问和搜索全部强制带 tenant;
5. 内存缓存、Hub 路由键、集群路由和 Push 任务均包含 tenant;
6. 系统账号和系统 Topic 按企业创建;
7. 后台管理账号采用企业范围和数据范围授权;
8. 增加跨租户攻击自动化测试,覆盖枚举 ID、文件 URL、搜索和转发。
这不是加一个字段的局部修改,而是横跨认证、存储接口、全部数据库适配器、缓存、集群路由和测试的基础工程。
## 7. 协议与 API 改造
### 7.1 保持现有协议兼容
继续支持现有 `hi`、`acc`、`login`、`sub`、`leave`、`pub`、`get`、`set`、`del`、`note`。新客户端通过 `x-im-schema` 声明产品扩展版本;旧客户端没有扩展字段时仍按现有路径工作。
推荐新增固定消息头:
| 字段 | 必填条件 | 说明 |
| --- | --- | --- |
| `x-im-schema` | 产品消息必填 | 扩展协议版本 |
| `x-im-type` | 产品消息必填 | 消息类型枚举 |
| `x-im-client-mid` | 普通消息必填 | 客户端生成的幂等 ID |
| `x-im-reply-to` | 回复消息时 | 被回复消息 SeqId |
| `x-im-forward-mode` | 转发时 | `single` 或 `merged` |
| `x-im-risk-state` | 仅服务端写 | 审核状态,客户端提交值必须忽略 |
### 7.2 消息幂等
新增唯一约束:
```text
(tenant_id, sender_id, client_mid) UNIQUE
```
一企一库时可省略物理 `tenant_id`,但代码接口仍建议预留租户上下文。幂等记录需要保存最终 Topic、SeqId 和响应摘要。消息保存、Topic 最新序号、订阅更新目前通过多次 Store 调用完成;新增幂等时应明确事务边界,避免数据库写成功但回复丢失后重试产生两条消息。
### 7.3 查询扩展
不建议继续把搜索条件塞进未定义的任意 JSON。可在 `MsgGetQuery` 中增加明确字段:
```go
type MsgSearchQuery struct {
Query string `json:"query,omitempty"`
Types []string `json:"types,omitempty"`
From string `json:"from,omitempty"`
SinceTime string `json:"since_time,omitempty"`
BeforeTime string `json:"before_time,omitempty"`
Cursor string `json:"cursor,omitempty"`
Limit int `json:"limit,omitempty"`
}
```
如果搜索由独立服务承担,则提供 `/v1/im/search/messages`,返回 Topic、SeqId 和安全摘要,客户端再通过原生 `get data` 拉取上下文。任何方案都必须在服务端重新做 Topic ACL 检查。
### 7.4 业务 API 约定
新增 `/v1/im/*` 路由,复用 Tinode Token 的认证结果。应把 `hdl_files.go` 中现有的请求认证逻辑抽取成通用 HTTP 中间件,而不是让每个 Handler 自行解析 Token。
所有写接口统一支持:
- `X-Request-ID`:链路追踪;
- `Idempotency-Key`:创建和审批的幂等控制;
- 统一错误结构:`code`、`message`、`details`、`request_id`;
- 游标分页,不使用数据增长时不稳定的大 Offset;
- 服务端从认证上下文取用户和租户,不接受正文伪造;
- 状态变更写审计日志和 Outbox。
## 8. 建议新增的数据模型
以下是逻辑模型,正式建表前需补充字段长度、外键策略、冷热分层和数据保留周期。
| 表 | 核心字段 | 关键约束/索引 | 作用 |
| --- | --- | --- | --- |
| `im_user_relations` | user_id、peer_id、state、remark、blocked、muted | 双向记录唯一;peer/state 索引 | 好友、备注、拉黑和通知关系 |
| `im_friend_requests` | id、from、to、message、state、expires_at | 待处理申请去重;to/state/time | 好友申请状态机 |
| `im_conversation_settings` | user_id、topic、pinned、muted、hidden | user/topic 唯一 | 会话个性化设置 |
| `im_privacy_settings` | user_id、search flags、friend policy | user 唯一 | 精准搜索和加好友授权 |
| `im_group_settings` | topic、join_policy、invite_policy、mute_all | topic 唯一 | 群业务规则 |
| `im_group_join_requests` | id、topic、applicant、inviter、state | topic/applicant/pending 去重 | 入群申请与审批 |
| `im_group_mutes` | topic、user、expires_at、operator | topic/user 唯一;expires 索引 | 单人禁言 |
| `im_group_announcements` | id、topic、version、content、publisher | topic/status/time | 群公告版本 |
| `im_group_announcement_reads` | announcement_id、user、read_at | announcement/user 唯一 | 公告已读 |
| `im_group_invite_tokens` | token_hash、topic、expires、max_uses | token_hash 唯一;expires | 群二维码/邀请链接 |
| `im_message_idempotency` | sender、client_mid、topic、seq_id | sender/client_mid 唯一 | 消息去重 |
| `im_message_index` | topic、seq_id、type、text、file_id、sent_at | topic/seq 唯一;搜索索引 | 消息、文件和媒体检索 |
| `im_favorites` | user、favorite_seq、source topic/seq、type | user/time;源消息索引 | 收藏筛选与分享 |
| `im_identity_verifications` | user、encrypted PII、state、review | user/current-state;request id | 实名认证和审计 |
| `im_org_units` | id、parent_id、name、path、state | parent/sort;path | 企业组织树 |
| `im_org_members` | user、org_unit、title、primary | unit/state;user | 部门成员关系 |
| `im_meetings` | id、organizer、title、start/end、state | organizer/time;state/time | 会议通知 |
| `im_meeting_participants` | meeting、user、response | meeting/user 唯一 | 参会状态 |
| `im_work_reports` | type、period、author、department、content、state | author/type/period;dept/time | 日报/月报 |
| `im_mailbox_messages` | id、sender、content、anonymous、state | state/time;sender/time | 总经理信箱 |
| `im_user_settings` | user、setting key/value、version | user/key 唯一 | 跨设备设置 |
| `im_teen_mode` | user、enabled、pin_hash、policy_version | user 唯一 | 青少年策略 |
| `im_reports` | reporter、target_type/id、reason、state | state/time;target | 举报工单 |
| `im_report_evidence` | report、snapshot、file、hash | report | 证据固化 |
| `im_feedback` | user、category、content、state | state/time;user/time | 意见反馈 |
| `im_risk_events` | actor、object、rule、level、action | actor/time;object | 风控记录 |
| `im_audit_logs` | actor、action、object、result、request_id | actor/time;object/time | 管理和敏感操作审计 |
| `im_outbox` | aggregate、event_type、payload、state | state/next_retry;dedupe key | 事务后可靠通知 |
所有表在共享 SaaS 模式下都必须包含 `tenant_id`,并将它放入唯一约束和查询前缀。软删除字段不能代替状态机和审计记录。
## 9. 代码级改造清单
### 9.1 现有文件修改
| 文件 | 建议修改 |
| --- | --- |
| `server/datamodel.go` | 定义产品消息头、消息类型、查询过滤和错误码;保持旧协议兼容 |
| `server/session.go` | 发布前幂等校验;调用产品策略层;统一映射业务错误;认证后绑定租户上下文 |
| `server/topic.go` | 发布前关系/禁言/青少年策略;结构化系统消息;群管理状态变化后的缓存失效 |
| `server/calls.go` | 引入 call_id、设备维度、结束原因、抢答与通话详单;群通话走独立模块 |
| `server/push.go` | 按消息类型生成 Push;支持 @提醒、审批、会议和审核状态;避免敏感内容出现在锁屏 |
| `server/hdl_files.go` | 抽取通用 HTTP 认证;下载增加 FileCanAccess;签名 URL;扫描状态检查 |
| `server/plugins.go` | 为外部审核增加超时、指标、熔断和场景化失败策略 |
| `server/main.go` | 注册 `/v1/im/*` 路由、业务模块生命周期、健康检查和后台任务 |
| `server/store/store.go` | 增加幂等、文件授权和搜索所需的核心接口,明确事务边界 |
| `server/store/types/types.go` | 增加明确的扩展类型;若共享 SaaS,核心实体加入 TenantId |
| `server/db/mysql/adapter.go` | schema 升级、索引、幂等和核心查询实现 |
| `server/db/mysql/schema.sql` | 与 adapter 中建表 SQL 同步,禁止只改其中一份 |
| `tinode.conf` | 产品模块、租户、搜索、审核、TURN、文件扫描和限流配置 |
### 9.2 建议新增包
```text
server/imapi/ HTTP 路由、请求校验、响应和认证中间件
server/impolicy/ 好友、黑名单、群禁言、隐私、青少年等同步策略
server/immessage/ 消息类型注册、结构校验、摘要与转发净化
server/imbiz/ 好友、群治理、组织、会议、报告、实名、举报服务
server/imstore/ 产品业务存储接口和 MySQL 实现
server/imnotify/ Outbox 消费、系统消息和 Push 桥接
server/imsearch/ 索引写入、回填、检索和 ACL 二次过滤
server/imjobs/ 过期申请、会议提醒、清理、索引回填等后台任务
```
### 9.3 数据库适配策略
当前项目同时包含 MySQL、PostgreSQL、MongoDB 和 RethinkDB 适配器。新增所有产品能力到公共 Store 接口会要求四套实现同步完成,改造面很大。
建议先明确产品正式支持的数据库:
- 如果生产只使用 MySQL,则产品业务 Store 首期实现 MySQL,并在启用 `im_product` 配置且底层不支持时拒绝启动,不能静默降级;
- 核心消息幂等、文件授权等通信安全能力应进入公共接口,或明确产品版只支持 MySQL;
- schema 版本从 116 升级时,四个适配器必须分别维护自己的版本,不能把 MySQL 已升级误写成所有数据库都支持;
- `adapter.go` 中的建表 SQL 与 `schema.sql` 必须通过测试保持一致。
## 10. 事务、一致性与异步通知
好友审批、入群审批、会议创建等操作通常同时涉及业务表、Topic/Subscription 和通知。若直接依次调用,任何一步失败都会出现“状态已接受但没有会话”或“会议创建了但没人收到通知”。
建议采用本地事务 + Outbox:
```mermaid
sequenceDiagram
participant A as App
participant API as IM Business API
participant DB as Database
participant W as Outbox Worker
participant T as Tinode Topic
A->>API: 接受好友/入群申请(Idempotency-Key)
API->>DB: 开启事务
API->>DB: 更新申请与关系/成员状态
API->>DB: 写入 Outbox 事件
API->>DB: 提交事务
API-->>A: 返回确定的业务状态
W->>DB: 锁定未处理事件
W->>T: 创建/更新订阅并发送系统通知
W->>DB: 标记完成或计划重试
```
Outbox 消费需要事件去重;状态变更 API 自身也需要 Idempotency-Key。对于必须与核心 Topic 数据原子提交的操作,应把它们放到同一数据库事务接口,而不是依赖最终一致性掩盖授权窗口。
## 11. 安全与合规基线
服务端改造至少应包含:
1. **认证安全**:密码强哈希、验证码限频、设备和 IP 风险、Token 撤销、失败锁定;
2. **授权安全**:所有对象查询从服务端认证上下文获得租户和用户;文件、搜索、转发必须二次校验 ACL;
3. **数据安全**:身份证等 PII 字段加密、密钥轮换、响应脱敏、日志禁止明文;
4. **内容安全**:文本、图片、链接和文件审核;病毒扫描;风险策略版本化;
5. **审计安全**:管理员、群主、实名审核、举报处置、匿名信箱访问全部留痕;
6. **接口安全**:限流、请求大小、游标签名、幂等、防重放、统一输入校验;
7. **隐私删除**:明确账号注销、聊天记录、收藏、举报证据和办公数据的不同保留周期;
8. **推送隐私**:锁屏 Push 默认不携带敏感正文,由用户设置控制预览;
9. **可观测性**:请求 ID、用户/租户匿名化标识、消息处理延迟、拒绝原因和插件超时指标。
## 12. 分阶段实施建议
### 阶段 P0:基线和关键决策
- 确定一企一实例还是共享 SaaS;
- 确定产品版支持的数据库;
- 清理旧设计文档与源码的版本漂移,建立 schema/协议单一事实来源;
- 建立数据库迁移、回滚、CI 和环境配置基线;
- 修复文件下载授权;
- 实现消息客户端 ID 幂等;
- 定义消息类型、错误码和兼容策略。
完成标准:安全缺口关闭;重复重试不会产生重复消息;旧客户端仍可通信;开发和测试环境可重复升级/回滚。
### 阶段 P1:账号、关系和基础聊天产品化
- 企业码发现、注册、验证码和忘记密码流程;
- 实名认证基础流程;
- 好友申请、联系人、黑名单和隐私设置;
- 结构化文本/图片/语音/视频/文件/名片消息;
- 会话置顶、免打扰、系统消息;
- @提醒、回复、逐条转发、合并转发;
- 基础风控和审计。
完成标准:原型第 1 至 5 类主流程可由真实客户端端到端完成。
### 阶段 P2:群治理、收藏、搜索和文件
- 入群申请、邀请审批、群公告;
- 管理员、群主转让、全员/成员禁言;
- 群二维码和群文件;
- 收藏索引;
- 消息关键词、文件、链接、图片和视频检索;
- 历史数据回填和搜索 ACL 验证。
完成标准:原型第 6、7 类全部服务端流程可验收,搜索不会返回无权消息或附件。
### 阶段 P3:通话、设置和安全治理
- call_id、通话记录、跨设备抢答、TURN 指标;
- 个人资料和二维码;
- 版本配置、隐私设置、青少年模式强制策略;
- 反诈提示策略、举报、证据、反馈和后台处置;
- 内容审核超时/降级策略演练。
完成标准:原型第 8、10、11 类服务端能力可验收,关键安全策略无法通过换设备绕过。
### 阶段 P4:企业工作台
- 组织与部门;
- 会议通知和提醒;
- 日报、月报和管理视图;
- 总经理信箱;
- 离职编排、数据权限和敏感操作审计。
完成标准:原型第 9 类完成业务闭环,组织数据权限和通知可靠性通过测试。
## 13. 测试与验收方案
### 13.1 自动化测试层次
| 测试层 | 重点 |
| --- | --- |
| 单元测试 | 消息类型校验、状态机、权限矩阵、Token/二维码、错误映射 |
| Store 集成测试 | schema 升降级、唯一约束、事务、并发审批、Outbox 重试 |
| WebSocket 端到端 | 登录、订阅、消息、重复发送、回执、禁言、拉黑、通话信令 |
| HTTP API 测试 | 好友/群审批、组织、报告、实名、举报、文件授权 |
| 搜索测试 | 中文关键词、类型筛选、删除同步、权限变化、历史回填 |
| 安全测试 | 跨用户/跨租户、文件 URL 猜测、ID 枚举、重放、越权审批 |
| 故障测试 | 审核超时、Push 失败、Outbox 重试、数据库瞬断、服务重启 |
| 真实设备测试 | Push、相机/麦克风、弱网重连、WebRTC NAT/TURN、跨设备抢答 |
### 13.2 必须覆盖的关键用例
1. 同一个 `x-im-client-mid` 并发重试 10 次,只产生一条消息并返回同一个 SeqId;
2. 拉黑后已有长连接不能继续发消息或呼叫;
3. 被禁言成员通过旧客户端或直接构造协议包仍被服务端拒绝;
4. 非群成员持有文件 URL 时无法下载,退出群后访问策略符合产品约定;
5. 搜索结果不包含已撤回、仅对本人删除、无 Read ACL 或其他租户的消息;
6. 入群审批重复点击不会重复创建 Subscription;
7. 群主转让、管理员变更和禁言都有审计记录且权限立即生效;
8. 实名信息不会出现在日志、Push、Public/Trusted 和普通后台响应;
9. 青少年模式换设备、重装或使用旧客户端仍受服务端限制;
10. 外部审核服务超时后,系统严格按配置的降级策略处理并产生告警;
11. Outbox 消费进程崩溃重启后,通知不丢失且业务副作用不重复;
12. 多租户模式下所有 API、消息、文件、搜索和后台查询均完成负向越权测试。
### 13.3 当前基线验证
本次分析执行了以下不依赖外部数据库的测试:
```bash
go test -count=1 ./server ./server/drafty ./server/media ./server/store/types ./server/db/common
```
上述包当前全部通过。MySQL/PostgreSQL/MongoDB/RethinkDB 的真实数据库集成测试未在本次报告中执行,改造前应在 CI 中配置正式支持的数据库并固定运行。
## 14. 风险与待确认事项
| 决策 | 不确认的风险 | 建议 |
| --- | --- | --- |
| 企业隔离模式 | 后期补 tenant 会重写大量查询和数据 | P0 首先决策,优先一企一实例 |
| 正式支持的数据库 | 四种适配器同步开发显著拖慢交付 | 产品业务首期明确 MySQL |
| 删除好友后的历史 | 客户端与服务端理解不同导致数据争议 | 默认保留历史、禁止新消息 |
| 收藏是否为独立副本 | 源消息删除后的合规行为不明确 | 明确快照和保留策略 |
| 全员撤回时限 | 无规则会导致历史任意篡改 | 配置时限、角色和审计 |
| 匿名信箱的匿名边界 | 绝对匿名与合规追溯冲突 | 业务匿名、受控审计可追溯 |
| 审核失败策略 | 失败开放有风险,失败关闭影响可用性 | 按场景分级并可观测 |
| 群音视频范围 | 当前 P2P 模型无法自然扩展 | 若需要多人通话,独立评估 SFU |
| 搜索技术选型 | MySQL 与独立搜索引擎成本差异大 | 依据消息量、中文检索和运维能力压测决定 |
## 15. 建议的第一批工程任务
建议立刻拆分以下可执行任务:
1. 建立“源码真实基线”文档,删除或标记未实现的 `x-im-*`、schema 119 和测试工具说明;
2. 编写消息扩展协议 v1,包括类型、字段、大小、错误码和兼容矩阵;
3. 设计并实现消息幂等,补并发和断线重试测试;
4. 修复文件下载授权,补上传者、群成员、退群用户和泄露 URL 测试;
5. 决定租户和数据库支持范围,冻结第一版 schema;
6. 新建 `imapi`、`impolicy`、`imbiz`、`imstore`、`imnotify` 的最小骨架;
7. 以“好友申请 → 接受 → 创建会话 → 发消息 → 拉黑”为第一条端到端业务链;
8. 以“群申请 → 审批 → 禁言 → 公告”为第二条端到端业务链;
9. 引入 Outbox 和审计日志,所有后续业务复用;
10. 在 CI 中加入服务端单元测试、MySQL 集成测试和 WebSocket 协议回归测试。
## 16. 最终建议
本项目具备可继续演进的实时通信核心,最合理的路线不是重写聊天服务,而是把 Tinode 明确定位为通信内核,在它外部增加可事务、可检索、可审计的产品业务层,并只在发布、订阅、文件和通话等关键入口加入同步策略校验。
实施时应优先解决租户、幂等和文件授权,再做好友与群业务。企业工作台与通信内核保持模块边界,通过 HTTP API 管理业务状态、通过 Outbox 和系统 Topic 可靠通知。这样既能复用现有 Topic/ACL/Push/WebRTC 能力,也能避免把大量办公和审批逻辑耦合进消息路由主链路。
+66
View File
@@ -0,0 +1,66 @@
-- IM 企业入口注册与问题反馈 SQL
-- 更新时间:2026-08-24
--
-- 执行范围:hotgo 控制库。
-- 说明:未登录企业注册、入口问题反馈发生在租户绑定前,因此公共附件和申请单不放入租户业务库。
-- 文件对象通过 IM 服务端当前 media handler 存储;生产环境应配置 media.use_handler=oss。
USE hotgo;
CREATE TABLE IF NOT EXISTS im_public_uploads (
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
mime_type VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
etag VARCHAR(128) NOT NULL DEFAULT '',
location VARCHAR(2048) NOT NULL DEFAULT '',
expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id),
INDEX idx_im_public_uploads_purpose(purpose, created_at),
INDEX idx_im_public_uploads_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_tenant_applications (
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
license_file_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
estimated_review_hour INT NOT NULL DEFAULT 24,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_applications_app(application_id),
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
INDEX idx_im_tenant_applications_credit(credit_code),
INDEX idx_im_tenant_applications_status(status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks (
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(255) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
screenshot_file_ids JSON,
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_feedback(feedback_id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
INDEX idx_im_tenant_entry_feedbacks_status(status, id),
INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,147 @@
# IM 会议笔记产品原型需求文档
> 本文档根据 `docs/note/` 中的 8 张会议笔记产品原型图整理。文档格式与 `IM-产品原型需求文档.md` 保持一致,后续可合并到第 9 章“企业工作台与办公协同”中,建议插入在总经理信箱之后、个人资料与设置之前。
## 9.14 会议笔记
会议笔记是企业工作台下的办公协同模块,用于围绕会议记录会议资料、纪要文件和相关附件。功能需要和会议通知、参会人、会议状态及文件能力联动,保证发起人、会议记录员、参会人和普通成员在不同权限下看到合适的入口、文件列表和操作按钮。
通知与刷新规则:
1. 工作台底部 Tab 的红点数字需要与工作台内可接收通知的模块上下对应。当前原型中的红点 `3` 表示工作台下共有 3 条未读通知,这 3 条通知只归属到“会议通知”模块。
2. 当前只有“会议通知”模块接收移动端通知,并承担会议创建、会议变更、会议提醒、参会反馈等通知展示。
3. 打卡、月报、日报、部门框架、举报信箱、会议笔记等其他工作台模块不做移动端通知,也不产生独立通知红点。
4. 除会议通知外,其他模块在用户进入模块时调用各自接口获取最新数据并刷新页面;客户端不得依赖通知事件来更新这些模块。
5. 会议笔记的文件新增、预览、下载状态只在会议笔记列表/详情页内刷新展示,不向工作台 Tab 追加通知数。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
功能说明:
工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。
业务规则:
1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。
2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。
3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
功能说明:
会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。
业务规则:
1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。
2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。
3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。
4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。
5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。
6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
功能说明:
会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。
业务规则:
1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。
2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。
3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。
4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。
5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。
6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
功能说明:
会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。
业务规则:
1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。
2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。
3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。
4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。
5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。
6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
功能说明:
普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。
业务规则:
1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。
2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。
3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。
4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
功能说明:
当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。
业务规则:
1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。
2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。
3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。
4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
功能说明:
文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。
业务规则:
1. 每个文件需独立展示下载进度、成功、失败和取消状态。
2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。
3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。
4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
功能说明:
文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `1/213`;更多菜单包含举报和分享操作。
业务规则:
1. 文件预览页应支持多页文档翻页、缩放、滑动浏览和加载失败重试。
2. 页码、加载状态和错误状态必须与当前文件一致,切换文件或返回详情页后应清理旧状态。
3. 分享权限由会议和文件权限共同决定;无分享权限时隐藏分享入口或提示无权限。
4. 举报入口用于举报文件内容或会议资料,提交后应关联会议 ID、文件 ID、上传人和当前查看人。
5. 预览页不得缓存超出权限有效期的文件内容;退出登录、切换企业或权限被收回后必须清理本地可访问副本。
## 合并建议
1. 合并到 `IM-产品原型需求文档.md` 时,建议将本章插入到第 9 章“企业工作台与办公协同”的 `9.13 总经理信箱-文案提示.png` 之后。
2. 如果主文档继续按图片顺序编号,可将本章编号调整为 `9.14` 至 `9.21`;如果按业务模块编号,可保留 `9.14.x` 子编号。
3. 本文档引用的图片已经复制到 `docs/images/`,文件名均以 `会议笔记-` 开头,避免与现有 `工作台.png` 等图片重名。
@@ -0,0 +1,181 @@
# IM 会议笔记前后端逐图对接协议
> 版本:v1.0
> 日期:2026-09-05
> 需求依据:[IM 会议笔记产品原型需求文档](./IM-会议笔记产品原型需求文档.md)
> 公共协议依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 说明:本文档的图片标题和顺序与会议笔记产品原型完全一致。后续合并时建议作为第 9 章的 `9.14 会议笔记`,原主文档 `9.14 接口示例` 顺延为 `9.15 接口示例`。
## 9.14 会议笔记
会议笔记属于企业工作台模块,复用会议主数据和统一文件能力。服务端负责判断会议是否结束、当前用户是否与会议有关、是否为会议记录员,以及文件查看和操作权限;客户端只根据响应中的状态与 `permissions` 展示页面,不自行推导权限。
本模块遵循主文档第 0 章的公共约定:产品 HTTP 使用 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Authorization: Bearer <token>`;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。
会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
**状态:工作台接口需改造。**
- `GET /v1/im/workbench` 的 `modules` 增加 `code="meeting_notes"`、`route="/meeting-notes"`。服务端按租户功能开关和当前用户权限决定是否返回该入口。
- 会议笔记模块固定返回 `unread_count=0`,不参与 `GET /v1/im/bootstrap` 的 `entry_badges.workbench_total_unread` 汇总。
- 当前工作台红点只对应“会议通知”模块的未读通知。日报、月报、打卡、部门框架、举报信箱和会议笔记均不产生工作台通知数。
- 客户端点击入口后请求 `GET /v1/im/meeting-notes`。切换企业后必须清空原租户的会议笔记缓存并重新加载。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20`。接口只返回当前用户作为发起人、会议记录员或参会人的已结束会议,不返回未开始、进行中或已取消会议。
- `query` 支持匹配会议主题、发起人、会议记录员、参会人和地点;搜索范围仍受当前用户会议可见权限限制。
- 列表按 `end_at` 倒序排列,分页使用不透明 `cursor`。客户端不得用本地时间过滤会议状态或自行扩大搜索范围。
- 已结束但尚未上传笔记的会议仍返回,使用 `note_status="empty"`、`file_count=0`;客户端展示会议基础信息,进入详情后显示“暂无笔记”。
- `permissions.can_view=true` 决定是否允许进入详情;`permissions.can_upload` 只对当前会议记录员为 `true`。普通参会人可以进入详情,但不显示上传按钮。
- `state="ended"` 是服务端按会议状态及服务端当前时间计算或持久化的有效状态。服务端必须以 `end_at` 和取消状态为准,不能信任客户端传入的“已结束”条件。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
**状态:现有会议创建接口需改造。**
- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。
- 创建仍使用 `POST /v1/im/meetings`,请求新增 `recorder_user_id`。客户端未传或传空字符串时,服务端默认使用当前登录用户;客户端不能通过 `organizer_id` 伪造发起人。
- 服务端校验会议记录员属于当前租户且账号有效。首版允许会议记录员是发起人或参会人;若选择了非参会人,服务端自动加入参会人列表并在响应中返回最终名单。
- 服务端保存的会议必须有 `recorder_user_id`;创建请求可省略该字段以使用发起人默认值。`attendee_user_ids`、`start_at`、`end_at` 和 `title` 为必填业务数据,`location` 可为空,且 `end_at` 必须晚于 `start_at`。
- 创建成功后的会议通知仍统一归属“会议通知”模块;会议笔记模块不额外生成通知或未读数。
- `PATCH /v1/im/meetings/{meeting_id}` 支持在会议结束前修改 `recorder_user_id`,并使用 `version` 做乐观锁。会议结束后返回 `MEETING_CLOSED`。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
**状态:新增。**
- 详情:`GET /v1/im/meetings/{meeting_id}/notes`,一次返回会议基础信息、发起人、会议记录员、参会人、文件列表、笔记状态和当前用户权限。
- 会议基础信息与会议通知页面共用 `im_meetings` 和 `im_meeting_participants` 数据,不复制会议标题、时间和成员快照作为另一份可编辑主数据。
- 当前用户为会议记录员且会议已结束时,`permissions.can_upload=true`,客户端展示“上传文件”;其他用户不展示该按钮。
- 上传文件先调用 `POST /v0/file/u/`,Multipart 携带 `purpose=meeting_note` 和 `meeting_id`;上传成功取得 `file_id` 后,再调用 `POST /v1/im/meetings/{meeting_id}/notes/files` 建立会议笔记文件记录。
- 文件登记成功后才进入详情列表。上传成功但登记失败的临时文件不得展示,并由服务端按临时文件 TTL 回收。
- 当前会议记录员可替换或删除自己登记的文件;所有写操作均需 `Idempotency-Key`,服务端记录操作审计。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
**状态:新增权限响应。**
- 普通参会人调用与 9.14.4 相同的详情接口。响应中 `permissions.can_view=true`、`can_upload=false`、`can_replace=false`、`can_delete=false`。
- 没有已上传文件时返回 `note_status="empty"` 和空数组 `files=[]`,客户端展示“暂无笔记”,不把空状态当作请求失败。
- 有文件时普通参会人只展示文件名、大小、类型、上传人、上传时间和预览入口,不展示上传、替换、删除按钮。
- 服务端必须在文件登记、替换、删除、预览和下载接口逐次校验会议关系及角色。隐藏客户端按钮不能代替服务端鉴权。
- 与会议无关、已离开可见范围或跨租户的用户统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得通过 `meeting_id`、`note_file_id` 或 `file_id` 探测资源。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。两个接口都必须校验当前用户仍有会议笔记查看权限。
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
**状态:复用统一下载任务;会议笔记 ACL 新增。**
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传。
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
**状态:客户端预览页;服务端复用 9.14.6 文件读取协议。**
- 预览页展示服务端返回的安全文件名和实际内容;标题不得直接信任未经清理的上传文件名。
- 图片、PDF、文本等受支持类型在应用内预览;Office 等类型优先使用受控转换文件,无法转换时按 `preview.mode="download_only"` 提示下载后打开。
- 文件被删除、审核拒绝、转换失败或权限撤销时,服务端返回稳定 `reason`,客户端关闭预览并刷新详情,不能继续显示旧的授权链接。
- 从预览页返回详情后调用 `GET /v1/im/meetings/{meeting_id}/notes` 获取最新文件状态。
### 9.14.9 接口示例
以下示例用于会议笔记 HTTP 联调快速查阅。成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`,下表只展示 `data`。
#### 9.14.9.1 工作台、会议记录员与会议笔记
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/workbench` 需改造 | Query only | `{"layout_version":2,"modules":[{"code":"meeting_notifications","name":"会议通知","route":"/meetings","unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","route":"/meeting-notes","unread_count":0,"enabled":true,"permission":"enabled"}]}` |
| `POST /v1/im/meetings` 需改造 | Header `Idempotency-Key`;Body `{"title":"项目周会","start_at":"2026-09-05T10:00:00+08:00","end_at":"2026-09-05T10:30:00+08:00","location":"会议室 A","recorder_user_id":"usr_me","attendee_user_ids":["usr_2"],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","state":"scheduled","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr_2","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true},"version":1}` |
| `PATCH /v1/im/meetings/{meeting_id}` 需改造 | Header `Idempotency-Key`;Body `{"recorder_user_id":"usr_2","version":1}` | `{"meeting_id":"mtg_1","recorder":{"user_id":"usr_2","display_name":"李四"},"version":2,"updated_at":"2026-09-05T01:30:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=&limit=20` 新增 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participant_count":2,"participant_names":["张三","李四"],"note_status":"empty","file_count":0,"permissions":{"can_view":true,"can_upload":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` 新增 | Path meeting ID | `{"meeting":{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participants":[{"user_id":"usr_me","display_name":"张三"},{"user_id":"usr_2","display_name":"李四"}]},"note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","uploader":{"user_id":"usr_me","display_name":"张三"},"created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"ready","mode":"native","preview_file_id":"file_1"},"permissions":{"can_preview":true,"can_download":true,"can_replace":true,"can_delete":true}}],"permissions":{"can_view":true,"can_upload":true},"version":1}` |
| `POST /v0/file/u/` | Multipart 文件;字段 `purpose=meeting_note`、`meeting_id=mtg_1`;Header `X-MeetingIM-Auth` | `{"ctrl":{"code":200,"params":{"file_id":"file_1","url":"/v0/file/s/file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf"}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"processing","mode":"converted"}}` |
| `PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_2","version":1}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_2","name":"会议纪要-v2.pdf","version":2,"updated_at":"2026-09-05T03:10:00Z"}` |
| `DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Query `version=2` | `{"note_file_id":"mnote_1","deleted":true,"deleted_at":"2026-09-05T03:20:00Z"}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 返回文件字节流及 `Content-Type`、`Content-Length`、`ETag`、`Accept-Ranges`;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
### 9.14.10 字段与状态约定
| 字段 | 取值 | 前端处理 |
| --- | --- | --- |
| `meeting.state` | `scheduled`、`in_progress`、`ended`、`canceled` | 会议笔记列表只展示 `ended` |
| `note_status` | `empty`、`available` | `empty` 展示“暂无笔记”,`available` 展示文件列表 |
| `preview.status` | `ready`、`processing`、`unsupported`、`failed` | 分别进入预览、短时刷新、仅下载提示、失败提示 |
| `preview.mode` | `native`、`converted`、`download_only` | 使用原文件、转换文件或仅提供下载 |
| `permissions.can_view` | Boolean | 是否可以查看当前会议笔记 |
| `permissions.can_upload` | Boolean | 是否显示并允许使用上传按钮,仅会议记录员为 `true` |
| `permissions.can_replace` | Boolean | 是否允许替换当前笔记文件 |
| `permissions.can_delete` | Boolean | 是否允许删除当前笔记文件 |
| `permissions.can_preview` | Boolean | 是否允许预览当前文件 |
| `permissions.can_download` | Boolean | 是否允许下载当前文件;首版普通参会人若产品要求“只能预览”,该值返回 `false` |
客户端必须以服务端返回的 `permissions` 为准。特别是普通参会人“只能预览”的要求,由文件项 `can_preview=true,can_download=false` 表达;即使预览底层使用文件读取接口,客户端也不得提供导出、系统分享或保存到相册入口。
### 9.14.11 错误码与客户端处理
| `reason` | 典型 HTTP 状态 | 场景 | 客户端处理 |
| --- | --- | --- | --- |
| `MEETING_NOT_ENDED` | 409 | 未结束会议上传或登记笔记 | 提示“会议结束后可上传笔记”,刷新会议详情 |
| `MEETING_CLOSED` | 409 | 会议结束后修改记录员或会议数据 | 切换为只读并刷新详情 |
| `NOT_MEETING_RECORDER` | 403 | 非记录员上传、替换或删除文件 | 隐藏操作入口并刷新权限 |
| `MEETING_NOTE_VERSION_CONFLICT` | 409 | 替换或删除时版本过期 | 拉取最新详情,不自动覆盖 |
| `MEETING_NOTE_FILE_INVALID` | 400 | 文件未上传完成、用途不符或不属于当前租户 | 提示重新上传 |
| `FILE_PREVIEW_PROCESSING` | 409 | 转换预览尚未完成 | 保持当前页并按退避策略刷新 |
| `FILE_PREVIEW_UNSUPPORTED` | 422 | 文件类型不支持在线预览 | 根据 `can_download` 决定是否提供下载 |
| `FILE_FORBIDDEN` | 403 | 当前用户无文件访问权限 | 关闭预览或停止下载并刷新详情 |
| `FILE_UNAVAILABLE` | 404/410 | 文件被删除、失效或审核拒绝 | 标记不可用并刷新文件列表 |
| `RESOURCE_NOT_FOUND` | 404 | 会议或笔记不可见、跨租户访问 | 返回列表,不提示资源是否真实存在 |
### 9.14.12 前后端联调与验收要点
1. 发起会议时不传 `recorder_user_id`,响应中的记录员必须默认为当前发起人;指定其他记录员时,响应返回最终记录员和参会人名单。
2. 会议笔记列表只包含与当前用户有关的已结束会议;已结束但无文件的会议仍展示,详情为空状态。
3. 记录员进入详情显示上传按钮,普通参会人进入同一会议不显示上传按钮;直接调用写接口仍必须被服务端拒绝。
4. 普通参会人只允许在线预览时,响应为 `can_preview=true,can_download=false`,客户端不展示下载、分享或导出入口。
5. 文件上传成功但会议笔记登记失败时,详情列表不出现该文件;重复登记同一幂等键返回首次结果。
6. 文件替换和删除必须校验 `version`;旧版本请求返回冲突,不能覆盖新文件。
7. 进入列表、进入详情以及从上传/预览/下载页面返回时均主动刷新;断开 WebSocket 不影响会议笔记数据获取。
8. 会议笔记新增、替换、删除均不产生 Push、会议通知未读或工作台红点;工作台红点只随会议通知模块变化。
9. 跨租户、无关用户和已撤销权限用户无法通过会议 ID 或文件 ID 查看、预览、下载会议笔记。
10. 切换企业或退出登录后,会议笔记列表、详情、预览文件和下载临时数据按租户及账号隔离清理。
## 合并建议
1. 将本文 `9.14 会议笔记` 整段插入现有《IM 前后端逐图对接协议》第 9 章的 `9.13 总经理信箱-文案提示.png` 之后。
2. 将现有 `9.14 接口示例` 及其子章节顺延为 `9.15`,并把本文 `9.14.9.1` 的接口行合并到新的第 9 章接口示例表中。
3. 同步扩展会议数据模型:`im_meetings` 增加会议记录员字段,并新增会议笔记文件关联表;具体表结构在系统设计和数据库变更文档中统一维护。
4. 主文档第 9 章现有会议创建示例需要补充 `recorder_user_id`,工作台示例需要增加 `meeting_notes` 模块并明确其 `unread_count=0`。
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
# IM 剩余问题修复方案
整理时间:2026-09-18
本文记录当前暂未完成的三个后端问题:青少年模式业务限制、Web 平台版本检查、消息搜索召回稳定性。
更新记录:
- 2026-09-18:P1 第一阶段已处理 Web 平台版本检查与消息搜索召回兜底;青少年模式已完成第一阶段策略层、夜间通话限制和消息内容过滤接入,剩余支付守护、好友/群邀请、Push 等全链路限制继续按 P2 增强。
## 1. 青少年模式业务限制未强制执行
### 现状
当前后端已经具备青少年模式的基础状态能力:
- `GET /v1/im/settings/youth-mode` 可返回 `enabled`、`policy_version`、`restrictions`、`restriction_items`。
- `POST /v1/im/settings/youth-mode/enable` 和 `disable` 已完成开启、关闭和独立密码校验链路。
- `im_youth_mode_settings` 已保存用户维度的启用状态、策略版本、限制项和密码状态。
第一阶段已将 `night_quiet` 接入音视频呼叫发起限制,将 `content_filter` 接入消息发送前内容安全审核,并把这两项 `restriction_items[].enforced` 改为 `true`。`payment_guard` 以及好友申请、群邀请、系统通知/Push 等更大范围入口仍未完整接入。
### 问题影响
如果只保存开关、不做服务端限制,客户端隐藏入口不能形成安全边界。用户可以通过旧客户端、重新安装、直接调用接口或构造 WebSocket 消息绕过限制。
### 建议修复方案
建议将青少年模式拆成一个统一策略检查层,例如 `imYouthPolicy` 或 `impolicy/youth`,所有关键入口调用同一套判断逻辑。
第一阶段建议先落这几个限制:
| 限制项 | 建议拦截点 | 修复方式 |
| --- | --- | --- |
| `night_quiet` 深夜免打扰 | 发消息、音视频呼叫、系统通知/Push | 服务端读取用户青少年状态和当前时间段,限制指定时段内发起呼叫或降低通知等级。 |
| `content_filter` 内容过滤 | 发文本、发链接、上传后发布文件、资料/群资料修改 | 复用现有内容安全/风险审核能力;命中高风险时拒绝或进入审核状态。 |
| `payment_guard` 支付守护 | 充值、购买、付费功能入口 | 当前 IM 暂无支付主链路,先预留统一错误码和策略接口,后续支付接口接入时强制调用。 |
建议新增统一返回:
```json
{
"code": 30005,
"reason": "YOUTH_MODE_RESTRICTED",
"message": "restricted by youth mode",
"details": {
"restriction": "night_quiet",
"policy_version": 1
}
}
```
### 建议改造点
- `server/im_http.go`
- HTTP 发消息、转发、收藏转发、好友申请、群邀请、音视频相关接口前置检查。
- `youthModeRestrictionItems` 中已真正接入的规则将 `enforced` 改为 `true`。
- `server/topic.go`
- 原生 WebSocket `{pub}` 保存消息前执行相同策略,避免绕过 HTTP。
- `server/store/store.go`
- 增加按用户读取青少年模式的轻量缓存,短 TTL 即可。
- `docs/IM-青少年模式前后端对接文档.md`
- 补充哪些接口会返回 `YOUTH_MODE_RESTRICTED`。
### 验收建议
- 开启青少年模式后,旧客户端直接发消息不能绕过限制。
- HTTP 和 WebSocket 两条链路限制结果一致。
- 关闭青少年模式后限制即时解除。
- `restriction_items[].enforced` 只对已经服务端强制执行的规则返回 `true`。
### 当前处理状态
已新增统一策略判断 `imYouthModePolicyDecision`。当前行为:
- `night_quiet`:青少年模式开启且带该限制时,22:00-06:00 发起音视频呼叫返回 `YOUTH_MODE_RESTRICTED`。
- `content_filter`:普通 WS 消息发送前进入内容安全审核,文本、文件名、链接 URL 等可展示内容会参与提取。
- `payment_guard`:仍为预留项,返回 `enforced=false`,后续支付或付费功能接入时再强制调用。
## 2. Web 平台版本检查接口缺失
### 现状
当前版本检查接口为:
```http
GET /v1/im/app/releases/latest?platform=ios|android&current_version=1.0.0&channel=stable
```
当前实现和文档主要覆盖 `ios`、`android`。Web 客户端如果调用 `platform=web`,会因为平台枚举未支持而无法得到可用版本配置。
### 问题影响
Web 端无法统一读取版本策略、升级提示、最低支持版本和发布说明。后续如果 Web 端需要灰度、强刷、维护提示或版本兼容控制,只能靠前端硬编码。
### 建议修复方案
将 `web` 加入 App Release 平台枚举。平台建议统一为:
```text
ios, android, web
```
Web 响应建议保持与移动端同结构:
```json
{
"current_version": "1.0.0",
"has_update": false,
"force_update": false,
"platform": "web",
"channel": "stable",
"latest_version": "1.0.0",
"min_supported_version": "1.0.0",
"update_type": "none",
"package_url": "",
"store_url": "",
"release_notes": "当前已是最新版本。",
"remind_after_seconds": 0
}
```
Web 没有安装包时,`package_url` 和 `store_url` 可以为空;如果需要强刷或维护提示,可通过 `update_type=required` 和 `release_notes` 传达。
### 建议改造点
- `server/im_http.go`
- 放开 `platform=web` 参数校验。
- 保持 `current_version` 必传。
- `server/db/mysql/adapter.go` 或 App Release 相关存储实现
- 确认 `im_app_releases.platform` 没有限制 `web`。
- 如数据库通过字典/配置初始化版本记录,需要补默认 Web 记录。
- `docs/IM-App版本更新前后端对接文档.md`
- 补充 Web 平台示例。
- `docs/IM-前后端对接文档.md`
- 将 `platform=ios|android` 更新为 `ios|android|web`。
### 验收建议
- `platform=web&current_version=1.0.0&channel=stable` 返回 200。
- `platform=invalid` 仍返回参数错误。
- Web 无更新、推荐更新、强制更新三类数据都能按同一结构返回。
### 当前处理状态
已在 `serveIMAppReleaseLatest` 放开 `platform=web`,并补充 Web 平台单元测试。对接文档已同步更新为 `ios|android|web`。
## 3. 消息搜索召回稳定性待复核
### 现状
当前接口为:
```http
GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=
GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=
```
现有后端已经能返回 `seq`、`from`、`ts`、`snippet`、`matches`、`snapshot`、`availability` 等字段。但问题清单中仍记录了“部分明显应该命中的关键词搜索返回空结果”的现象。该问题目前属于待复核项,可能来源包括:
- 搜索范围只覆盖部分消息内容字段,没有覆盖所有文本来源。
- 结构化消息中正文可能存在于 `content.text`、`content.txt`、`content.title`、`content.summary`、Drafty 或文件快照字段中,索引/过滤口径不一致。
- 中文分词、大小写、空格、标点归一化不一致。
- 分页游标与过滤顺序不一致,导致命中项被跳过。
- 热消息、分表消息和历史消息的查询路径不一致。
### 问题影响
搜索结果不稳定会直接影响聊天记录检索、文件检索、收藏定位和问题复盘。用户明知道某条消息存在,但搜索不到,会被认为是数据丢失。
### 建议修复方案
第一阶段先做“确定性兜底”,不急于引入复杂搜索引擎:
1. 明确搜索字段规范。
- 文本消息:统一提取 `content.text`、`content.txt`、`content.content`、`content.title`、`content.summary`、字符串 content。
- 文件消息:统一提取文件名、mime、文件说明、消息文本。
- 链接/名片/合并转发:提取标题、摘要、展示文本。
2. 增加 `message_search_text` 生成函数。
- 写入消息时生成可搜索文本。
- 老数据可查询时动态 fallback。
3. 查询顺序固定。
- 先按用户权限和 topic 过滤可见消息。
- 再按搜索文本匹配。
- 最后按 `seq desc` 或 `created_at desc` 分页。
4. 中文和符号归一化。
- 至少做大小写归一、前后空格、连续空白压缩、全角半角基础处理。
5. 增加回归测试。
- 普通文本、中文关键词、文件名、链接标题、转发快照、撤回/删除消息、分页边界都要覆盖。
第二阶段如果数据量和体验要求更高,再考虑引入专用搜索索引:
- MySQL FULLTEXT/ngram。
- OpenSearch/Elasticsearch。
- 独立 `im_message_search_index` 表,按 topic、seq、search_text 存储轻量索引。
### 建议改造点
- `server/im_http.go`
- `messageText` 和 `messageSearchPayload` 的提取逻辑继续收口,保证响应与查询使用同一文本口径。
- `server/db/mysql/adapter.go` 或消息查询实现
- 检查 `TenantMessageSearch` 的 SQL 过滤条件和分页顺序。
- 确认群消息分表 `im_group_messages_YYYYMM` 与私聊消息表搜索路径一致。
- `server/store/types`
- 如新增搜索索引表,补结构体和迁移 SQL。
- `docs/IM-前后端对接文档.md`
- 明确搜索匹配字段、排序规则和 `availability` 行为。
### 验收建议
- 同一 topic 下创建 20 条混合消息,逐条用关键词可搜到。
- 搜索中文短词、英文大小写、文件名、链接标题均有稳定结果。
- 翻页时不丢边界记录、不重复。
- 被删除/撤回消息按 `availability` 返回或按产品规则隐藏,行为一致。
- 私聊和群聊搜索结果字段一致。
### 当前处理状态
已完成第一阶段确定性兜底:私聊和群聊搜索不再只依赖 SQL `LIKE` 命中消息 JSON 原文,改为先按权限、Topic、类型和游标读取固定窗口,再对结构化文本做统一提取和归一化匹配。当前覆盖文本、文件名、标题、摘要、链接、卡片和转发快照等字段,并补充结构化内容召回测试。
## 建议排期
| 优先级 | 项目 | 原因 |
| --- | --- | --- |
| P1 | Web 平台版本检查 | 改动小,边界清楚,适合快速补齐。 |
| P1 | 消息搜索召回复核 | 影响用户感知,需要先补测试锁定问题。 |
| P2 | 青少年模式业务限制 | 涉及多个入口和策略口径,需要独立设计和联调,不能只改一个接口。 |
@@ -0,0 +1,256 @@
# IM 原生 Cluster 与 Redis Fanout 职责边界
## 1. 背景
IM Server 当前同时存在两套跨 Pod 能力:
- Tinode 原生 Cluster:以 Topic master 为中心,负责把 Topic 请求路由到唯一 master 节点,由 master 统一处理 Topic 状态机。
- Redis Fanout:以 Redis Pub/Sub 为跨 Pod 实时总线,负责把服务端生成的实时事件广播到其他 Pod,再由各 Pod 投递给本机 WebSocket session。
测试环境目前是多 Pod standalone 部署,日志表现为 `Cluster: running as a standalone server.`,因此当前实际跨 Pod 主链路是 Redis Fanout。目标架构需要启用原生 Cluster,把 Topic 热路径、强顺序状态机和实时通话状态迁回 Topic master;Redis Fanout 保留为大数据量、缓存、补偿和用户级控制能力。
本文定义两套机制的职责边界,避免后续同时启用后出现重复投递、重复状态变更或状态源不一致。
## 2. 总原则
1. 同一类 Topic 事件只能有一个实时主链路。
2. 原生 Cluster 启用时,Topic 状态机相关事件优先由 Cluster 处理。
3. Redis Fanout 不作为 Topic master 的替代品;它是跨 Pod 投递、通知和 session control 总线。
4. 关键业务状态必须以 MySQL、Redis 原子状态或 Kafka 顺序消费作为权威,WebSocket fanout 只负责实时通知。
5. 可丢的临时状态可以只走 fanout;不可丢的状态必须有历史补拉或条件更新兜底。
6. 能利用原生 Cluster 内存态、高性能、强一致和强顺序的 Topic 热路径,应优先交给原生 Cluster。
7. 数据量大、需要广播、缓存、聚合、补偿或跨业务模块共享的数据,应优先交给 Redis/MySQL/Kafka。
## 3. 分工原则
两套机制不是互斥关系,而是按数据特征分工:
```text
强状态、强顺序、小范围热路径 -> 原生 Cluster
大数据量、跨节点广播、缓存、补偿 -> Redis / MySQL / Kafka
```
### 3.1 优先使用原生 Cluster 的场景
以下场景应尽量使用原生 Cluster,发挥 Topic master 的内存态和串行状态机能力:
- 单个 Topic 内的订阅、离开、权限变更和在线 session 状态。
- 单聊/群聊 Topic 内的 `pub`、`read`、`recv`、`get/set meta`。
- 通话实时状态机:`ringing`、`accept`、`hang-up`、`offer/answer/ice-candidate`。
- 对顺序敏感的 Topic 内临时事件,例如正在输入、同 Topic 内 presence 派发。
- 需要立即反馈客户端、且依赖 Topic 当前内存状态的操作。
这些能力的共同特点是:
- 数据范围集中在单个 Topic。
- 需要强顺序或强一致。
- 更适合由唯一 Topic master 串行处理。
- 不适合广播给所有 Pod 后各自判断。
### 3.2 优先使用 Redis / MySQL / Kafka 的场景
以下场景应优先使用 Redis、MySQL 或 Kafka,不强行塞进原生 Cluster:
- 全局用户在线状态查询、好友列表在线状态展示。
- 群成员在线节点集合、大群在线成员筛选。
- 大群 fanout、热点消息缓存、未读聚合、离线补偿。
- 用户级 session control:踢端、全端下线、同平台挤旧端、密码重置清退。
- 限流、短状态、幂等、分布式去重。
- 历史消息、通话记录、审计、后台统计。
- Push、文件安全、后台管理命令等异步任务。
这些能力的共同特点是:
- 数据量大或跨 Topic。
- 需要被 HTTP、Kafka、后台任务等多入口共享。
- 可以最终一致,或可以通过历史补拉/条件更新补偿。
- 不适合放在某个 Topic master 的内存里长期维护。
## 4. 目标启用模式
目标模式为:
```text
原生 Cluster Topic master + Redis 大数据量支撑 + MySQL/Kafka 权威状态
```
目标分工:
- 原生 Cluster 负责单个 Topic 内的实时请求路由、内存状态机、强顺序处理和低延迟下发。
- Redis 负责全局在线状态、大群在线成员集合、热点缓存、大数据量 fanout、限流、幂等和 session control。
- MySQL 负责最终历史、通话记录、关系状态和可补偿查询。
- Kafka 负责后台异步任务、Push、审计、文件安全和跨服务事件。
启用原生 Cluster 后的核心原则:
```text
Topic 内强状态、强顺序、小范围热路径,必须优先走原生 Cluster
跨 Topic、大数据量、可最终一致或需补偿的数据,继续走 Redis/MySQL/Kafka
```
当前 standalone + Redis Fanout 是过渡状态。切换到原生 Cluster 前,需要先完成部署形态、职责收敛、重复链路关闭和压测验收。
## 5. 职责边界表
| 能力 | 当前 standalone + Redis Fanout 过渡模式 | 目标原生 Cluster 模式 | 说明 |
| --- | --- | --- | --- |
| Topic master 路由 | 不启用 | Cluster 负责 | 原生 Cluster 通过一致性哈希确定 Topic master。 |
| `{sub}` 订阅 | 本 Pod Hub/Topic 处理,跨 Pod 投递依赖 Redis | Cluster `ProxyReqJoin` | 启 Cluster 后不要再用 Redis 模拟订阅路由。 |
| `{leave}` 离开 | 本 Pod 处理 | Cluster `ProxyReqLeave` | 与订阅同源。 |
| `{pub}` 消息写入 | 本 Pod 处理,生成 data 后 Redis fanout | Cluster `ProxyReqBroadcast` 到 Topic master | 启 Cluster 后 Topic data 不应再额外 Redis topic fanout。 |
| `{get/set}` meta | 本 Pod 处理 | Cluster `ProxyReqMeta` | 强依赖 Topic 状态时应由 master 处理。 |
| `read/recv` 回执 | Redis topic/user fanout 下发结果 | Cluster 路由 note 到 master | 启 Cluster 后回执状态由 master 生成,避免双份 info。 |
| 通话 `ringing/accept/hang-up` | Redis `call_event` fanout + DB 条件更新兜底 | Cluster `ProxyReqCall` | 启 Cluster 后 Redis `call_event` 必须跳过。 |
| `offer/answer/ice-candidate` | 当前可由现有通话状态机处理;声网模式下不走 SDP/ICE | Cluster 由 Topic master 转发 | 首版声网主要关注 accept/hang-up。 |
| Topic 内 data/info/pres 下发 | Redis realtime fanout | Cluster proxy response | 两者不能同时作为主链路。 |
| 用户在线状态查询 | Redis presence | 可继续 Redis presence | 即使启 Cluster,也建议 Redis 保持全局在线查询缓存。 |
| 群在线成员缓存 | Redis topic member presence | 可继续 Redis 缓存 | 用于大群本节点在线成员查找和展示。 |
| 踢端/全端下线 | Redis session control | Redis session control | 用户级控制不属于单个 Topic,建议继续 Redis。 |
| 密码重置清退 | Redis session control | Redis session control | 与 Cluster 不冲突。 |
| 同平台新登录挤旧端 | 本 Pod + Redis platform evict | 本 Pod + Redis platform evict | 继续用 Redis 保证跨 Pod 生效。 |
| 好友/群业务事件 | 本地投递 + Redis user/topic fanout | 可继续 Redis user fanout | 业务事件通常不是 Topic 状态机请求。 |
| 系统消息通知 | 本地投递 + Redis fanout | 视消息写入入口决定 | 如果写入会话 Topic,应避免和 Cluster topic 下发重复。 |
| 群消息 seq/热消息缓存 | Redis / MySQL | 可继续 Redis / MySQL | 大数据量缓存和历史权威不依赖原生 Cluster。 |
| Kafka 后台事件 | Kafka 负责异步持久化、Push、审计 | Kafka 继续负责 | Kafka 不做实时 WS fanout 主链路。 |
## 6. Redis Fanout 主链路范围
在当前 standalone 过渡模式下,Redis Fanout 是跨 Pod 实时投递主链路。启用原生 Cluster 后,Redis Fanout 需要收敛为配套能力,负责:
1. `message/user`:用户维度的系统事件、业务事件、me presence 下发。
2. `session_control/user|device|platform`:踢端、清退、同平台挤旧端。
3. presence 缓存:用户在线状态、节点 session 信息、群成员在线节点信息。
4. 大群在线成员筛选、热点消息缓存、未读聚合和历史补偿辅助。
5. 限流、短状态、幂等、分布式去重。
仅在 standalone 过渡模式下,Redis Fanout 额外负责:
1. `message/topic`:Topic 维度的 `data`、`info`、`pres` 跨 Pod下发。
2. `call_event/topic`:通话 `ringing`、`accept`、`hang-up` 进入 Topic 状态机。
这些 Topic 状态机相关 fanout 在原生 Cluster 启用后必须关闭或跳过。
Redis Fanout 不负责:
- 作为消息历史权威。
- 作为通话最终状态权威。
- 保证同一 Topic 的全局严格顺序。
- 替代 MySQL/Kafka 的审计、补偿和持久化。
## 7. 原生 Cluster 主链路范围
启用原生 Cluster 后,它应负责:
1. Topic owner/master 分配。
2. Topic 请求路由:`sub`、`leave`、`pub`、`get/set`、`note`。
3. Topic 内状态机:订阅、权限、回执、通话、presence 派发。
4. Topic master 到 proxy session 的响应和广播。
5. 节点故障后的 proxy session 清理与订阅失效通知。
原生 Cluster 不建议负责:
- 用户级 session control。
- 全局在线状态查询缓存。
- 业务后台事件可靠投递。
- Kafka 异步消费、Push、审计。
- Redis 热消息缓存、限流、短状态。
- 大群全量成员 fanout、全局未读聚合、历史数据扫描等大数据量任务。
## 8. 双开时必须避免的重复链路
如果 `globals.cluster != nil`,以下 Redis Fanout 应跳过或降级:
| Redis 入口 | Cluster 启用时处理 |
| --- | --- |
| `publishRealtimeTopicMessage` | 跳过。Topic data/info/pres 由 Cluster proxy response 下发。 |
| `publishRealtimeCallEvent` | 跳过。通话 note 由 `ProxyReqCall` 路由到 Topic master。 |
| 群消息在线投递 fanout | 需要专项确认;如果同一 data 已由 Cluster 下发,不得再广播。 |
| 会话 Topic 系统消息 fanout | 如果消息进入 Topic 状态机,应避免再次 topic fanout。 |
以下 Redis Fanout 可继续保留:
| Redis 入口 | 保留原因 |
| --- | --- |
| `publishRealtimeEvictUser` | 用户级清退,不归属单一 Topic。 |
| `publishRealtimeEvictDevice` | 设备级清退,不归属单一 Topic。 |
| `publishRealtimeEvictUserPlatform` | 同平台挤旧端,需要跨 Pod 控制 session。 |
| 用户 presence 缓存 | 全局在线查询和好友列表展示仍需要。 |
| 业务 user event | 多数业务事件不是 Topic 状态机请求。 |
## 9. 状态权威与顺序规则
### 9.1 消息顺序
- 权威顺序来自 `topic + seq`。
- Redis Fanout 到达乱序时,客户端必须按 seq 去重和重排。
- 服务端历史读取按 seq 返回,作为重连补偿来源。
### 9.2 通话状态
通话状态必须使用条件更新保证单向流转:
```text
started -> accepted -> finished
started -> missed
started -> declined
accepted -> disconnected
```
规则:
- `accept` 只能从 `started` 写入 `accepted`。
- `missed` 只能从 `started` 且 `accepted_at IS NULL` 写入。
- `finished` 只能结束未结束通话。
- 已 `accepted` 的通话不得被 `missed` 或 `declined` 覆盖。
- WebSocket 下发失败不能改变最终状态,客户端可通过通话记录或会话消息补偿。
### 9.3 Session Control
session control 以 Redis 短状态和 token/auth_version 为兜底:
- Redis 实时踢端用于立即关闭在线 WS。
- token/auth_version 用于防止旧 token 重新登录。
- stale session-control fanout 必须丢弃,避免旧事件踢掉新 session。
## 10. 启用原生 Cluster 的前置条件
启用原生 Cluster 前必须满足:
1. K8s 部署从普通 Deployment 改为适合稳定网络身份的形态,例如 StatefulSet 或稳定 Pod DNS。
2. 每个 Pod 配置唯一且稳定的 `cluster_self`。
3. 配置完整 `cluster_config.nodes`,包括节点名和可达地址。
4. 开放并验证 gRPC/cluster 节点间通信。
5. 明确 Redis Fanout 哪些保留、哪些关闭。
6. 压测 `sub/pub/read/recv/call/group` 跨节点链路。
7. 验证节点重启、扩缩容、Topic rehash、proxy subscription 失效和客户端重订阅。
8. 验证不会重复投递 data/info/pres/call event。
## 11. 观测与验收
必须保留以下观测指标:
- Redis fanout publish/receive/deliver 计数。
- Redis fanout `skip_native_cluster` 计数。
- Redis fanout `no_local_topic`、`no_local_session`、`hub_full` 计数。
- 通话状态转换计数:started、accepted、finished、missed、declined。
- DB 条件更新失败计数,用于识别迟到事件和乱序事件。
- Cluster 模式下 proxy queue、TopicMaster RPC 失败、cluster unreachable、rehash 次数。
验收用例至少覆盖:
1. A、B 连接到不同 Pod,P2P 消息实时到达。
2. 接收方未订阅当前会话,仅在线 me,能收到未读提醒或补偿。
3. 通话 accept/hang-up 跨 Pod 到达,且不会被 missed 覆盖。
4. 多端登录同一账号,所有端收到业务事件。
5. 密码重置或后台踢端能清退所有 Pod 上的 session。
6. 大群在线成员分布在多 Pod,消息只投递在线成员,离线靠 seq 补拉。
7. Redis 短暂不可用时,关键状态不写坏,客户端可补偿。
## 12. 当前结论
当前阶段职责划分如下:
- 当前测试环境仍是 standalone + Redis Fanout,这是过渡状态。
- 目标是启用原生 Cluster,让 Topic master 承担内存高性能、强一致、强顺序的实时状态机。
- Redis 不退出架构,而是转为大数据量、缓存、补偿、全局 presence、session control 和异步支撑能力。
- 启用原生 Cluster 时,Topic 状态机相关的 Redis topic/call fanout 必须关闭或降级,避免 data/info/pres/call event 双路投递。
+458
View File
@@ -0,0 +1,458 @@
# IM 原生 Cluster 运维部署说明
## 1. 目标
本文给运维说明 IM Server 如何部署才能真正启用 Tinode 原生 Cluster。
目标效果:
- 多个 IM Pod 组成同一个 Tinode Cluster,不再是 standalone。
- 每个 Pod 拥有稳定、唯一的节点名。
- 节点之间通过 `cluster_config.nodes.addr` 建立内部 TCP RPC 通信。
- Topic 请求可以路由到唯一 Topic master,由 master 处理 Topic 内状态机、顺序和实时下发。
如果部署后日志仍出现:
```text
Cluster: running as a standalone server.
```
说明原生 Cluster 没有启用,跨 Pod Topic 状态机不会生效。
## 2. 原生 Cluster 启用条件
必须同时满足以下条件:
1. `cluster_self` 非空。
2. `cluster_self` 的值必须等于 `cluster_config.nodes` 中某个节点的 `name`。
3. `cluster_config.nodes` 必须包含所有 IM 节点,包括当前节点。
4. 每个节点的 `addr` 必须是其他 Pod 可访问的内部地址。
5. 每个节点必须监听自己的 `addr` 端口。
6. 节点之间网络策略、安全组、Service 必须放通 cluster 内部端口。
7. 至少 2 个节点才算有效 Cluster,生产建议 3 个节点起。
当前代码启动参数由 `docker/tinode/entrypoint.sh` 传入:
```bash
./tinode --config=${CONFIG} --static_data=$STATIC_DIR --cluster_self=$CLUSTER_SELF --pprof_url=$PPROF_URL
```
所以运维侧必须确保每个 Pod 的环境变量 `CLUSTER_SELF` 有值,并且值稳定。
## 3. 推荐部署形态
推荐使用 Kubernetes `StatefulSet`,不要用普通 `Deployment` 直接滚动多个副本。
原因:
- 原生 Cluster 依赖稳定节点名,例如 `im-server-0`、`im-server-1`、`im-server-2`。
- 原生 Cluster 依赖稳定 DNS,例如 `im-server-0.im-server-cluster.default.svc.cluster.local`。
- 普通 Deployment 的 Pod 名会变化,不适合作为 `cluster_self`。
- StatefulSet 配合 Headless Service 可以给每个 Pod 提供固定网络身份。
推荐结构:
```text
Headless Service: im-server-cluster
StatefulSet: im-server
Pod DNS:
im-server-0.im-server-cluster.<namespace>.svc.cluster.local
im-server-1.im-server-cluster.<namespace>.svc.cluster.local
im-server-2.im-server-cluster.<namespace>.svc.cluster.local
```
## 4. 端口规划
至少需要区分三类端口:
| 端口 | 用途 | 是否对公网暴露 | 说明 |
| --- | --- | --- | --- |
| `6060` | HTTP/WebSocket/API | 通过 Ingress/SLB 暴露 | 客户端连接入口。 |
| `16060` | Tinode gRPC 客户端端口 | 通常不对公网暴露 | 管理或内部 gRPC 客户端使用。 |
| `12000` | 原生 Cluster 内部 RPC | 不对公网暴露,只在 Pod 间放通 | `cluster_config.nodes.addr` 使用。 |
注意:`cluster_config.nodes.addr` 使用的是原生 Cluster 内部 TCP RPC 地址,不是 `grpc_listen` 的 `16060`。
建议统一所有 Pod 的 cluster 监听端口为 `12000`,通过 Pod DNS 区分节点:
```json
{
"name": "im-server-0",
"addr": "im-server-0.im-server-cluster.default.svc.cluster.local:12000"
}
```
不要沿用 docker compose 示例里的 `tinode-0:12000`、`tinode-1:12001`、`tinode-2:12002`,除非是在同一主机或 docker compose 网络里部署。
## 5. 配置文件要求
`server/tinode.conf` 默认配置是:
```json
"cluster_config": {
"self": "",
"nodes": [],
"failover": {
"enabled": false
}
}
```
这个配置不会启用 Cluster。
生产或测试环境需要提供完整配置。建议 3 节点配置如下:
```json
"cluster_config": {
"self": "",
"nodes": [
{
"name": "im-server-0",
"addr": "im-server-0.im-server-cluster.default.svc.cluster.local:12000"
},
{
"name": "im-server-1",
"addr": "im-server-1.im-server-cluster.default.svc.cluster.local:12000"
},
{
"name": "im-server-2",
"addr": "im-server-2.im-server-cluster.default.svc.cluster.local:12000"
}
],
"failover": {
"enabled": true,
"heartbeat": 100,
"vote_after": 8,
"node_fail_after": 16
}
}
```
说明:
- `self` 可以保持为空,由启动参数 `--cluster_self=$CLUSTER_SELF` 覆盖。
- `CLUSTER_SELF` 必须分别为 `im-server-0`、`im-server-1`、`im-server-2`。
- `nodes` 必须所有 Pod 完全一致。
- `addr` 必须能被其他 Pod 访问。
- 3 节点建议开启 failover;2 节点不建议开启 failover。
## 6. Kubernetes 示例
以下示例只展示 Cluster 相关关键配置,实际部署时需要合并现有镜像、环境变量、Secret、资源限制、探针、Ingress 和业务配置。
### 6.1 Headless Service
```yaml
apiVersion: v1
kind: Service
metadata:
name: im-server-cluster
namespace: default
spec:
clusterIP: None
selector:
app: im-server
ports:
- name: cluster
port: 12000
targetPort: 12000
- name: http
port: 6060
targetPort: 6060
- name: grpc
port: 16060
targetPort: 16060
```
### 6.2 客户端入口 Service
客户端入口可以继续使用普通 Service 或 Ingress,负载到所有 Pod:
```yaml
apiVersion: v1
kind: Service
metadata:
name: im-server
namespace: default
spec:
sessionAffinity: None
selector:
app: im-server
ports:
- name: http
port: 6060
targetPort: 6060
```
### 6.3 StatefulSet
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: im-server
namespace: default
spec:
serviceName: im-server-cluster
replicas: 3
selector:
matchLabels:
app: im-server
template:
metadata:
labels:
app: im-server
spec:
containers:
- name: im-server
image: <im-server-image>
ports:
- name: http
containerPort: 6060
- name: grpc
containerPort: 16060
- name: cluster
containerPort: 12000
env:
- name: CLUSTER_SELF
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: EXT_CONFIG
value: /etc/tinode/tinode.conf
- name: CLUSTER_PROXY_TO_MASTER_BUFFER
value: "4096"
- name: CLUSTER_PROXY_TO_MASTER_WORKERS
value: "16"
- name: CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS
value: "300"
- name: CLUSTER_ROUTE_RETRIES
value: "3"
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
value: "25"
resources:
requests:
cpu: "1"
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet:
path: /healthz
port: 6060
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /livez
port: 6060
timeoutSeconds: 3
volumeMounts:
- name: tinode-config
mountPath: /etc/tinode
readOnly: true
volumes:
- name: tinode-config
configMap:
name: im-server-tinode-conf
```
重点:
- `serviceName` 必须指向 Headless Service。
- `CLUSTER_SELF` 直接取 Pod 名,StatefulSet 下会稳定为 `im-server-0`、`im-server-1`、`im-server-2`。
- ConfigMap 中的 `cluster_config.nodes.name` 必须和 Pod 名一致。
- ConfigMap 中的 `cluster_config.nodes.addr` 建议使用 StatefulSet Pod DNS。
## 7. NetworkPolicy / 安全组要求
如果集群启用了 NetworkPolicy,需要允许 IM Pod 之间访问 cluster 端口:
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: im-server-cluster-allow
namespace: default
spec:
podSelector:
matchLabels:
app: im-server
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: im-server
ports:
- protocol: TCP
port: 12000
egress:
- to:
- podSelector:
matchLabels:
app: im-server
ports:
- protocol: TCP
port: 12000
```
如果没有 NetworkPolicy,可以跳过此项,但仍要确认云厂商安全组、服务网格策略没有拦截 Pod 间 TCP `12000`。
## 8. 启动顺序
建议:
1. 先部署 ConfigMap、Headless Service、普通入口 Service。
2. 再部署 StatefulSet。
3. 第一个 Pod 可以执行数据库初始化和升级。
4. 其他 Pod 设置 `NO_DB_INIT=true`,避免多 Pod 同时初始化数据库。
5. 等所有 Pod Ready 后再接入客户端流量。
如果当前镜像沿用 `docker/tinode/entrypoint.sh` 的逻辑:
- 第一个 Pod 可以保留 `NO_DB_INIT=false`。
- 其他 Pod 建议设置 `NO_DB_INIT=true`。
- 如果所有 Pod 同时启动,要确认数据库初始化脚本具备幂等能力,否则建议改成 init Job 单独执行。
## 9. 发布步骤建议
### 9.1 灰度前检查
执行:
```bash
kubectl -n default get pod -l app=im-server -o wide
kubectl -n default get svc im-server-cluster
kubectl -n default exec im-server-0 -- sh -c 'echo $CLUSTER_SELF'
kubectl -n default exec im-server-1 -- sh -c 'echo $CLUSTER_SELF'
kubectl -n default exec im-server-2 -- sh -c 'echo $CLUSTER_SELF'
```
期望:
```text
im-server-0
im-server-1
im-server-2
```
验证节点 DNS:
```bash
kubectl -n default exec im-server-0 -- sh -c 'nc -vz im-server-1.im-server-cluster.default.svc.cluster.local 12000'
kubectl -n default exec im-server-0 -- sh -c 'nc -vz im-server-2.im-server-cluster.default.svc.cluster.local 12000'
```
### 9.2 启动后日志检查
查看日志:
```bash
kubectl -n default logs im-server-0 | grep -i cluster
kubectl -n default logs im-server-1 | grep -i cluster
kubectl -n default logs im-server-2 | grep -i cluster
```
不能出现:
```text
Cluster: running as a standalone server.
```
如果出现该日志,优先检查:
- `CLUSTER_SELF` 是否为空。
- `CLUSTER_SELF` 是否不在 `cluster_config.nodes.name` 中。
- 配置文件是否确实被容器读取。
- `EXT_CONFIG` 是否指向了正确文件。
- `cluster_config.nodes` 是否为空。
如果出现节点连接失败,优先检查:
- `cluster_config.nodes.addr` DNS 是否能解析。
- 端口 `12000` 是否监听。
- Pod 间 NetworkPolicy 是否放通。
- Service `targetPort` 是否正确。
### 9.3 功能验收
至少验证:
1. A、B 两个账号分别连到不同 Pod,P2P 消息实时互达。
2. A、B 分别连到不同 Pod,语音通话 `ringing`、`accept`、`hang-up` 状态实时互达。
3. 同一个账号多端连接不同 Pod,`recv/read` 回执正常。
4. 群聊成员分布在不同 Pod,群消息实时下发正常。
5. 重启任意一个 Pod 后,客户端能重连,Topic 可重新订阅。
6. 日志无持续 cluster unreachable、proxy timeout、dial timeout。
## 10. 与 Redis Fanout 的关系
启用原生 Cluster 后,Redis 仍然保留,但职责要收敛:
- 保留:全局 presence、session control、踢端、同平台挤旧端、大群在线缓存、热点缓存、限流、幂等、补偿。
- 收敛或关闭:Topic data/info/pres 的 Redis topic fanout。
- 收敛或关闭:通话 `call_event/topic` 的 Redis fanout。
否则会出现同一个 Topic 事件既由 Cluster 下发、又由 Redis Fanout 下发,导致重复 data/info/pres/call event。
对应职责边界参考:
- `docs/IM-原生Cluster与RedisFanout职责边界.md`
## 11. 常见问题
### 11.1 日志仍然是 standalone
原因通常是 `cluster_self` 为空或配置文件里的 `nodes` 为空。
处理:
1. 确认 Pod 环境变量 `CLUSTER_SELF`。
2. 确认启动参数包含 `--cluster_self=$CLUSTER_SELF`。
3. 确认容器读取的是带完整 `cluster_config.nodes` 的配置。
### 11.2 Pod 能启动,但跨 Pod 消息还是不通
原因通常是 cluster 内部端口不通,或节点地址写错。
处理:
1. 从任意 Pod `nc -vz <peer-pod-dns> 12000`。
2. 检查 Headless Service selector 是否匹配 Pod label。
3. 检查 NetworkPolicy 是否放通 egress/ingress。
4. 检查 `cluster_config.nodes.addr` 是否写成了只在 docker compose 里可用的短域名。
### 11.3 扩缩容后部分 Topic 异常
原生 Cluster 的 Topic master 基于节点集合计算。扩缩容会改变节点集合,可能触发 Topic 重新路由。
建议:
- 生产先固定 3 节点。
- 不要频繁自动扩缩容 IM Server。
- 扩缩容必须配合完整压测和重连验证。
- 如果要从 3 节点扩到 5 节点,先更新所有 Pod 的 `cluster_config.nodes`,再按发布计划滚动。
### 11.4 2 节点能不能用
2 节点可以启用基础 Cluster,但不建议开启 failover,也不建议生产使用。
生产推荐 3 节点起,便于 leader 心跳和故障判断。
## 12. 运维上线检查清单
- [ ] 使用 StatefulSet,不使用普通 Deployment 承载 Cluster 节点身份。
- [ ] 创建 Headless Service。
- [ ] 每个 Pod 的 `CLUSTER_SELF` 等于 Pod 名。
- [ ] `cluster_config.nodes` 包含所有 Pod。
- [ ] 所有 Pod 使用同一份 `cluster_config.nodes`。
- [ ] 每个 `nodes.addr` 可从其他 Pod 访问。
- [ ] 放通 cluster 内部 TCP 端口 `12000`。
- [ ] 普通 HTTP/WebSocket 入口仍通过 Service/Ingress 负载到所有 Pod。
- [ ] 只有一个入口负责数据库初始化,或数据库初始化已改为独立 Job。
- [ ] 启动日志不再出现 `Cluster: running as a standalone server.`。
- [ ] 跨 Pod P2P、群聊、read/recv、通话状态已验收。
- [ ] 原生 Cluster 启用后,Topic 相关 Redis Fanout 已按职责边界关闭或降级。
+848
View File
@@ -0,0 +1,848 @@
# IM 后台需求文档
> 编写日期:2026-08-12
> 依据文档:`IM-产品需求服务端改造分析报告.md`、`IM-服务端多租户改造方案.md`、`IM-多租户第5至12章代码改造与测试方案.md`、`IM-前后端对接文档.md`、`IM-系统设计文档.md`
> 目标读者:后台产品、后端、管理端前端、测试
## 1. 文档目的
本文从现有 IM 产品与服务端设计文档中提取“后台管理端”需求,用于后续后台系统开发。本文只描述后台能力,不重复普通移动端聊天、通讯录、消息收发等客户端功能。
后台系统分为两类:
| 后台 | 使用对象 | 数据范围 |
| --- | --- | --- |
| 平台运营后台 | 平台管理员、运维、安全负责人 | 可管理全部租户,但跨租户操作必须二次验证和审计 |
| 企业管理后台 | 租户负责人、企业管理员、安全审计员、组织管理员 | 只能管理当前租户内的数据 |
所有后台能力必须建立在多租户隔离之上:后台 Token 必须包含可信 `tenant_id`、`uid`、角色、权限版本;平台后台的跨租户操作必须走独立后台 API,禁止通过普通 WebSocket `AsUser` 任意代操作用户。
## 2. 后台角色与权限
### 2.1 角色
| 角色 | 作用域 | 说明 |
| --- | --- | --- |
| `platform_admin` | 平台级 | 租户生命周期、平台配置、运维诊断、跨租户审计 |
| `tenant_owner` | 租户级 | 企业最高管理员,拥有本租户全部管理权限 |
| `tenant_admin` | 租户级 | 组织、用户、群、安全策略和工作台配置 |
| `security_auditor` | 租户级 | 实名、举报、反馈、审计读取和安全处置 |
| `org_admin` | 租户级或部门级 | 部门、成员、岗位、组织数据范围 |
| `report_admin` | 部门级 | 日报、月报、会议统计与提醒 |
| `group_admin` | 群级 | 群成员、公告、禁言、群文件等群治理 |
### 2.2 权限原则
1. 平台管理员可以选择租户上下文进行运维,但所有跨租户读取、导出、代操作必须记录审计。
2. 企业管理员只能访问自己租户的数据,不能通过 URL、Header、Body 指定其他 `tenant_id`。
3. 后台菜单和按钮由服务端返回权限裁剪结果,前端隐藏只是体验优化,服务端仍必须二次鉴权。
4. 敏感操作必须支持二次认证、幂等键、版本号或 `If-Match`,并写入审计。
5. 普通租户管理员不能修改或删除审计日志。
## 3. 后台功能总览
| 模块 | 平台后台 | 企业后台 | 优先级 |
| --- | --- | --- | --- |
| 租户管理 | 是 | 否 | P0 |
| 租户配置与配额 | 是 | 部分只读或申请变更 | P0 |
| 管理员与角色权限 | 是 | 是 | P0 |
| 用户与账号安全 | 支持跨租户运维 | 是 | P0 |
| 组织架构 | 否 | 是 | P1 |
| 群治理 | 运维只读/处置 | 是 | P1 |
| 工作台应用配置 | 平台模板 | 租户配置 | P2 |
| 会议、日报、月报 | 否 | 是 | P2 |
| 总经理信箱 | 否 | 是 | P2 |
| 实名认证审核 | 平台可审计 | 是 | P2 |
| 反诈与内容审核 | 平台策略/插件 | 租户策略/处置 | P2 |
| 举报与反馈工单 | 平台可审计 | 是 | P2 |
| 文件与存储治理 | 平台运维 | 租户范围 | P1 |
| Push、短信、TURN、对象存储配置 | 是 | 租户专属配置 | P1 |
| 审计日志 | 是 | 只读本租户 | P0 |
| Outbox 与后台任务 | 是 | 租户任务可见 | P1 |
## 4. 平台后台需求
### 4.1 租户管理
后台需要支持租户的创建、查看、编辑、停用、恢复、删除前置检查和删除归档。
租户状态:
| 状态 | 登录 | 读消息 | 写业务 | 管理操作 |
| --- | --- | --- | --- | --- |
| `provisioning` | 禁止 | 禁止 | 禁止 | 平台管理员可配置 |
| `active` | 允许 | 允许 | 允许 | 按角色允许 |
| `suspended` | 禁止新登录 | 可配置只读 | 禁止 | 平台管理员可恢复或导出 |
| `deleting` | 禁止 | 禁止 | 禁止 | 只允许删除任务 |
| `deleted` | 禁止 | 禁止 | 禁止 | 只保留审计 |
核心字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 内部租户 ID,普通客户端不返回 |
| `code` | 企业码,全局唯一,最长 64 字符 |
| `name` | 企业名称 |
| `tenant_desc` | 企业说明 |
| `state` | 租户状态 |
| `created_by/updated_by` | 操作人 |
| `created_at/updated_at` | 时间 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenants` | 租户列表,支持状态、企业码、名称过滤 |
| `POST` | `/admin/v1/tenants` | 创建租户 |
| `GET` | `/admin/v1/tenants/{tenant_id}` | 租户详情 |
| `PATCH` | `/admin/v1/tenants/{tenant_id}` | 修改名称、说明等基础资料 |
| `POST` | `/admin/v1/tenants/{tenant_id}/suspend` | 停用租户 |
| `POST` | `/admin/v1/tenants/{tenant_id}/restore` | 恢复租户 |
| `POST` | `/admin/v1/tenants/{tenant_id}/deletion-check` | 删除前检查 |
| `DELETE` | `/admin/v1/tenants/{tenant_id}` | 进入删除流程 |
验收点:
1. 租户 `code` 全局唯一。
2. 停用后旧 Token、WS 登录、HTTP 产品接口均拒绝。
3. 删除必须进入异步任务,不允许直接硬删业务表。
4. 所有状态变更写平台审计。
### 4.2 租户配置与配额
首期配置真源是 `im_tenant_config`。
| 配置 | 说明 | 默认 |
| --- | --- | --- |
| `max_users` | 有效用户数上限 | `0` 表示不限 |
| `max_groups` | 有效群数量上限 | `0` 表示不限 |
| `max_group_members` | 单群有效成员上限 | `0` 表示不限 |
后续可扩展配置:
| 配置域 | 示例 |
| --- | --- |
| 功能开关 | 加好友、建群、用户搜索、音视频、群文件、日报月报 |
| 安全策略 | 实名要求、青少年限制、风控模式、数据保留期 |
| 消息策略 | 文本长度、媒体类型、消息频控、撤回时限、编辑时限 |
| 文件策略 | 单文件大小、总存储配额、允许 MIME、病毒扫描 |
| Push 配置 | APNS/FCM 凭证、模板、锁屏隐私 |
| 外部服务 | 短信、REST 认证、TURN、对象存储、审核插件 |
| 企业品牌 | Logo、主题色、企业描述、客户端下载地址 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenants/{tenant_id}/config` | 查询租户配置 |
| `PATCH` | `/admin/v1/tenants/{tenant_id}/config` | 修改配额和策略 |
| `GET` | `/admin/v1/tenants/{tenant_id}/usage` | 查询用户数、群数、文件容量、消息量 |
| `GET` | `/admin/v1/tenants/{tenant_id}/features` | 查询功能开关 |
| `PATCH` | `/admin/v1/tenants/{tenant_id}/features` | 修改功能开关 |
验收点:
1. 注册、创建群、入群审批必须在事务内校验配额。
2. 超限返回稳定错误码,并记录当前用量、限制值、租户和操作。
3. 修改配置只影响当前租户,不能污染全平台缓存。
### 4.3 平台管理员管理
需求:
1. 创建、停用、恢复平台管理员。
2. 分配平台角色和权限。
3. 平台管理员登录、二次认证、Token 撤销。
4. 查看平台管理员操作审计。
敏感操作:
| 操作 | 要求 |
| --- | --- |
| 创建平台管理员 | 二次确认和审计 |
| 授权 `platform_admin` | 二次认证 |
| 跨租户代操作 | 必须填写原因,记录目标租户和目标对象 |
| 导出数据 | 审批或二次认证,记录导出范围 |
### 4.4 平台运维与任务
需求:
1. 查看运行节点、数据库版本、Adapter 类型和多租户能力。
2. 非 MySQL 或未完成租户能力的 Adapter 在产品模式下必须拒绝启动。
3. 查看 Outbox 事件积压、失败、死信和重试。
4. 查看文件垃圾回收、搜索索引任务、Push 投递、外部审核调用状态。
5. 支持按租户维度查看任务,但避免 Prometheus 高基数标签。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/system/health` | 系统健康 |
| `GET` | `/admin/v1/system/nodes` | 节点列表 |
| `GET` | `/admin/v1/system/outbox-events` | Outbox 查询 |
| `POST` | `/admin/v1/system/outbox-events/{id}/retry` | 重试事件 |
| `POST` | `/admin/v1/system/outbox-events/{id}/dead-letter` | 标记死信 |
| `GET` | `/admin/v1/system/audit-logs` | 平台审计 |
## 5. 企业后台需求
### 5.1 企业管理员与角色权限
企业后台需要支持本租户内管理员管理。
角色:
| 角色 | 权限 |
| --- | --- |
| `tenant_owner` | 全部租户管理权限 |
| `tenant_admin` | 用户、组织、群、安全、工作台配置 |
| `security_auditor` | 实名、举报、反馈、审计读取 |
| `org_admin` | 组织架构和成员维护 |
| `report_admin` | 日报、月报、会议统计 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/admins` | 企业管理员列表 |
| `POST` | `/admin/v1/tenant/admins` | 添加管理员 |
| `PATCH` | `/admin/v1/tenant/admins/{user_id}` | 修改角色 |
| `DELETE` | `/admin/v1/tenant/admins/{user_id}` | 移除管理员 |
| `GET` | `/admin/v1/tenant/roles` | 可用角色和权限点 |
验收点:
1. Tenant Admin 只能管理当前租户。
2. 授权、移除、代操作必须审计。
3. 不允许最后一个 `tenant_owner` 被删除或停用。
### 5.2 用户与账号安全管理
需求:
1. 用户列表、搜索、详情。
2. 创建、启用、停用、离职、恢复用户。
3. 重置密码、撤销 Token、踢下线;无手机号账号忘记密码时只能由当前租户管理员执行重置。
4. 查看登录设备、登录 Session、安全事件。
5. 查看用户实名状态、青少年状态、隐私设置摘要。
6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。
7. 支持注册邀请码单个/批量生成、有效期设置、撤销、明文精确查询和导出;邀请码固定为 5 位大小写英文字母,区分大小写且租户内不可重复。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/users` | 用户列表 |
| `POST` | `/admin/v1/tenant/users` | 创建用户 |
| `GET` | `/admin/v1/tenant/users/{user_id}` | 用户详情 |
| `PATCH` | `/admin/v1/tenant/users/{user_id}` | 修改资料或状态 |
| `POST` | `/admin/v1/tenant/users/{user_id}/reset-password` | 重置密码 |
| `POST` | `/admin/v1/tenant/users/{user_id}/revoke-sessions` | 撤销会话和 Token |
| `GET` | `/admin/v1/tenant/users/{user_id}/devices` | 登录设备 |
| `GET` | `/admin/v1/tenant/users/{user_id}/security-events` | 安全事件 |
验收点:
1. 停用用户不影响其他租户相同手机号或相同设备用户。
2. 管理员只能重置当前租户用户,不得根据用户名跨租户查找或操作。
3. 重置密码必须递增 `auth_version`,撤销旧 Token 和在线 Session,并写入包含操作者、目标用户、时间和结果的安全审计。
4. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。
5. 邀请码明文查看、查询和导出必须校验独立权限并写审计;普通日志不得记录邀请码。
### 5.3 组织架构管理
需求:
1. 部门树管理:新增、编辑、移动、删除部门。
2. 部门成员管理:添加、移除、调岗、设置负责人。
3. 组织搜索:按部门、用户、岗位搜索。
4. 离职用户处理:不可联系或从结果移除。
5. 组织数据权限:管理员只能管理权限范围内部门。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_org_units` | 部门树 |
| `im_org_members` | 部门成员、岗位、负责人 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/org/departments` | 部门树 |
| `POST` | `/admin/v1/tenant/org/departments` | 创建部门 |
| `PATCH` | `/admin/v1/tenant/org/departments/{id}` | 修改或移动部门 |
| `DELETE` | `/admin/v1/tenant/org/departments/{id}` | 删除部门 |
| `GET` | `/admin/v1/tenant/org/departments/{id}/members` | 部门成员 |
| `POST` | `/admin/v1/tenant/org/departments/{id}/members` | 添加成员 |
| `DELETE` | `/admin/v1/tenant/org/departments/{id}/members/{user_id}` | 移除成员 |
| `GET` | `/admin/v1/tenant/org/search` | 组织搜索 |
验收点:
1. 部门 `parent_id`、负责人、成员必须同租户。
2. 移动部门不能形成环。
3. 删除部门需要校验子部门和成员处理策略。
### 5.4 群治理管理
企业后台和群主/群管理员都需要群治理能力。后台侧侧重全租户范围查询、策略配置、违规处置和审计。
需求:
1. 群列表、群详情、群成员列表。
2. 群资料编辑:名称、头像、简介。
3. 群治理配置:入群审批、全员禁言、成员上传文件、成员私聊、成员邀请。
4. 群管理员管理:添加、删除管理员,管理员数量限制。
5. 禁言管理:添加、解除、过期自动失效。
6. 群主转让、解散群聊。
7. 群二维码管理:生成、撤销、过期。
8. 群文件管理:列表、搜索、删除、下载权限。
9. 群操作日志:角色变化、禁言、公告、解散。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_group_settings` | 群治理配置 |
| `im_group_members` | 群成员和角色真源 |
| `im_group_join_requests` | 入群申请 |
| `im_group_announcements` | 群公告 |
| `im_group_mutes` | 禁言记录 |
| `im_group_admin_logs` | 群管理日志 |
| `im_group_files` | 群文件索引 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/groups` | 群列表 |
| `GET` | `/admin/v1/tenant/groups/{topic}` | 群详情 |
| `PATCH` | `/admin/v1/tenant/groups/{topic}` | 修改群资料 |
| `GET/PATCH` | `/admin/v1/tenant/groups/{topic}/governance` | 群治理配置 |
| `GET` | `/admin/v1/tenant/groups/{topic}/members` | 成员列表 |
| `POST` | `/admin/v1/tenant/groups/{topic}/admins/batch` | 添加管理员 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}/admins/{user_id}` | 删除管理员 |
| `POST` | `/admin/v1/tenant/groups/{topic}/mutes/batch` | 添加禁言 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}/mutes/{user_id}` | 解除禁言 |
| `POST` | `/admin/v1/tenant/groups/{topic}/owner-transfer` | 群主转让 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}` | 解散群 |
| `GET` | `/admin/v1/tenant/groups/{topic}/files` | 群文件 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}/files/{group_file_id}` | 删除群文件 |
验收点:
1. 群业务真源与 Topic/Subscription/ACL 投影必须在同一事务内更新。
2. 管理员不能越级操作群主,普通管理员之间操作规则由治理策略决定。
3. 解散群必须撤销 Topic、二维码、邀请和文件新增权限。
### 5.5 工作台应用配置
需求:
1. 工作台模块配置:启用、禁用、排序、图标、名称、路由。
2. 根据组织角色、权限和功能开关裁剪用户可见模块。
3. 维护会议、日报、月报、总经理信箱等入口。
4. 工作台未读角标由系统事件更新。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_workbench_apps` | 租户工作台应用配置 |
| `im_workbench_user_apps` | 用户或角色级工作台配置 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/workbench/apps` | 应用列表 |
| `POST` | `/admin/v1/tenant/workbench/apps` | 新增应用 |
| `PATCH` | `/admin/v1/tenant/workbench/apps/{code}` | 修改应用 |
| `DELETE` | `/admin/v1/tenant/workbench/apps/{code}` | 删除或禁用应用 |
| `POST` | `/admin/v1/tenant/workbench/apps/reorder` | 排序 |
验收点:
1. 客户端只接受白名单内部 `route`。
2. 禁用模块后普通用户不可通过直接 URL 访问。
### 5.6 会议管理
需求:
1. 创建、编辑、取消会议。
2. 管理参会人、提醒未响应者。
3. 查看参会响应:`pending`、`accepted`、`declined`、`tentative`。
4. 会议过期后只读展示。
5. 生成系统通知和 Push。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_meetings` | 会议主表 |
| `im_meeting_participants` | 参会人响应 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/meetings` | 会议列表 |
| `POST` | `/admin/v1/tenant/meetings` | 创建会议 |
| `GET` | `/admin/v1/tenant/meetings/{id}` | 会议详情 |
| `PATCH` | `/admin/v1/tenant/meetings/{id}` | 更新会议 |
| `POST` | `/admin/v1/tenant/meetings/{id}/cancel` | 取消会议 |
| `POST` | `/admin/v1/tenant/meetings/{id}/reminders` | 提醒未响应者 |
### 5.7 日报与月报管理
需求:
1. 日报/月报模板和校验规则配置。
2. 普通用户草稿、提交、修订。
3. 管理员按部门查看统计和明细。
4. 提醒未提交人员。
5. 查看详情时记录已读状态。
6. 附件使用文件 ACL,不能通过旧 URL 越权下载。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_reports` | 日报/月报统一表 |
| `im_report_read_states` | 阅读状态 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/reports/daily/statistics` | 日报统计 |
| `GET` | `/admin/v1/tenant/reports/daily` | 日报列表 |
| `GET` | `/admin/v1/tenant/reports/daily/{id}` | 日报详情 |
| `POST` | `/admin/v1/tenant/reports/daily/reminders` | 日报提醒 |
| `GET` | `/admin/v1/tenant/reports/monthly/statistics` | 月报统计 |
| `GET` | `/admin/v1/tenant/reports/monthly` | 月报列表 |
| `GET` | `/admin/v1/tenant/reports/monthly/{id}` | 月报详情 |
| `POST` | `/admin/v1/tenant/reports/monthly/reminders` | 月报提醒 |
验收点:
1. 管理员只能看组织权限范围内成员。
2. 并发保存必须使用版本号,冲突返回当前最新版本。
3. 提醒接口需要限流并返回逐项结果。
### 5.8 总经理信箱
需求:
1. 配置是否启用、是否允许匿名、必填字段、附件策略、提示文案。
2. 用户提交意见或建议。
3. 后台查看、分派、处理、回复和关闭。
4. 匿名模式下业务接收方不可见提交人;安全审计是否保留内部追踪由企业政策决定。
5. 文案变更后,客户端提交时可要求重新确认。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_manager_mailbox` | 信箱提交和处理记录 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/executive-mailbox/config` | 查询配置 |
| `PATCH` | `/admin/v1/tenant/executive-mailbox/config` | 修改配置 |
| `GET` | `/admin/v1/tenant/executive-mailbox/submissions` | 提交列表 |
| `GET` | `/admin/v1/tenant/executive-mailbox/submissions/{id}` | 提交详情 |
| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/assign` | 分派 |
| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/reply` | 回复 |
| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/close` | 关闭 |
## 6. 安全、审核与工单后台
### 6.1 实名认证审核
需求:
1. 查看实名认证列表:未认证、待审核、已通过、已拒绝、申诉中。
2. 查看脱敏信息,具备权限时按流程查看敏感原文。
3. 审核通过、拒绝、要求补充材料。
4. 实名策略配置:是否强制实名、允许跳过、提醒周期、重复身份策略。
5. 审核结果通过系统事件通知用户。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_realname_verifications` | 实名信息、审核状态、脱敏展示 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/security/realname/submissions` | 实名审核列表 |
| `GET` | `/admin/v1/tenant/security/realname/submissions/{id}` | 审核详情 |
| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/approve` | 通过 |
| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/reject` | 拒绝 |
| `GET/PATCH` | `/admin/v1/tenant/security/realname/policy` | 实名策略 |
验收点:
1. 姓名、证件号加密存储,普通日志不出现原文。
2. 审核操作必须审计。
3. 普通管理员无权读取敏感原文。
### 6.2 青少年模式策略
需求:
1. 配置青少年模式默认策略和限制范围。
2. 查看用户青少年模式状态。
3. 处理用户忘记独立密码后的重置流程。
限制范围首期建议包括:
1. 添加好友。
2. 加群和创建群。
3. 文件传输。
4. 音视频通话。
5. 工作台外部链接。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/security/youth-mode/policy` | 青少年策略 |
| `GET` | `/admin/v1/tenant/security/youth-mode/users` | 用户状态列表 |
| `POST` | `/admin/v1/tenant/security/youth-mode/users/{user_id}/reset-pin` | 重置独立密码 |
### 6.3 反诈与内容审核
需求:
1. 风控策略配置:高风险动作、提示文案、二次确认、阻断策略。
2. 审核插件配置:平台统一审核服务或租户专属审核服务。
3. 审核模式配置:`sync_block`、`async_hold`、`async_post_audit`。
4. 查看风险事件、确认、取消、过期、阻断记录。
5. 对已发布违规消息执行系统撤回。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/security/risk-policy` | 风控策略 |
| `GET/PATCH` | `/admin/v1/tenant/security/moderation-policy` | 内容审核策略 |
| `GET` | `/admin/v1/tenant/security/risk-events` | 风险事件 |
| `GET` | `/admin/v1/tenant/security/moderation-tasks` | 审核任务 |
| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/approve` | 审核通过 |
| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/reject` | 审核拒绝 |
| `POST` | `/admin/v1/tenant/messages/{message_id}/recall-by-review` | 审核撤回 |
验收点:
1. 客户端提交的审核状态一律忽略。
2. 插件返回 `REPLACE` 或 `RESPOND` 时不能修改 TenantID。
3. 风控确认 Token 单次、短期、绑定原动作。
### 6.4 举报工单
需求:
1. 配置举报原因,按目标类型区分用户、群、消息。
2. 举报列表、详情、证据、目标快照。
3. 工单状态:`received`、`processing`、`resolved`、`rejected`、`closed`。
4. 处置动作:警告、禁言、封禁用户、解散群、撤回消息、标记无效。
5. 重复举报合并策略。
6. 处理结果通知举报人,必要时通知被处置对象。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_abuse_reports` | 举报工单 |
| `im_evidence_files` | 证据文件 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/reports-abuse/reasons` | 举报原因配置 |
| `GET` | `/admin/v1/tenant/reports-abuse` | 举报列表 |
| `GET` | `/admin/v1/tenant/reports-abuse/{id}` | 举报详情 |
| `POST` | `/admin/v1/tenant/reports-abuse/{id}/assign` | 分派 |
| `POST` | `/admin/v1/tenant/reports-abuse/{id}/resolve` | 处置 |
| `POST` | `/admin/v1/tenant/reports-abuse/{id}/close` | 关闭 |
验收点:
1. 举报人必须能看到被举报目标,后台详情使用提交时固化快照。
2. 证据文件使用独立 ACL 和保留期。
3. 原消息删除或撤回后,举报证据仍可处理。
### 6.5 意见反馈工单
需求:
1. 配置反馈类别、最大正文、附件规则、是否允许联系方式、日志授权说明。
2. 反馈列表、详情、附件、设备和版本信息。
3. 分派、回复、关闭、标记重复。
4. 处理结果通过系统通知或客户端查询展示。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_feedbacks` | 反馈工单 |
| `im_evidence_files` | 反馈附件 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/feedback/config` | 反馈配置 |
| `GET` | `/admin/v1/tenant/feedbacks` | 反馈列表 |
| `GET` | `/admin/v1/tenant/feedbacks/{id}` | 反馈详情 |
| `POST` | `/admin/v1/tenant/feedbacks/{id}/assign` | 分派 |
| `POST` | `/admin/v1/tenant/feedbacks/{id}/reply` | 回复 |
| `POST` | `/admin/v1/tenant/feedbacks/{id}/close` | 关闭 |
## 7. 文件、搜索与数据治理后台
### 7.1 文件治理
需求:
1. 按租户查看文件用量、文件列表、状态和业务用途。
2. 支持群文件、收藏文件、举报证据、反馈附件、日报/月报附件分类管理。
3. 文件下载审计。
4. 清理未关联文件、过期文件和违规文件。
5. 配置文件大小、MIME、总存储配额、保留期。
文件状态建议:
```text
pending -> uploaded -> ready
pending/uploaded -> failed
uploaded(超时且无引用) -> orphaned -> deleted
```
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/files` | 文件列表 |
| `GET` | `/admin/v1/tenant/files/{file_id}` | 文件详情 |
| `POST` | `/admin/v1/tenant/files/{file_id}/quarantine` | 隔离文件 |
| `DELETE` | `/admin/v1/tenant/files/{file_id}` | 删除文件 |
| `GET/PATCH` | `/admin/v1/tenant/files/policy` | 文件策略 |
验收点:
1. 文件下载不能只靠 FileID,必须校验上传者或关联消息/Topic 读取权限。
2. 后台删除文件必须审计,并处理消息、收藏、举报等引用。
### 7.2 搜索治理
需求:
1. 消息索引状态查看。
2. 重建指定租户、Topic 或时间段索引。
3. 搜索敏感内容审计。
4. 查看搜索性能和失败任务。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/search/index-status` | 索引状态 |
| `POST` | `/admin/v1/tenant/search/reindex` | 重建索引 |
| `GET` | `/admin/v1/tenant/search/audit-logs` | 搜索审计 |
验收点:
1. 搜索索引不是授权来源,返回前仍需 Topic Read ACL 二次校验。
2. 用户退群或权限变化后必须立即阻断搜索结果。
## 8. 通知、Push 与外部服务后台
### 8.1 系统消息模板
需求:
1. 管理系统事件类型和模板版本。
2. 配置账号安全、好友、群、文件、通话、工作台、安全等通知模板。
3. Outbox 事件驱动系统消息、Push、索引和定时任务。
首期事件:
| 业务 | 事件 |
| --- | --- |
| 账号安全 | 新设备登录、密码修改、实名提醒 |
| 好友 | 新申请、通过、拒绝、删除 |
| 群 | 邀请、申请、审批、公告、禁言、管理员变更、群主转让、解散 |
| 文件 | 群文件上传、文件失效 |
| 通话 | 未接、拒绝、取消、忙线、通话结束 |
| 工作台 | 会议通知、会议变更、日报提醒、月报提醒、信箱处理 |
| 安全 | 反诈提示、举报受理、反馈受理 |
### 8.2 Push、短信、TURN、对象存储
需求:
1. 租户级 Push 模板、App 包名、APNS/FCM 凭证引用。
2. 短信服务配置和模板。
3. TURN 配置。
4. 对象存储 Bucket、Prefix、签名策略。
5. 外部 REST 认证服务配置。
6. 外部审核和风控插件配置。
验收点:
1. 租户没有配置时按明确策略失败,不得静默使用其他租户配置。
2. 密钥只保存引用,不把明文 Secret 放入普通业务 JSON。
3. 外部回调必须校验服务端签名的 TenantContext 和 nonce。
## 9. 审计与日志后台
### 9.1 审计范围
以下操作必须写审计:
1. 租户创建、停用、恢复、删除。
2. 平台管理员和租户管理员授权、移除、代操作。
3. 用户停用、离职、密码重置、数据导出。
4. 群主转让、管理员变更、禁言、解散群。
5. 实名审核、举报处置、反馈处理、匿名信箱读取。
6. 文件下载、删除、批量导出、搜索敏感内容。
7. 反诈二次确认、内容审核处置。
8. 外部服务配置修改。
### 9.2 审计字段
| 字段 | 说明 |
| --- | --- |
| `tenant_id` | 租户 |
| `actor_uid` | 操作人 |
| `actor_role` | 操作角色 |
| `action` | 操作类型 |
| `target_type` | 目标类型 |
| `target_id` | 目标 ID |
| `before_snapshot` | 变更前摘要 |
| `after_snapshot` | 变更后摘要 |
| `reason` | 操作原因 |
| `request_id` | 请求 ID |
| `ip` | 操作 IP,必要时脱敏 |
| `user_agent` | 操作客户端 |
| `created_at` | 操作时间 |
审计表只允许追加,不允许普通管理员修改或删除。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/audit-logs` | 租户审计 |
| `GET` | `/admin/v1/audit-logs` | 平台审计 |
| `POST` | `/admin/v1/audit-logs/export` | 审计导出 |
## 10. 后台公共能力
### 10.1 认证与会话
1. 后台登录必须使用独立后台入口,不与普通 App 登录混用权限。
2. 后台 Token 包含 `tenant_id`、`uid`、角色、权限版本、`auth_version`。
3. 支持刷新、撤销、踢下线、登录设备管理。
4. 高危操作需要二次认证或 `X-Action-Token`。
### 10.2 API 规范
后台 API 建议统一使用 `/admin/v1/*`。
成功响应:
```json
{
"code": 0,
"data": {},
"request_id": "01K...",
"server_time": "2026-08-12T05:30:00.000Z"
}
```
失败响应:
```json
{
"code": 30001,
"reason": "FORBIDDEN",
"message": "permission denied",
"details": {},
"request_id": "01K...",
"server_time": "2026-08-12T05:30:00.000Z"
}
```
公共要求:
1. 写操作必须带 `Idempotency-Key`。
2. 更新操作必须带版本号或 `If-Match`。
3. 列表统一使用 `limit + cursor`。
4. 请求必须带 `X-Request-ID`。
5. 不能从请求 Body、URL Query 或普通 Header 信任客户端自报 `tenant_id`。
6. 手机号、证件号、验证码、Token、设备 Token、文件签名不写日志。
### 10.3 数据表公共规则
1. 新业务表统一使用 `im_` 前缀。
2. 租户业务表必须包含 `tenant_id BIGINT NOT NULL`。
3. 唯一索引优先使用 `(tenant_id, business_key)`。
4. 子表外键优先使用 `(tenant_id, parent_id)`。
5. 更新、删除、聚合、清理任务必须带 `tenant_id`。
6. 平台级表和租户根表例外,但必须在 DDL 评审中说明作用域。
## 11. 开发优先级建议
### P0:后台地基
1. `/admin/v1` 认证中间件、RBAC、租户 Principal。
2. 平台租户管理:创建、停用、恢复、配置配额。
3. 企业管理员与角色权限。
4. 用户管理:停用、重置密码、撤销 Session。
5. 审计日志基础表和查询。
6. Outbox 查询与重试基础能力。
### P1:企业治理
1. 组织架构管理。
2. 群治理后台:群列表、群成员、管理员、禁言、解散、群文件。
3. 文件策略、文件列表、文件审计。
4. 外部服务配置:Push、短信、对象存储、TURN。
5. 功能开关和配额用量面板。
### P2:办公与安全业务
1. 工作台配置。
2. 会议管理。
3. 日报、月报统计和提醒。
4. 总经理信箱。
5. 实名认证审核。
6. 青少年策略。
7. 反诈、内容审核、举报、反馈工单。
## 12. 验收清单
- [ ] 平台后台与企业后台权限边界清晰,跨租户操作必须审计。
- [ ] 所有后台列表、详情、更新、删除都按租户隔离。
- [ ] 租户停用后普通用户登录和 HTTP 产品接口拒绝。
- [ ] 用户停用、重置密码、撤销 Token 不影响其他租户同手机号用户。
- [ ] 创建用户、建群、入群审批配额在事务内强制执行。
- [ ] 群主转让、禁言、管理员变更、解散群写审计并同步系统消息。
- [ ] 实名、举报、反馈、信箱等敏感数据脱敏展示并按权限读取。
- [ ] 文件下载、删除、导出均有资源级授权和审计。
- [ ] 外部服务密钥只保存引用,不写普通日志。
- [ ] Outbox 事件可查询、重试、死信处理,重复执行不产生重复副作用。
- [ ] 审计表只允许追加,普通管理员不可修改或删除。
+638
View File
@@ -0,0 +1,638 @@
# IM 增强项第一批开发
> 编写日期:2026-08-21
> 来源依据:`docs/IM-未实现后续增强项.md`、`docs/IM-前后端对接文档.md`、当前 `server/main.go` 和 `server/im_http.go` 路由实现。
> 目标:从后续增强项中筛选出当前可以直接开发、且不依赖短信服务商、FCM/APNs、TURN/STUN、SFU、病毒扫描服务等第三方能力的内容,作为第一批排期范围。
## 1. 筛选原则
第一批只纳入满足以下条件的增强项:
1. 只依赖当前 IM 服务、Tinode/IM 租户库、HotGo 控制库或已有 Redis/MySQL 基础设施。
2. 不依赖真实短信供应商、离线推送供应商、音视频媒体服务、病毒扫描引擎等第三方服务。
3. 优先补齐前后端对接文档中已定义,但当前代码未实现或实现不完整的接口。
4. 优先选择能直接提升前端联调完整度、生产可用性和接口一致性的项目。
以下内容不进入第一批:
1. 真实短信发送、短信模板、供应商回执和供应商失败重试。
2. FCM/APNs 离线 Push 投递和供应商错误码处理。
3. TURN/STUN 实际服务接入、SFU、群音视频媒体房间。
4. 第三方病毒扫描或文件内容安全服务。
5. 必须依赖 HotGo 管理后台页面完整改造才能闭环的运营后台功能。
## 2. 第一批开发范围总览
### 2.1 当前开发进度
> 进度更新:2026-08-21
| 开发项 | 状态 | 备注 |
| --- | --- | --- |
| 组织全局成员接口 | 已实现 | 新增 `GET /v1/im/organization/members`,支持部门、关键词、分页和 `has_more`。 |
| 月报 reopen | 已实现 | 新增 `POST /v1/im/reports/monthly/{id}/reopen`,按本人报告和 version 回退草稿。 |
| 实名更正申诉 | 已实现 | 新增 `POST /v1/im/security/realname/appeals` 和 `im_realname_appeals` 表。 |
| 群邀请外部接受 | 已实现 | 新增 `POST /v1/im/group-invitations/{id}/accept`。 |
| 主动创建/获取 P2P 会话 | 已实现 | 新增 `POST /v1/im/conversations/p2p`,复用好友/黑名单策略。 |
| 会话设置 | 已实现 | 新增 `GET/PATCH /v1/im/conversations/{topic}/settings` 和 `im_conversation_settings` 表。 |
| 多端云草稿 | 已实现 | 新增 `GET/PUT /v1/im/topics/{topic}/draft` 和 `im_topic_drafts` 表。 |
| 群文件列表、登记、删除 | 已实现 | 新增 `GET/POST/DELETE /v1/im/groups/{topic}/files`,复用现有群文件表。 |
| 普通消息转发 | 未实现 | 待下一步接入消息快照、目标权限校验和消息落库。 |
| 合并转发 Bundle 详情 | 未实现 | 待下一步新增 bundle 表和详情接口。 |
| 登出与指定会话撤销 | 部分实现 | `POST /v1/im/auth/logout` 已通过 Redis 精准撤销当前 token;按 session ID 撤销指定设备仍待统一 session 模型。 |
| 列表 `has_more` 统一 | 部分实现 | 新增/触达接口已补,历史接口需继续横向清理。 |
| 风控 token 单次消费接入业务接口 | 未实现 | 待和具体业务动作一起接入。 |
### 2.2 范围表
| 模块 | 优先级 | 开发项 | 是否依赖第三方 | 当前状态 |
| --- | --- | --- | --- | --- |
| 账号安全 | P0 | 登出与指定会话撤销 | 否 | 当前 token 登出已实现;指定 session 撤销待实现 |
| 消息转发 | P0 | 普通消息转发 | 否 | 仅转发目标接口已实现 |
| 消息转发 | P0 | 合并转发 Bundle 详情 | 否 | 文档有接口,代码未见路由 |
| 群聊 | P0 | 群文件列表、登记、删除 | 否 | 路径已不误兜底,但业务未实现 |
| 单聊 | P1 | 主动创建/获取 P2P 会话 | 否 | 文档有接口,代码未见路由 |
| 会话 | P1 | 会话设置 | 否 | 文档有接口,代码未见路由 |
| 会话 | P1 | 多端云草稿 | 否 | 文档有接口,代码未见路由 |
| 群聊 | P1 | 群邀请外部接受 | 否 | 群内邀请已有,外部接受接口未见路由 |
| 安全实名 | P1 | 实名更正申诉 | 否 | 实名提交/查询已有,申诉未见路由 |
| 组织架构 | P1 | 组织全局成员接口 | 否 | 部门成员和组织搜索已有,独立全局成员接口未见路由 |
| 办公协同 | P1 | 月报 reopen | 否 | 月报草稿/提交/详情已有,reopen 未见路由 |
| 协议一致性 | P1 | 列表 `has_more` 统一 | 否 | 部分列表仍只返回 `items,next_cursor` |
| 安全风控 | P1 | 风控 token 单次消费接入业务接口 | 否 | 可签发 `risk_action_token`,业务消费仍需补齐 |
## 3. 详细需求
### 3.1 登出与指定会话撤销
#### 目标
补齐客户端退出登录和安全页踢出指定设备能力,避免只能通过 `auth_version` 做全量失效。
#### 接口
1. `POST /v1/im/auth/logout`
- 退出当前登录设备。
- 需要使当前 token 对应 session 失效。
- 不影响同账号其他设备。
2. `DELETE /v1/im/security/sessions/{id}`
- 撤销指定 session。
- 只能撤销本人 session。
- 撤销后该 session 对应 token 立即不可用。
#### 数据建议
如当前没有独立 session 表,建议新增:
```sql
CREATE TABLE im_user_sessions (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
session_id VARCHAR(64) NOT NULL,
device_id VARCHAR(128) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT '',
token_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
last_seen_at DATETIME(3) NULL,
revoked_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_user_sessions_session (tenant_id, session_id),
KEY idx_im_user_sessions_user_status (tenant_id, user_id, status)
);
```
主键使用已有雪花 ID 工具生成。
#### 验收点
1. 当前设备登出后,当前 token 访问登录态接口返回未登录或 token 失效。
2. 其他设备 token 不受影响。
3. 删除指定 session 后,被删除设备无法继续访问。
4. 重复登出或重复删除返回幂等成功或稳定错误码。
### 3.2 普通消息转发
#### 目标
补齐普通聊天消息转发能力。现有 `GET /v1/im/forward-targets` 只解决目标选择,不等同于真正转发。
#### 接口
`POST /v1/im/messages/forward`
建议请求字段:
```json
{
"source_topic": "grp_xxx",
"message_seqs": [101, 102],
"target_topics": ["usrA-usrB", "grp_yyy"],
"mode": "single",
"client_mid": "uuid"
}
```
`mode` 建议支持:
1. `single`:逐条转发。
2. `bundle`:合并转发,生成转发卡片和 bundle。
#### 关键规则
1. 校验当前用户对源消息有查看权限。
2. 校验当前用户对每个目标会话有发送权限。
3. 不允许转发已被策略禁止转发的消息类型。
4. 文件消息需要重新校验文件访问权限。
5. 使用 HTTP 幂等能力,避免重复转发。
#### 验收点
1. 单条消息可转发到好友、群和文件助手。
2. 多条消息可逐条转发。
3. 无权限源消息、无权限目标会话返回稳定错误。
4. 同一幂等键重试不会重复投递。
### 3.3 合并转发 Bundle
#### 目标
支持合并转发卡片详情查看,使客户端可以打开“聊天记录”样式的转发包。
#### 接口
`GET /v1/im/forward-bundles/{id}`
建议响应结构:
```json
{
"id": "fb_123",
"title": "聊天记录",
"source_summary": {
"topic": "grp_xxx",
"message_count": 12
},
"items": [],
"next_cursor": "",
"has_more": false
}
```
#### 数据建议
建议新增:
```sql
CREATE TABLE im_forward_bundles (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
bundle_no VARCHAR(64) NOT NULL,
owner_user_id BIGINT NOT NULL,
source_topic VARCHAR(128) NOT NULL,
title VARCHAR(128) NOT NULL DEFAULT '',
message_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_forward_bundles_no (tenant_id, bundle_no),
KEY idx_im_forward_bundles_owner (tenant_id, owner_user_id, created_at)
);
CREATE TABLE im_forward_bundle_items (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
bundle_id BIGINT NOT NULL,
source_topic VARCHAR(128) NOT NULL,
source_seq INT NOT NULL,
sender_user_id BIGINT NOT NULL,
content_json JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_im_forward_bundle_items_bundle (tenant_id, bundle_id, id)
);
```
#### 验收点
1. 合并转发后生成 bundle。
2. 接收方可分页查看 bundle 内容。
3. Bundle 内容使用生成时快照,不因源消息后续不可见而展示异常。
4. 原始会话无权限用户不能直接借 bundle 访问源会话。
### 3.4 群文件
#### 目标
补齐群文件列表、上传登记和删除管理能力。当前 `/groups/{topic}/files` 已不再误返回群详情,但业务仍缺失。
#### 接口
1. `GET /v1/im/groups/{topic}/files`
- 支持分页、关键词、文件类型筛选。
2. `POST /v1/im/groups/{topic}/files`
- 将已上传文件登记为群文件。
3. `DELETE /v1/im/groups/{topic}/files/{file_id}`
- 删除或移除群文件。
#### 数据建议
可复用现有文件上传表,并新增群文件索引表:
```sql
CREATE TABLE im_group_files (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
topic VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
uploader_user_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
mime VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
KEY idx_im_group_files_topic (tenant_id, topic, status, created_at),
UNIQUE KEY uk_im_group_files_file (tenant_id, topic, file_id)
);
```
#### 权限规则
1. 只有群成员可查看群文件。
2. 只有群成员可登记群文件。
3. 删除权限建议第一批按群主、管理员、上传者放行。
4. 退群后不可继续通过群文件入口访问。
#### 验收点
1. 群成员可分页查看群文件。
2. 上传者登记文件后列表可见。
3. 普通成员不能删除其他人的文件,管理员和群主可以删除。
4. 非群成员访问返回无权限。
### 3.5 主动创建/获取 P2P 会话
#### 目标
补齐前端从好友资料页或通讯录进入单聊时的显式会话创建入口。
#### 接口
`POST /v1/im/conversations/p2p`
建议请求:
```json
{
"peer_user_id": "usr_xxx"
}
```
#### 规则
1. 必须校验双方好友关系。
2. 必须校验黑名单状态。
3. 如果 P2P topic 已存在,直接返回已有 topic。
4. 如果不存在,创建或确保 Tinode P2P 会话关系存在。
#### 验收点
1. 好友之间调用返回稳定 P2P topic。
2. 非好友或黑名单关系返回不可发起会话。
3. 重复调用返回同一个 topic。
### 3.6 会话设置
#### 目标
支持置顶、免打扰、会话备注等跨端同步设置。
#### 接口
1. `GET /v1/im/conversations/{topic}/settings`
2. `PATCH /v1/im/conversations/{topic}/settings`
建议字段:
```json
{
"pinned": true,
"muted": false,
"remark": "",
"version": 3
}
```
#### 数据建议
```sql
CREATE TABLE im_conversation_settings (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
topic VARCHAR(128) NOT NULL,
pinned TINYINT(1) NOT NULL DEFAULT 0,
muted TINYINT(1) NOT NULL DEFAULT 0,
remark VARCHAR(128) NOT NULL DEFAULT '',
version BIGINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_conversation_settings_user_topic (tenant_id, user_id, topic)
);
```
#### 验收点
1. 设置后同账号其他端读取一致。
2. 版本冲突返回稳定错误或按后写覆盖策略处理。
3. 非会话参与者不可设置。
### 3.7 多端云草稿
#### 目标
支持同账号多端同步草稿。
#### 接口
`PUT /v1/im/topics/{topic}/draft`
建议请求:
```json
{
"content": "draft text",
"entities": [],
"version": 1
}
```
#### 数据建议
```sql
CREATE TABLE im_topic_drafts (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
topic VARCHAR(128) NOT NULL,
content TEXT NULL,
entities_json JSON NULL,
version BIGINT NOT NULL DEFAULT 1,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_topic_drafts_user_topic (tenant_id, user_id, topic)
);
```
#### 验收点
1. 保存草稿后其他端可读取。
2. 清空草稿后其他端同步清空。
3. 非会话参与者不可写入草稿。
### 3.8 群邀请外部接受
#### 目标
支持被邀请人从邀请卡片或外部入口接受群邀请。
#### 接口
`POST /v1/im/group-invitations/{id}/accept`
#### 规则
1. 校验邀请存在、未过期、未失效。
2. 校验当前登录用户是被邀请人。
3. 按群治理策略决定直接入群或进入审批。
4. 重复接受应幂等返回当前状态。
#### 验收点
1. 被邀请人可接受有效邀请。
2. 非被邀请人不可接受。
3. 已过期或已撤销邀请返回稳定错误。
4. 需要审批的群不会绕过审批。
### 3.9 实名更正申诉
#### 目标
实名审核驳回或用户发现实名信息有误时,可提交更正申诉。
#### 接口
`POST /v1/im/security/realname/appeals`
建议请求:
```json
{
"reason": "证件姓名填写错误",
"attachments": ["file_1"]
}
```
#### 数据建议
```sql
CREATE TABLE im_realname_appeals (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '',
attachments_json JSON NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
reviewer_id BIGINT NULL,
review_comment VARCHAR(500) NOT NULL DEFAULT '',
reviewed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_im_realname_appeals_user (tenant_id, user_id, created_at),
KEY idx_im_realname_appeals_status (tenant_id, status, created_at)
);
```
#### 验收点
1. 用户可提交申诉。
2. 重复待处理申诉按策略限制。
3. 申诉记录可供后续后台审核使用。
### 3.10 组织全局成员接口
#### 目标
补齐前端需要全局组织成员列表时的统一入口,避免只能从部门成员或搜索接口拼装。
#### 接口
`GET /v1/im/organization/members`
建议查询参数:
1. `department_id`
2. `keyword`
3. `cursor`
4. `limit`
#### 规则
1. 复用组织成员表和组织搜索能力。
2. 按租户、部门、成员状态过滤。
3. 字段返回遵循现有隐私策略。
#### 验收点
1. 不传部门时返回全局成员分页。
2. 传部门时返回指定部门成员。
3. keyword 可搜索姓名、账号、手机号等允许搜索字段。
4. 返回 `items,next_cursor,has_more`。
### 3.11 月报 reopen
#### 目标
支持已提交月报按权限退回草稿,便于用户修正后重新提交。
#### 接口
`POST /v1/im/reports/monthly/{id}/reopen`
#### 规则
1. 只能 reopen 本人的月报,或由具备管理权限的用户 reopen。
2. 已归档、已锁定周期不可 reopen。
3. reopen 后状态回到草稿或待修改状态。
4. 保留审计记录。
#### 验收点
1. 已提交月报可按权限 reopen。
2. reopen 后可以继续保存草稿并重新提交。
3. 无权限用户不可 reopen 他人月报。
### 3.12 列表 `has_more` 统一
#### 目标
统一所有 HTTP 列表接口分页协议,减少前端针对每个接口单独判断。
#### 范围
第一批建议优先覆盖新增接口,以及以下已有高频接口:
1. 好友列表、新朋友列表、黑名单列表。
2. 群列表、群成员、群申请、群公告。
3. 会议列表、日报列表、月报列表。
4. 系统通知、举报/反馈相关列表。
5. 搜索类接口。
#### 响应要求
```json
{
"items": [],
"next_cursor": "",
"has_more": false
}
```
#### 验收点
1. 空列表返回 `items: []`、`has_more: false`。
2. 最后一页返回 `has_more: false`。
3. 有下一页时返回非空 `next_cursor` 和 `has_more: true`。
### 3.13 风控 token 单次消费
#### 目标
当前风控确认接口可签发 `risk_action_token`,第一批补齐业务接口消费该 token 的闭环。
#### 范围
优先接入以下业务动作:
1. 打开或发送高风险 URL。
2. 高频添加好友。
3. 高频群邀请或入群申请。
4. 举报提交中的高风险目标。
#### 规则
1. token 必须绑定租户、用户、动作、目标和过期时间。
2. token 只能消费一次。
3. token 只能用于原业务动作,不可跨动作复用。
#### 验收点
1. 未确认风险时业务接口返回 challenge。
2. 确认后携带 token 可继续原动作。
3. token 重复使用返回已消费。
4. token 用于其他动作返回无效。
## 4. 建议开发顺序
### 第一阶段:低风险补齐路由
1. 组织全局成员接口。
2. 月报 reopen。
3. 实名更正申诉。
4. 群邀请外部接受。
这些接口业务边界相对清楚,可以最快完成前端缺口补齐。
### 第二阶段:会话能力
1. 主动创建/获取 P2P 会话。
2. 会话设置。
3. 多端云草稿。
4. 登出与指定会话撤销。
这些能力涉及用户状态和多端一致性,建议一起设计数据表和事件同步。
### 第三阶段:消息与文件增强
1. 普通消息转发。
2. 合并转发 Bundle。
3. 群文件。
4. 收藏分享权限二次校验。
5. 文件下载短时签名 URL。
这些能力涉及消息权限、文件权限和幂等,建议放在同一阶段统一处理。
### 第四阶段:协议一致性与风控闭环
1. 列表 `has_more` 统一。
2. 风控 token 单次消费。
3. 好友申请、入群申请、@ 全体等限频。
4. 二维码吊销与扫码审计。
5. 关系操作审计。
这一阶段适合在主要业务接口补齐后做横向治理。
## 5. 本批次不处理但需保留的增强项
以下增强项仍然重要,但不纳入第一批:
1. 真实短信供应商发送、失败重试和发送审计。
2. FCM/APNs 离线 Push 投递。
3. TURN/STUN 配置实际接入和 SFU 群通话。
4. 病毒扫描和第三方内容安全检测。
5. HotGo 管理后台完整审核流、组织维护界面、信箱后台流转界面。
6. 大规模性能压测和专项压测报告。
这些项目建议作为第二批或外部依赖就绪后的专项处理。
## 6. 文档同步要求
每完成一个开发项,需要同步更新:
1. `docs/IM-前后端对接文档.md`
2. `docs/IM-系统设计文档.md`
3. `docs/IM-未实现后续增强项.md`
4. 必要时补充数据库变更到 `docs/IM-数据库变更汇总.sql`
已经完成的增强项应从 `IM-未实现后续增强项.md` 的待办中移除或标记为已进入基线,避免重复排期。
@@ -0,0 +1,162 @@
# IM 多开跨 Pod 实时投递压测方案
日期:2026-09-13
> 006 商户、2000 账号、两台压测机的可执行场景与统计口径见
> [IM-006商户双机消息压力测试方案](./IM-006商户双机消息压力测试方案.md)。本文保留为通用阶梯与指标说明。
## 1. 目标
验证 IM Server 使用 Redis realtime fanout 后,在多 Pod、多端登录、群消息和群业务事件场景下的实时投递能力。
本次压测重点观察:
1. 跨 Pod 在线消息是否能投递到所有目标 WS 连接。
2. 群消息在多 Pod 下的端到端延迟。
3. Redis Pub/Sub 发布、接收、投递失败和本地无连接的比例。
4. Hub 队列满、WS 写队列满、Redis 发布失败等异常是否可观测。
5. Pod 扩缩容、单 Pod 重启时是否出现明显丢消息或延迟尖刺。
## 2. 前置环境
建议至少准备:
- IM Server:3 个 Pod。
- Redis:独立实例或 Redis Cluster,开启 `group_fanout_enabled`。
- MySQL:使用压测租户库。
- 监控:开启 `/metrics`,采集 Prometheus 指标。
- 账号:准备足够多的群成员账号 CSV,格式为 `username,password[,token]`。
- 群 topic:提前创建一个大群,并把压测账号加入该群。
每个 Pod 必须配置不同的节点标识:
```json
"node_id": "im-pod-1"
```
或通过环境变量:
```shell
IM_NODE_ID=im-pod-1
```
## 3. 新增服务端指标
本次改造新增 Prometheus 指标:
```text
realtime_fanout_events_total{stage,kind,target_type,status}
realtime_fanout_delivery_duration_seconds_bucket{kind,target_type}
```
常用查询:
```promql
sum by (stage, kind, target_type, status) (rate(realtime_fanout_events_total[1m]))
```
```promql
histogram_quantile(
0.95,
sum by (le, kind, target_type) (rate(realtime_fanout_delivery_duration_seconds_bucket[5m]))
)
```
重点关注 status:
- `publish/ok`:本 Pod 成功发布到 Redis。
- `publish/error`:Redis 发布失败。
- `publish/skip_native_cluster`:启用了 Tinode 原生 cluster,跳过 Redis topic fanout。
- `receive/ok`:其他 Pod 收到 Redis fanout。
- `receive/malformed`:收到无法解析的消息。
- `deliver/local_delivered`:本 Pod 找到本地 WS 并投递。
- `deliver/no_local_session`:本 Pod 没有目标本地连接,属于正常情况。
- `deliver/hub_full`:Hub routeSrv 队列满,需要重点告警。
- `deliver/skip_no_session_store`:session store 未初始化。
## 4. 压测脚本
脚本位置:
```shell
loadtest/realtime_fanout_probe.go
```
运行示例:
```shell
go run ./loadtest/realtime_fanout_probe.go \
-ws "ws://im-pod-1.example.com/v0/channels,ws://im-pod-2.example.com/v0/channels,ws://im-pod-3.example.com/v0/channels" \
-apikey "$IM_API_KEY" \
-accounts loadtest/users.csv \
-topic grpXXXXXXXXXXXX \
-subscribers 3000 \
-publishers 10 \
-rate 200 \
-duration 10m \
-ramp 2m
```
脚本行为:
1. 按 CSV 账号建立 WS 连接。
2. 将连接均匀分布到 `-ws` 提供的多个 Pod 地址。
3. 每个连接登录并订阅同一个群 topic。
4. 从前 `-publishers` 个连接中选择发布者。
5. 按 `-rate` 总速率发布带 `probe_id` 和 `sent_at_unix_nano` 的群消息。
6. 所有订阅连接统计收到的 `{data}`,输出接收数、丢失率、p50/p95/p99/max 延迟。
输出示例:
```text
==== realtime fanout probe summary ====
published=120000 active_subscribers=3000 expected_deliveries=360000000 received=359998200 lost=1800 loss_rate=0.0005% unique_messages_seen=120000 errors=0 ctrl_acks=120000
latency p50=18ms p95=65ms p99=140ms max=900ms samples=359998200
```
## 5. 压测阶梯
建议按下面阶梯逐步推进,每个阶梯至少跑 10 分钟:
| 阶段 | Pod | 在线连接 | 发布者 | 总 QPS | 目标 |
| --- | --- | ---: | ---: | ---: | --- |
| S1 | 3 | 300 | 1 | 10 | 验证链路正确性 |
| S2 | 3 | 3000 | 10 | 100 | 验证常规群聊压力 |
| S3 | 3 | 10000 | 20 | 300 | 验证大群在线 fanout |
| S4 | 6 | 30000 | 50 | 500 | 验证扩容后的 Redis fanout |
| S5 | 6 | 30000 | 50 | 1000 | 探测上限和队列瓶颈 |
## 6. 通过标准
建议初始通过标准:
- `publish/error` 为 0。
- `receive/malformed` 为 0。
- `deliver/hub_full` 为 0。
- 压测脚本统计丢失率低于 0.01%。
- p95 端到端延迟低于 200ms。
- p99 端到端延迟低于 500ms。
- Pod CPU、内存、GC 无持续上升趋势。
- Redis CPU 不超过 70%,网络出入带宽有余量。
## 7. 故障注入
基础压测通过后执行:
1. 压测中重启一个 IM Pod,观察其他 Pod 是否继续投递。
2. 压测中扩容 Pod 数,观察 `receive/ok` 是否按 Pod 数增加。
3. 临时降低 Hub/WS 队列容量或制造慢客户端,确认 `hub_full`、WS 写失败日志可见。
4. Redis 短暂不可用,确认 `publish/error` 增加,恢复后新消息继续投递。
## 8. 结果记录
每轮记录:
- Git commit。
- IM Pod 数量和规格。
- Redis 规格。
- 在线连接数、发布 QPS、消息大小。
- 脚本输出摘要。
- Prometheus p95/p99。
- `publish/error`、`deliver/hub_full`、WS 写失败数量。
- Pod 重启/扩容期间的最大延迟和丢失率。
@@ -0,0 +1,488 @@
# IM 多开与跨 Pod 消息投递改造方案
> 日期:2026-09-12
> 适用范围:IMServer、Admin Server / HotGo、Redis、Kafka、JPush
> 关联文档:`docs/对接文档最新/IM-前后端对接文档.md`、`docs/IM-服务端架构与新功能开发指南.md`、`docs/IM-Redis三层数据分层改造方案.md`
## 1. 背景与目标
多开指同一用户在多设备、多 App 实例、多浏览器窗口或同设备多连接同时在线。服务端必须保证任意一个 Pod 收到的实时消息、业务通知、在线状态变化和账号治理命令,都能正确投递到同一租户内该用户在所有相关 Pod 上的 WebSocket / Long Polling 连接。
当前代码已有以下基础:
- `SessionStore` 管理本 Pod 内的实时连接。
- Redis presence 记录用户在线状态、session 集合和节点维度在线用户。
- 群消息已有 `redis_fanout.go`,通过 Redis Pub/Sub 通知其他 Pod。
- 群消息已拆到 `im_group_messages`,并具备 Redis seq、`client_mid` 幂等和 Kafka 异步持久化基础。
- `im_business_events.go` 仍使用租户库 business event outbox 做本地轮询投递。
本功能仍处于开发完善阶段,尚未上线。本次改动按“完整补齐多开跨 Pod 能力”处理,不按线上热修补或灰度兼容旧缺陷的思路处理。
本次改造目标:
1. 不再依赖租户库 business event outbox 作为 C 端实时通知主链路。
2. 建立统一的跨 Pod 实时投递总线,覆盖个人消息、群消息、系统消息、好友申请、好友状态、群治理、账号治理等需要多开同步的数据。
3. IMServer 只把本 Pod 连接保存在内存,全局在线、节点路由和跨 Pod 投递统一依赖 Redis。
4. Admin Server 不直连 IM 租户库,不直接操作用户 WS;后台写操作通过 Kafka Command 到 IMServer,由 IMServer 更新租户库并通过实时投递总线通知在线端。
5. 离线 Push 只做提醒和唤醒,不作为消息正文或业务状态真源。
## 1.1 前置确认
实施前必须确认生产部署是否启用 Tinode 原生 `cluster_config`:
- 如果未启用,Redis realtime dispatcher 是跨 Pod 实时投递唯一主链路。
- 如果启用,Topic 消息不要同时走 Tinode 原生 Topic master/proxy 和 Redis 广播式 dispatcher,否则可能重复投递。当前代码实现策略是:`globals.cluster != nil` 时,Topic 消息 fanout 不再额外发布 Redis realtime;session control 仍可通过 Redis 做幂等控制补充。
未启用原生 cluster 时,每个 Pod 必须配置唯一节点 ID。优先级为:
1. `node_id` 配置项。
2. `IM_NODE_ID` 环境变量。
3. `HOSTNAME` 环境变量。
4. 本地开发兜底值 `local`。
多 Pod 环境不能让所有实例都使用 `local`,否则 Redis fanout 会误认为其他 Pod 是本节点并跳过投递。
## 2. 核心结论
跨 Pod 实时投递使用 Redis Pub/Sub 或 Redis Stream,不使用数据库 outbox,也不使用 Kafka 作为 WebSocket fanout 主链路。
Kafka 仍保留以下职责:
- Admin Server 到 IMServer 的后台写命令。
- IMServer 到 Admin Server 的命令结果、审核结果、摘要同步。
- 私聊/群聊消息异步持久化。
- 文件安全检测、离线 Push 任务、审计、死信。
Redis 实时投递总线负责:
- 这个 Pod 收到消息后,通知其他 Pod。
- 其他 Pod 根据本地 `SessionStore` 找到真实 WS 连接并投递。
- 通过事件 ID / 消息 ID / `topic+seq` 做短期去重,优先复用已有 `ClaimShortState` / `ClaimShortStateValue` Redis 原语。
MySQL 负责:
- 最终状态和历史补偿。
- 用户、好友、群、系统通知、消息、文件、设备会话等可追溯数据。
- 客户端重连后按 `topic+seq`、通知 ID 或业务 ID 拉取缺口。
## 3. 总体架构
```text
Client A
-> Pod-1 WebSocket
-> Session / Topic / HTTP handler
-> 权限、幂等、seq、状态写入
-> 投递本 Pod 本地 sessions
-> Redis realtime bus
channel/stream: im:{tenant_id}:rt:{kind}
payload: envelope + server message
-> Pod-2 / Pod-3 subscriber
-> 按 tenant + target user/topic 找本 Pod sessions
-> queueOut 到对应 WS / Long Polling
Client B reconnect
-> hi -> token login -> sub me/topics
-> 按 seq/read/recv 或 notification cursor 从 MySQL/Redis 热缓存补齐
```
Admin Server 写操作:
```text
Admin Server
-> 校验后台权限
-> 写 hotgo 命令状态
-> Kafka im.admin.command
-> IMServer 任一 Pod 消费
-> 更新租户 MySQL / Redis / Topic 权限快照
-> Redis realtime bus 通知在线 C 端
-> Kafka im.admin.command.result 回写 Admin
```
## 4. 跨 Pod 实时投递总线设计
### 4.1 统一 Envelope
所有需要投递到其他 Pod 的实时事件统一包装:
```json
{
"event_id": "rt_01K...",
"tenant_id": "1",
"tenant_code": "default",
"kind": "message|topic_event|user_event|presence|session_control",
"target_type": "user|topic|device|session",
"target": "usr_xxx 或 grp_xxx 或 p2p_xxx",
"from": "usr_sender",
"source_node": "im-pod-1",
"skip_sid": "sid_sender",
"dedup_key": "message:grp_xxx:123",
"occurred_at": "2026-09-08T10:00:00.000Z",
"payload": {}
}
```
字段约定:
- `event_id`:全局唯一,消费者短期去重。
- `tenant_id`:必填,Redis key 和投递过滤必须带租户。
- `kind`:决定消费者如何解释 payload。
- `target_type=user`:投递给某用户在本 Pod 的所有连接。
- `target_type=topic`:投递给本 Pod 已订阅该 Topic 的连接。
- `target_type=device`:投递或控制某用户指定设备连接。
- `target_type=session`:精确控制某个 Session,主要用于本 Pod 本地,跨 Pod 可选。
- `skip_sid`:避免给发起连接重复投递,除非业务要求多端含当前端也收到。
- `dedup_key`:短期幂等键,例如 `msg:topic:seq`、`friend_request:id:version`。
- `payload`:可以是 Tinode `ServerComMessage` 的 JSON,也可以是业务刷新事件。
### 4.2 Redis Channel 建议
首版可以使用 Pub/Sub,要求客户端重连补偿兜底:
```text
im:{tenant_id}:rt:user
im:{tenant_id}:rt:topic
im:{tenant_id}:rt:presence
im:{tenant_id}:rt:session_control
```
如果需要更强的可观测和短暂重放,可升级为 Redis Stream:
```text
im:{tenant_id}:stream:rt
consumer_group = imserver:{node_id}
```
选择建议:
- 在线消息 fanout:Pub/Sub 足够,丢失后客户端按 seq 补偿。
- 踢设备、封禁、强制退出:建议 Pub/Sub + Redis control key 双写,避免订阅瞬断导致控制命令遗漏。
- Admin 重要业务变更:状态已落 MySQL,实时事件可以 Pub/Sub;客户端重进页面重新 GET。
### 4.3 本 Pod 投递规则
收到跨 Pod envelope 后:
1. 丢弃 `source_node == currentNodeID()` 的回环事件,除非显式需要自消费。
2. 使用 Redis `rt:dedup:{event_id}` 或本地 LRU 做短期去重。
3. 校验 `tenant_id`,不得跨租户投递。
4. 根据 `target_type` 选择本地 sessions:
- `user`:`session.uid == target && session.tenantID == tenantID`
- `topic`:`session.hasSub(target) && session.tenantID == tenantID`
- `device`:`session.uid == uid && session.deviceID == device_id`
5. 对每个匹配 Session 调用 `queueOut`。
6. 队列满时记录指标,不阻塞 Redis 订阅协程;客户端靠重连补偿。
## 5. 哪些数据需要多开处理
| 类别 | 场景 | 是否需要跨 Pod 实时投递 | 目标 | 最终补偿来源 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 个人消息 | P2P 文本、图片、语音、文件、名片、通话邀请消息 | 是 | P2P Topic 在线成员、发送人其他端 | 私聊消息表 / Topic seq | Pod-1 收到发送后,本 Pod 投递并通过 Redis 投递到接收人和发送人其他端所在 Pod。 |
| 群消息 | 群文本、媒体、文件、@、合并转发 | 是 | 群 Topic 在线成员 | `im_group_messages` / Redis 热消息 | 已有群 fanout 雏形,需要纳入统一 envelope 和节点去重。 |
| 发送回执 | `pub` ack、消息发送成功 seq | 当前连接本地为主,发送人其他端可选 | 发送人其他端 | 消息表 / seq | 当前连接必须收到 `ctrl 202`;其他端同步消息用 `{data}`,不依赖 ctrl。 |
| 送达/已读 | `note recv/read` | 是 | 对端所有在线端、本人其他端 | subscription 游标 | 多端 read/recv 取最大值,所有端刷新气泡状态和未读数。 |
| 正在输入 | `kp/kpa` 临时状态 | 是,但 TTL 短 | 对端在线端或群内订阅端 | 无 | 不落库,允许丢失,限频。 |
| 消息撤回/编辑 | recall、revision | 是 | Topic 在线成员 | 消息版本表 / 消息状态 | HTTP 成功后发 topic 事件,在线端刷新 `topic+seq+version`。 |
| 删除/清空记录 | 当前用户视图删除 | 是,用户维度 | 本人所有端 | 删除游标 / del 记录 | 只影响操作者多端,不影响对方。 |
| 系统消息 | 系统通知列表新增、已读 | 是 | 目标用户所有端 | `im_system_notifications` | 新增需要提醒所有端;已读要同步清红点。 |
| 好友申请 | 创建、同意、拒绝 | 是 | 申请人、被申请人所有端 | 好友申请表、系统通知表 | 刷新新朋友列表、bootstrap badge、会话 context。 |
| 好友关系 | 添加、删除好友、拉黑/取消拉黑、备注 | 是 | 双方或操作者所有端 | 好友/黑名单表 | 输入区权限、会话标题、通讯录和黑名单需要同步。 |
| 好友在线状态 | 上线、离线、最近活跃 | 是 | 订阅相关 P2P/me Topic 的在线端 | Redis presence | 不应扫库;presence 以 Redis TTL 为准。 |
| 群成员 | 入群、退群、踢人、邀请审批 | 是 | 群 Topic 在线成员、相关用户所有端 | 群成员表、订阅表 | 同步群人数、成员列表、会话权限和系统提示。 |
| 群角色 | 群主转让、管理员增删 | 是 | 群 Topic 在线成员、目标用户所有端 | 群成员角色表 | 必须刷新 Topic `perUser` 权限快照和 Redis 成员缓存。 |
| 群禁言 | 全员禁言、成员禁言/解禁 | 是 | 群 Topic 在线成员、被禁言用户所有端 | 群治理/禁言表 | 输入框权限不能只靠前端本地状态。 |
| 群公告 | 发布、更新、已读 | 是 | 群 Topic 在线成员;已读为本人所有端 | 群公告表、公告已读表 | 顶部入口、公告详情和红点同步。 |
| 群资料 | 名称、头像、简介 | 是 | 群 Topic 在线成员 | 群表 / Topic desc | 会话标题、列表摘要、成员页都要刷新。 |
| 群文件 | 上传登记、删除、审核结果 | 是 | 群 Topic 在线成员 | 群文件表、文件引用表 | 文件正文不走 Redis,只投递文件项变化事件。 |
| 会话设置 | 置顶、免打扰、群昵称、草稿 | 是,用户维度 | 本人所有端 | subscription/private 设置表 | 不影响其他用户。 |
| 未读数/入口红点 | bootstrap badge、会话 unread、系统通知 unread | 是 | 本人所有端 | seq/read、系统通知表、业务表 | 实时事件只触发刷新;服务端 GET 返回最终值。 |
| 设备会话 | 新设备登录、退出当前设备、踢设备、全端退出 | 是 | 被控制用户所有端或指定设备 | 设备会话表、Token auth_version、Redis control key | 需要跨 Pod 关闭 WS。 |
| 账号治理 | 封禁、解封、冻结、注销、密码重置 | 是 | 目标用户所有端 | 用户状态、auth_version | Admin command 触发,IM 执行后踢在线并清 Token。 |
| 安全风控 | 风险弹窗确认/取消 | 是,用户维度 | 操作者所有端 | 风控挑战表 / Redis action token | 防止多端同时继续敏感操作。 |
| 会议/工作台 | 会议通知、日报月报提醒、工作台红点 | 是 | 目标用户所有端 | 对应业务表 | 会议笔记不产生通知,但页面返回后主动刷新。 |
| 文件安全 | 审核通过/拒绝、文件失效 | 是 | 文件引用可见用户 | 文件元数据/引用表 | 通知在线端刷新消息卡片或文件列表。 |
| 离线 Push | 用户离线、后台或不可实时投递 | 不属于 WS 多开,但需去重 | JPush alias / registration_id | Push 任务表或 Kafka | 多设备允许同 alias,payload 只带定位字段。 |
## 6. IMServer 改造方案
### 6.1 新增 Realtime Dispatcher
建议新增模块:
```text
server/realtime_dispatcher.go
server/store/realtime_runtime.go
```
职责:
- 发布跨 Pod envelope 到 Redis。
- 订阅 Redis envelope 并投递到本 Pod `SessionStore`。
- 提供 `PublishToUser`、`PublishToTopic`、`PublishSessionControl` 等方法。
- 统一 `event_id`、`dedup_key`、`skip_sid`、租户校验和指标。
接口示意:
```go
type RealtimeTarget struct {
TenantID types.TenantID
Type string // user/topic/device/session
Target string
SkipSid string
}
func PublishRealtime(target RealtimeTarget, msg *ServerComMessage) error
func PublishRealtimeEvent(target RealtimeTarget, event IMRealtimeEvent) error
```
现有 `redis_fanout.go` 后续可以改为调用统一 dispatcher,保留兼容封装。
### 6.2 个人消息跨 Pod
当前群消息已有专用 fanout,个人消息也需要同等级处理。
发送流程:
1. WS 收到 P2P `pub`。
2. Session 完成租户、登录、Topic 归一化。
3. Topic 校验好友关系、黑名单、风控、文件 ACL。
4. 使用 `client_mid` 做幂等,生成 Topic seq。
5. 本 Pod 投递:
- 当前连接返回 `ctrl 202`。
- 发送人其他端收到同一 `{data}`。
- 接收人在本 Pod 的端收到 `{data}`。
6. Redis realtime bus 投递到其他 Pod:
- `target_type=topic`,`target=p2p...`
- 或拆成 `target_type=user` 给双方用户。
7. 异步持久化到 MySQL。
8. 不在线设备只生成 Push 任务,客户端重连按 seq 补齐。
关键点:
- `noecho=false` 时发送人多端应同步消息。
- `skip_sid` 只能跳过当前连接的重复 `{data}`,不能跳过发送人其他端。
- P2P 传入 `usr...` 时必须先归一化为真实 P2P Topic。
### 6.3 群消息跨 Pod
保留当前 Redis seq、`client_mid` 幂等、热消息和 `im_group_messages` 最终落库模型。
需要补强:
- `redis_fanout.go` 使用统一 envelope。
- Redis channel 从全局 `im:0:group_fanout` 调整为带租户或 envelope 必带 `tenant_id`。
- 投递时优先使用 `topic:{topic}:node:{node_id}:online_members`,避免所有 Pod 都扫描全部 session。
- 消费端支持 topic 已加载和未加载两种路径:
- Topic 已加载:投递到 `hub.routeSrv`,由 Topic 统一处理。
- Topic 未加载:直接扫描本 Pod 已订阅该 Topic 的 session 投递。
- Redis Pub/Sub 丢失不补发,客户端重连通过 `since=last_seq+1` 补齐。
### 6.4 业务状态事件跨 Pod
替换 `im_business_events.go` 的数据库 outbox 轮询模式。
HTTP handler 或 Store 写成功后:
1. 同步写 MySQL 最终状态。
2. 失效 Redis 缓存和 Topic 权限快照。
3. 调用 realtime dispatcher 发布事件。
4. 如需通知离线用户,投 Kafka Push 任务。
业务事件 payload 示例:
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "friend.request.created",
"payload": {"request_id": "frq_1"},
"ts": "2026-09-08T10:00:00Z"
}
}
```
群事件:
```json
{
"info": {
"topic": "grp_xxx",
"from": "usr_actor",
"what": "group",
"event": "group.role.updated",
"payload": {"topic": "grp_xxx", "user_id": "usr_2", "role": "admin"},
"ts": "2026-09-08T10:00:00Z"
}
}
```
### 6.5 Session Control 跨 Pod
设备退出、踢设备、全端退出、密码重置、账号封禁必须跨 Pod 生效。
建议 Redis key:
```text
im:{tenant_id}:session_control:user:{uid}:version
im:{tenant_id}:session_control:device:{uid}:{device_id}:version
im:{tenant_id}:session_control:event:{event_id}
```
流程:
1. IMServer 或 Admin command 更新 MySQL 用户状态、设备会话状态或 `auth_version`。
2. 写 Redis control version,TTL 可 1 到 7 天。
3. 发布 `target_type=user/device` 的 `session_control` envelope。
4. 各 Pod 收到后调用 `SessionStore.EvictUser` 或 `EvictUserDevice`。
5. Session 心跳或上行业务包也检查 Redis control version,防止 Pub/Sub 丢失。
### 6.6 Presence 多开规则
Redis presence 是全局在线真源:
- 用户登录成功:增加 session,写 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。
- 订阅群 Topic:写 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`。
- 心跳:刷新 session TTL、user TTL、已订阅群成员 TTL。
- 断线:移除 session;最后一个 session 离线时标记 `online=0`。
- 节点异常:依赖 TTL 自动过期。
好友在线状态变化:
- 不扫描所有用户。
- 只通知与该用户有订阅关系或正在会话的在线端。
- 客户端展示最近在线状态时,HTTP/WS 查询均以 Redis presence 为准。
## 7. Admin Server 改造方案
### 7.1 Admin 不直接做 WS 投递
Admin Server / HotGo 只负责后台权限、配置、审核和命令生产,不直接连接 IMServer 内存态,也不直接写租户库。
Admin 写操作统一:
1. 校验后台管理员权限。
2. 写 hotgo 命令表,记录 `command_id/event_id/idempotency_key/actor/request_id`。
3. 发送 Kafka `im.admin.command`。
4. 等待 IMServer `im.admin.command.result` 更新命令状态。
### 7.2 Admin 命令触发的多开场景
| Admin 操作 | Kafka Command | IMServer 动作 | C 端实时投递 |
| --- | --- | --- | --- |
| 封禁用户 | `user.disable` | 更新用户状态、递增 `auth_version`、踢在线 | `session_control user.disabled` 给目标用户所有端 |
| 解封用户 | `user.enable` | 更新用户状态、失效缓存 | 可选 `user.enabled` 给本人和相关管理视图 |
| 重置密码 | `user.password.reset` | 更新密码、递增 `auth_version`、撤销 Token | `session_control security.password_reset` |
| 踢设备 | `user.device.revoke` | 撤销设备会话 | `session_control device.revoked` |
| 解散群 | `group.delete` | 解散 Topic、撤销订阅和二维码 | `group.deleted` 给群在线成员 |
| 踢群成员 | `group.member.remove` | 更新成员状态和 Topic 订阅 | `group.member.removed` 给群成员和被踢用户多端 |
| 设置管理员 | `group.admin.set` | 更新角色、刷新权限快照 | `group.role.updated` |
| 删除管理员 | `group.admin.remove` | 降级角色、刷新权限快照 | `group.role.updated` |
| 禁言成员 | `group.member.mute` | 写禁言表、刷新权限 | `group.mute.updated` |
| 撤回/处置消息 | `message.moderate` | 更新消息状态、审核记录 | `message.recalled` 或 `message.moderated` |
| 文件审核处置 | `file.audit.update` | 更新文件状态和引用可见性 | `file.unavailable` 给引用可见用户 |
| 举报处理结果 | `abuse.report.resolve` | 更新举报状态、生成系统通知 | `system_notification.created` 给举报人 |
### 7.3 Admin 摘要同步
Admin 页面需要列表、统计和审核台数据,不应通过实时总线获得。
推荐:
- Admin 查询租户详情:调用 IM 内部只读 API。
- Admin 高频列表:维护 hotgo 摘要表。
- IM 状态变更后通过 Kafka result/event 同步摘要给 Admin。
这些摘要同步不属于 C 端多开实时投递,不要求发到用户 WS。
## 8. Redis、Kafka、MySQL 边界
| 组件 | 用途 | 不应用于 |
| --- | --- | --- |
| Redis Pub/Sub / Stream | 在线跨 Pod WS 投递、session control、presence 变化、短期去重 | 最终历史、长事务、后台命令可靠执行 |
| Redis KV/Set/Hash | presence、节点在线用户、Topic 在线成员、短期幂等、热消息 | 长期业务主数据 |
| Kafka | Admin command/result、消息持久化、Push、文件安全、审计、死信 | WebSocket 实时 fanout、验证码、在线状态 |
| MySQL 租户库 | 最终状态、历史查询、重连补偿、审计 | 在线连接表、实时 fanout |
| hotgo | Admin 后台主数据、配置、命令状态、摘要 | C 端 IM 消息和租户业务最终状态 |
## 9. 渐进实施计划
### 阶段一:统一跨 Pod 投递
1. 新增 realtime dispatcher。
2. 先增加 P2P 个人消息跨 Pod 投递。当前 P2P 是完全空白,应优先于群消息迁移。
3. 将现有群消息 Redis fanout 改为 dispatcher,并修复 `ServerComMessage` 内部路由字段 `json:"-"` 导致跨 Pod 丢 `TenantID/RcptTo/SkipSid` 的问题。
4. 增加用户维度事件投递,先覆盖好友申请、系统通知、已读同步。
5. 增加 session control 跨 Pod 踢设备/全端退出。
6. 同步上线 Redis 订阅健康检查、发布/消费/队列满指标和基础告警。
### 阶段二:替换 business event outbox
1. 已停止把 C 端在线通知写入 `im_business_events_outbox`,本次改造直接替换为 realtime dispatcher。
2. `publishIMRelationEvent`、`publishIMMeetingEvent` 等改为:
- 需要历史展示的系统通知写 `im_system_notifications`。
- 在线刷新直接调用 realtime dispatcher。
- 离线提醒投 Kafka Push。
3. 已移除 `startIMExternalBusinessEventPoller`。
4. `registration.invite_used` 改为注册提交后直接同步邀请码使用聚合;`security.password_reset` 改为密码重置后直接跨 Pod 踢线。
### 阶段三:Admin 联动
1. Admin Server 后台写操作全部通过 Kafka command。
2. IMServer command consumer 执行业务状态更新。
3. IMServer 执行成功后通过 realtime dispatcher 通知 C 端。
4. IMServer 通过 Kafka result/event 回写 Admin 命令状态和摘要。
### 阶段四:可观测和压测
1. 完善跨 Pod 投递指标:发布数、消费数、投递 session 数、丢弃数、队列满数、延迟。
2. 完善 Redis 订阅健康检查和告警。
3. 压测同一用户 10 端、多 Pod P2P 消息、千人群/万人群消息。
4. 验证 Pub/Sub 丢失后重连补偿路径。
## 10. 验收用例
| 用例 | 步骤 | 预期 |
| --- | --- | --- |
| 同用户多端收个人消息 | 用户 A 两端分别连 Pod-1、Pod-2,用户 B 发 P2P 消息到 Pod-1 | A 两端都收到消息,B 其他端也同步,消息只落一条。 |
| 群消息跨 Pod | 群成员分布在 Pod-1、Pod-2、Pod-3,Pod-1 收到群消息 | 所有在线订阅成员实时收到;离线成员重连按 seq 补齐。 |
| 重复发送幂等 | 断线后使用同一 `client_mid` 重发 | 返回同一 `seq`,不重复落库,不重复 Push。 |
| 已读多端同步 | A 在手机读到 seq=100 | A 其他端未读清除,B 多端看到 read 更新。 |
| 好友申请多端同步 | B 向 A 发好友申请 | A 所有在线端刷新新朋友红点和申请列表。 |
| 系统通知已读同步 | A 在 Pod-1 上把系统通知标记已读 | A 在 Pod-2 的端红点同步消失。 |
| 群角色变更 | Admin 将 A 设为管理员 | A 所有端群管理入口出现;群在线成员看到角色标识刷新。 |
| 禁言即时生效 | Admin 禁言 A,A 另一个端在 Pod-2 继续发消息 | 服务端返回 `GROUP_MUTED`,不能成功发送。 |
| 踢设备 | Admin 踢 A 的 device-1 | device-1 在任意 Pod 的连接被关闭,其他设备保持在线。 |
| 全端退出 | 密码重置或封禁 A | A 所有 Pod 上的连接关闭,旧 Token 后续登录失败。 |
| Pub/Sub 瞬断补偿 | 人为断开 Pod-2 Redis 订阅后发送消息,再恢复并重连客户端 | 实时期间可能漏收,但重连后按 seq 补齐,最终状态正确。 |
## 11. 代码落点建议
IMServer:
- `server/realtime_dispatcher.go`:统一跨 Pod 发布与订阅。
- `server/redis_fanout.go`:改造成群消息 dispatcher 适配层。
- `server/sessionstore.go`:补充按租户、用户、设备、Topic 的本地投递辅助方法。
- `server/session.go`:登录、心跳、断线时刷新 Redis presence 和 control version 检查。
- `server/topic.go`:个人/群消息发布成功后调用 dispatcher。
- `server/im_http.go`:好友、系统通知、群治理、会话设置等 HTTP 写成功后调用 dispatcher。
- `server/kafka_admin_commands.go`:Admin command 执行成功后调用 dispatcher。
- `server/store/group_message_runtime.go`:补充 realtime bus Redis 发布、订阅、去重 key。
Admin Server / HotGo:
- 后台写操作保留 command/outbox 到 Kafka 的可靠链路。
- 不新增任何直接调用 IM Pod WS 内存态的接口。
- 命令结果和摘要通过 Kafka result/event 回写 hotgo。
- 后台实时页面如需刷新,使用 Admin 自己的 WebSocket/SSE,从 hotgo 命令结果或摘要表驱动,不复用 C 端 WS。
## 12. 风险与注意事项
1. Pub/Sub 不保证离线重放,因此所有实时投递都必须有最终补偿来源。
2. `event_id` 去重不能代替消息 `client_mid` 幂等;二者层级不同。
3. 群消息 seq 只能有一个发号源,Redis 不可用时不能临时改用 MySQL 发号。
4. Session control 不能只靠 Pub/Sub,必须在心跳/上行业务包检查版本兜底。
5. Topic 权限变化后必须刷新内存快照和 Redis 缓存,否则多开端会出现权限漂移。
6. Push payload 不得带敏感正文,只带 `tenant_code/topic/seq/notification_id` 等定位字段。
7. Admin command 成功但实时投递失败时,不回滚业务状态;客户端通过重连或重新 GET 补偿。
8. 跨租户 Redis key、channel、payload 都必须带 `tenant_id`,不允许使用裸 `topic` 做全局路由。
@@ -0,0 +1,484 @@
# IM 工作台服务拆分迁移稳定方案
> 目标:将 `im_server` 中的工作台业务拆到独立仓库 `/Users/youshunlai/go_workspace/im_workbench`,先形成独立服务和独立发布边界;数据库暂不迁移,等接口全部稳定后再单独做工作台库迁移。
## 1. 结论
建议采用“独立仓库 + 分阶段迁移 + Nginx 分流 + Redis 统一 Token 鉴权”的方式:
1. `im_server` 保留 IM 核心能力:登录态校验、聊天协议、单聊/群聊、消息存储、长连接、在线状态、Redis 实时 fanout、Kafka 消息消费。
2. `im_workbench` 承接工作台能力:工作台入口、组织架构、考勤、会议、会议笔记、日报、月报、总经理信箱、工作台红点。
3. 当前阶段不迁移数据库,`im_workbench` 先连接现有租户库读取和写入工作台相关表。
4. 等 `im_workbench` 接口、鉴权、Kafka、监控全部稳定后,再单独启动数据库迁移专项。
5. 工作台接口直接切换为 `/v1/workbench/*`,由 Nginx/API Gateway 按路径分发到 `im_workbench`;`im_server` 不再代理工作台请求。
6. 迁移采用“先复制迁出、旧代码保留”的方式;只有 `im_workbench` 全部接口、数据库、Kafka、监控联调通过后,才删除 `im_server` 中的工作台旧代码。
一句话:网关分流,Redis 鉴权,先拆服务和接口,数据库后迁,最后清理 `im_server` 中的工作台旧代码。
## 2. 拆分范围
### 2.1 迁出到 im_workbench
以下业务从 `im_server` 迁移到 `im_workbench`:
| 模块 | 当前接口示例 | 迁移归属 |
|---|---|---|
| 工作台入口 | `GET /v1/workbench` | im_workbench |
| 组织架构 | `/v1/workbench/organization/*` | im_workbench |
| 考勤打卡 | `/v1/workbench/attendance/*` | im_workbench |
| 会议通知 | `/v1/workbench/meetings*` | im_workbench |
| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` | im_workbench |
| 日报 | `/v1/workbench/reports/daily*` | im_workbench |
| 月报 | `/v1/workbench/reports/monthly*` | im_workbench |
| 总经理信箱 | `/v1/workbench/mailbox*` | im_workbench |
| 工作台红点 | `workbench_total_unread`、模块未读 | im_workbench 计算,im_server 聚合透出 |
### 2.2 留在 im_server
以下能力继续留在 `im_server`:
| 模块 | 说明 |
|---|---|
| 登录与 Token 签发 | C 端登录仍由 IM 负责,登录成功后把 Token 会话写入 Redis。 |
| 聊天消息 | 单聊、群聊、消息 seq、消息落库、Redis fanout、WS 下发。 |
| 群治理 | 群资料、群成员、禁言、进群审批、群公告、群文件。 |
| 文件上传入口 | `POST /v0/file/u/` 暂留 im_server,工作台只保存业务引用;后续可单独拆文件服务。 |
| 系统通知投递 | 最终给 C 端的在线通知仍由 im_server 通过 WS 下发。 |
| Kafka 消费到 C 端 | Admin 或 Workbench 产生的业务事件,由 im_server 消费后找在线用户下发。 |
## 3. 目标架构
```text
C 端 App
|
| HTTPS
v
Nginx / API Gateway
|-- /v1/im/*、/v0/channels、/v0/file/u/ -> im_server
|-- /v1/workbench/* -> im_workbench
im_workbench
|
| MySQL
v
现有租户库中的工作台表
im_workbench
|
| Kafka: workbench 业务事件
v
im_server
|
| WebSocket
v
C 端在线用户
```
稳定边界:
1. `im_workbench` 不直接操作 IM 消息表、在线连接、WebSocket。
2. `im_server` 不直接写工作台业务表,也不代理工作台 HTTP 请求。
3. `im_server` 登录成功后将 Token 会话写入 Redis,`im_workbench` 每次请求从 Redis 校验 Token。
4. 两边共享用户 ID 口径:内部管理和工作台接口使用数据库 `BIGINT/int64`;聊天协议层仍可使用 `usr...`。
5. 工作台需要通知 C 端时投 Kafka 事件,由 `im_server` 统一下发。
## 4. 数据库处理方案
### 4.0 当前阶段原则
当前阶段数据库先不迁移。`im_workbench` 独立服务启动后,仍访问现有租户库中的工作台相关表,先保证新服务接口、Redis Token 鉴权、Nginx 路由、Kafka 事件和监控全部跑通。
代码层面禁止服务启动时自动建库建表。`im_server` 和 `im_workbench` 都只负责连接已有数据库和执行正常业务 SQL;所有 `CREATE DATABASE`、`CREATE TABLE`、`ALTER TABLE`、索引变更、初始化字典和种子数据,统一整理为 SQL 文件,经审核后由人工或运维发布流程执行。
当前 MySQL adapter 已禁用 `CreateDb` 自动建库建表和 `UpgradeDb` 自动升级 DDL。服务启动时如果数据库不存在会直接连接失败;数据库版本不一致时只报错提示执行人工 SQL,不再自动补表、补字段或补索引。
数据库迁移作为后置专项处理,触发条件是:
1. `/v1/workbench/*` 全部接口在本地、测试环境、前端联调中稳定。
2. `im_server` 中工作台旧接口确认没有客户端继续访问。
3. 工作台事件、通知、红点、文件引用链路全部验收通过。
4. 运维确认新库命名、备份、监控、账号权限和迁移窗口。
### 4.1 当前工作台相关表
当前这些表仍保留在租户库 `tinode` 中,暂不迁移;后续数据库专项时再纳入迁移范围:
| 表 | 用途 | 迁移建议 |
|---|---|---|
| `im_workbench_apps` | 工作台模块配置、排序、入口 | 暂留租户库;后续迁到工作台库或由 Admin 配置后同步。 |
| `im_org_departments` | 组织部门树 | 暂留租户库;后续迁到工作台库。 |
| `im_org_members` | 组织成员、岗位、联系方式 | 暂留租户库;保留 `user_id BIGINT` 关联 IM 用户。 |
| `im_org_visibility_overrides` | 组织可见范围覆盖 | 暂留租户库。 |
| `im_attendance_rules` | 考勤规则 | 暂留租户库。 |
| `im_attendance_shifts` | 考勤班次 | 暂留租户库。 |
| `im_attendance_records` | 考勤日记录 | 暂留租户库。 |
| `im_attendance_checkins` | 打卡流水 | 暂留租户库。 |
| `im_attendance_appeals` | 补卡/异常申诉 | 暂留租户库。 |
| `im_meetings` | 会议主表 | 暂留租户库。 |
| `im_meeting_participants` | 会议参与人 | 暂留租户库。 |
| `im_meeting_note_files` | 会议笔记文件引用 | 暂留租户库;文件实体仍在文件表/OSS。 |
| `im_daily_reports` | 日报 | 暂留租户库。 |
| `im_monthly_reports` | 月报 | 暂留租户库。 |
| `im_mailbox_config` | 总经理信箱配置 | 暂留租户库;后续可迁到工作台库或 Admin 公共库配置后同步。 |
| `im_mailbox_submissions` | 总经理信箱提交 | 暂留租户库。 |
### 4.2 不迁出的表
| 表/数据 | 原因 |
|---|---|
| `users`、认证、会话、设备 | 属于 IM 登录和账号基础能力。 |
| `topics`、`subscriptions`、`messages`、`im_group_messages` | 属于聊天主链路。 |
| 好友、黑名单、群成员、群治理表 | 属于 IM 社交关系和群聊能力。 |
| 文件实体表、OSS 元数据 | 暂不迁移,工作台只保存 `file_id` 引用。 |
| `im_system_notifications` | 建议先留在 im_server,作为统一 C 端通知中心。后续如要拆,可单独设计通知服务。 |
| `im_business_events_outbox` | 已废弃,不纳入迁移;当前代码不再自动建表,工作台拆分后事件统一走 Kafka 或实时投递链路。 |
### 4.3 后续目标库建议
后续接口稳定后,再给工作台单独库,命名按环境区分:
```text
本地:im_workbench
测试:im_workbench_test 或按运维规范命名
生产:im_workbench
```
如果继续保持“一个租户一个库”,则工作台也应支持租户库路由:
```text
hotgo.im_tenant
- code
- im_db_name
- workbench_db_name
- im_internal_base_url
- workbench_internal_base_url
```
正式方向建议在 `hotgo.im_tenant` 或租户配置表里增加工作台库配置,而不是让 `im_server` 读取工作台库 DSN。
### 4.4 后续数据迁移顺序
迁库等接口全部稳定后再做,不要和本轮服务拆分混在一起。后续建议分四步:
1. 建新库新表:在 `im_workbench` 库手动执行审核后的 SQL,创建同结构表,先不接线上流量。
2. 全量复制:从租户库复制历史工作台表数据到工作台库。
3. 增量同步:迁移窗口内对工作台写操作做双写,或由旧库 binlog/脚本同步到新库。
4. 切读切写:先灰度读新库,再切写新库,最后关闭旧库工作台写入。
迁移校验至少包括:
| 校验项 | 说明 |
|---|---|
| 行数校验 | 每张表按租户统计行数。 |
| 主键/业务唯一键校验 | `meeting_id`、`report_id`、`submission_id`、`department_id` 等必须一致。 |
| 更新时间校验 | 对比 `updated_at` 最大值,确认增量无遗漏。 |
| 抽样内容校验 | 抽样会议、日报、月报、考勤记录详情。 |
| 外键引用校验 | 会议参与人、会议笔记文件、组织成员部门引用不能断。 |
### 4.5 回滚原则
当前阶段没有数据库迁移,回滚以服务和路由为主。后续真正迁库时,旧库数据至少保留一个发版周期,不删除旧表。
回滚策略:
1. 如果服务拆分异常:关闭 `/v1/workbench/*` 路由,前端回退旧路径或旧版本。
2. 如果 `im_workbench` 业务异常:保留 `im_server` 旧工作台代码作为临时回退能力。
3. 如果 Redis 鉴权异常:停止工作台新路由,优先恢复登录态校验。
4. 如果 Kafka 通知异常:业务写入不回滚,只降级为客户端主动拉取和后台补偿推送。
5. 后续如果新库读写异常:再按迁库专项方案切回旧库。
## 5. 路由与鉴权迁移方案
### 5.1 Nginx 分流规则
工作台接口直接使用新路径 `/v1/workbench/*`,Nginx/API Gateway 按路径分流:
```text
/v1/im/* -> im_server
/v0/channels -> im_server
/v0/file/u/ -> im_server
/v1/workbench/* -> im_workbench
```
建议工作台目标路由:
| 原 IM 路径 | 新 Workbench 路径 |
|---|---|
| `GET /v1/im/workbench` | `GET /v1/workbench` |
| `/v1/im/organization/*` | `/v1/workbench/organization/*` |
| `/v1/im/attendance/*` | `/v1/workbench/attendance/*` |
| `/v1/im/meetings*` | `/v1/workbench/meetings*` |
| `/v1/im/meeting-notes*` | `/v1/workbench/meeting-notes*` |
| `/v1/im/meetings/{id}/notes*` | `/v1/workbench/meetings/{id}/notes*` |
| `/v1/im/reports/daily*` | `/v1/workbench/reports/daily*` |
| `/v1/im/reports/monthly*` | `/v1/workbench/reports/monthly*` |
| `/v1/im/mailbox*` | `/v1/workbench/mailbox*` |
不建议让 `im_server` 继续代理工作台请求。路由由 Nginx 分流,服务各自鉴权、各自访问自己的数据库。
### 5.2 Redis Token 鉴权
登录仍由 `im_server` 负责。登录成功后,`im_server` 将 Token 会话写入 Redis,`im_workbench` 直接从 Redis 校验登录态。
Redis Key 建议:
```text
im:session:token:{token_hash}
```
Value 建议使用 JSON:
```json
{
"user_id": 42,
"tenant_id": 1,
"tenant_code": "default",
"device_id": "ios-device-1",
"auth_level": "auth",
"state": "active",
"expires_at": "2026-09-13T18:00:00+08:00",
"issued_at": "2026-09-13T10:00:00+08:00"
}
```
请求 Header 保持 C 端统一:
| Header | 说明 |
|---|---|
| `Authorization` | `Bearer {token}`,两个服务都从 Redis 校验。 |
| `X-MeetingIM-APIKey` | 应用 API Key。 |
| `X-Tenant-Code` | 租户编码。 |
| `X-IM-Schema` | 协议版本。 |
| `X-Request-ID` | 链路追踪 ID。 |
校验流程:
1. 从 `Authorization` 取 Bearer Token,先做格式校验。
2. 对 Token 做 hash 后查 Redis,避免 Redis 明文 Key 暴露原始 Token。
3. 校验会话存在、未过期、`state=active`。
4. 校验 `X-Tenant-Code` 与 Redis 会话中的 `tenant_code` 一致。
5. 校验 API Key 是否可用于当前租户和客户端类型。
6. 将 `tenant_id`、`tenant_code`、`user_id`、`device_id` 写入请求上下文。
Token 失效场景:
| 场景 | 处理 |
|---|---|
| 用户主动退出 | 删除当前 Token Key。 |
| 踢设备 | 删除指定 `device_id` 对应 Token Key。 |
| 冻结/封禁账号 | 删除用户全部 Token Key,并通过 Kafka 通知在线连接断开。 |
| 修改密码 | 删除用户全部 Token Key。 |
| Token 过期 | Redis TTL 自动过期。 |
### 5.3 共享鉴权代码
建议抽一个轻量公共包,两个服务复用:
```text
meetingim_common/auth/session
```
公共包只放稳定协议:
1. Redis Key 生成。
2. Token hash 规则。
3. Session JSON 结构。
4. Header 解析。
5. 错误码定义。
不要把数据库访问、HTTP handler、业务权限放进公共包,避免公共包变成新的耦合点。
## 6. 事件与通知
工作台拆出后,工作台业务变更不再直接调用 `im_server` 代码函数,而是投 Kafka。
建议 Topic:
| Topic | 生产者 | 消费者 | 用途 |
|---|---|---|---|
| `im.workbench.event` | im_workbench | im_server | 工作台事件、红点、系统通知、WS 下发。 |
| `im.cache.invalidate` | im_workbench/Admin | im_server/im_workbench | 组织、工作台模块、配置变更后的缓存失效。 |
| `im.audit.event` | im_workbench | Admin/审计消费者 | 工作台敏感操作审计。 |
`im.workbench.event` 消息建议字段:
```json
{
"event_id": "wb_default_meeting_mtg_1_created_v1",
"tenant_id": 1,
"tenant_code": "default",
"actor_user_id": 42,
"module": "meeting",
"action": "meeting.created",
"resource_id": "mtg_1",
"target_user_ids": [42, 43],
"title": "会议通知",
"summary": "项目周会",
"data": {
"meeting_id": "mtg_1",
"badge_delta": 1,
"version": 1
},
"occurred_at": "2026-09-13T13:30:00+08:00"
}
```
消费原则:
1. `event_id` 全局幂等,消费端记录处理日志。
2. 系统通知写入失败可重试,超过次数进入死信。
3. WS 下发失败不影响业务写入,客户端重连后通过工作台接口和 bootstrap 补偿。
4. 工作台红点以服务端查询为准,Kafka 只做实时增量提醒。
## 7. 代码迁移步骤
### 7.1 第一阶段:服务骨架
在 `/Users/youshunlai/go_workspace/im_workbench` 新建 Go 服务:
```text
im_workbench/
cmd/workbench/
internal/http/
internal/store/
internal/kafka/
internal/config/
docs/
sql/
```
第一阶段只做:
1. 配置文件、日志、MySQL、Redis、Kafka 初始化。
2. 健康检查:`GET /healthz`、`GET /readyz`。
3. Redis Token 鉴权中间件。
4. 租户上下文解析。
5. 复制工作台接口 DTO 和响应结构。
### 7.2 第二阶段:迁移业务代码
从 `im_server` 迁出以下代码:
| 当前位置 | 迁移内容 |
|---|---|
| `server/im_http.go` | `serveIMWorkbench`、Organization、Attendance、Meetings、MeetingNotes、DailyReports、MonthlyReports、Mailbox 相关 handler。 |
| `server/store/store.go` | 工作台相关 Persistence Interface 和 mapper。 |
| `server/store/types/*.go` | `workbench.go`、`organization.go`、`attendance.go`、`meeting.go`、`reports.go`、`mailbox.go` 等类型。 |
| `server/db/mysql/tenant_adapter.go` | 工作台相关 SQL 实现。 |
| `server/im_business_events.go` | 工作台事件生产逻辑拆到 `im_workbench`,只保留 IM 消费下发。 |
| `server/kafka_workbench_audit.go` | 工作台事件生产侧迁出,IM 侧保留消费或下发处理。 |
迁移时要保留测试:
1. 原 handler 单测迁到 `im_workbench`。
2. `im_server` 原有工作台测试暂时保留,作为旧链路回归保护。
3. `im_workbench` 新增同等接口测试,确认新路径行为一致。
4. Kafka 事件用例拆为 Workbench 生产端测试和 IM 消费端测试。
### 7.3 第三阶段:Nginx 路由切换
`im_server` 中原工作台 handler 暂时不删除,不做主链路代理。新请求由 Nginx/API Gateway 直接分流到 `im_workbench`:
```text
客户端请求 /v1/workbench
-> Nginx/API Gateway
-> im_workbench Redis Token 鉴权
-> return response
```
路由层必须支持:
1. `/v1/workbench/*` 全量转发到 `im_workbench`。
2. `/v1/im/*`、`/v0/channels`、`/v0/file/u/` 继续转发到 `im_server`。
3. 透传 `Authorization`、`X-MeetingIM-APIKey`、`X-Tenant-Code`、`X-IM-Schema`、`X-Request-ID`。
4. 工作台服务超时建议 3 到 5 秒。
5. 工作台不可用时返回明确 `WORKBENCH_UNAVAILABLE` 或网关 502/504,不影响聊天主链路。
旧代码保留策略:
1. `im_server` 工作台旧 handler、store、SQL 先保留。
2. 前端切新路径后,测试环境持续观察一个发版周期。
3. `im_workbench` 全部接口、数据库、Kafka、监控确认稳定后,再从 `im_server` 删除旧工作台代码。
4. 删除前要确保没有客户端继续访问 `/v1/im/workbench`、`/v1/im/meetings*` 等旧路径。
### 7.4 第四阶段:接口稳定验收
当前阶段只做接口稳定验收,不做数据库切换。建议保留以下服务开关:
| 配置 | 说明 |
|---|---|
| `workbench.service.enabled` | 是否启用工作台独立服务。 |
| `workbench.route.enabled` | Nginx/API Gateway 是否启用 `/v1/workbench/*` 路由。 |
| `workbench.kafka.enabled` | 是否启用工作台事件 Kafka。 |
后续迁库专项再增加数据库切换开关:
```text
workbench.db.read_from=old|new|shadow
workbench.db.write_to=old|dual|new
```
## 8. 发布与回归
### 8.1 发布前检查
1. `im_workbench` 健康检查通过。
2. `im_workbench` 可连接现有租户库,并只访问工作台相关表。
3. Redis Token 鉴权与 `im_server` 登录态一致。
4. Nginx/API Gateway 工作台路由灰度开关默认关闭或仅测试环境开放。
5. Kafka Topic、消费组、死信队列已建。
6. 监控已覆盖 HTTP 成功率、耗时、Kafka 积压、DB 错误、网关转发失败。
7. 本阶段不要求新工作台库建表和数据复制。
### 8.2 必测用例
| 模块 | 用例 |
|---|---|
| 工作台入口 | 登录后返回模块排序、开关、未读角标。 |
| 组织架构 | 普通用户、手机号注册用户、邀请用户、测试账号可见范围正确。 |
| 考勤 | 配置查询、打卡、重复幂等、记录列表、申诉创建。 |
| 会议 | 创建、列表、详情、修改、取消、提醒、参会响应。 |
| 会议笔记 | 上传文件、绑定文件、详情、替换、删除、权限校验。 |
| 日报 | 创建草稿、保存、提交、列表、统计、提醒。 |
| 月报 | 创建、保存、提交、reopen、详情、统计。 |
| 总经理信箱 | 配置查询、匿名/实名提交、附件引用。 |
| 通知 | 工作台事件生成系统通知,在线用户收到 WS,离线用户重登可补偿。 |
| 回滚 | 关闭 `/v1/workbench/*` 路由后,前端回退旧版本或网关明确降级;旧代码未删前可切回旧路径。 |
## 9. 风险点
| 风险 | 处理 |
|---|---|
| 一次性拆代码和迁库导致回归面过大 | 本阶段不迁库,只拆服务和接口;数据库等接口稳定后单独迁移。 |
| 工作台依赖 IM 用户资料 | 通过 IM 内部查询 API 获取用户摘要,禁止直连 IM 库。 |
| IM 需要工作台红点 | 通过工作台内部接口或 Kafka 事件聚合,禁止 im_server 查工作台库。 |
| 文件上传仍在 im_server | 工作台保存 `file_id` 引用,文件服务后续再独立拆。 |
| Kafka 事件丢失 | 生产端直接投 Kafka,必要时使用业务失败表补偿;消费端使用幂等和死信队列。 |
| 前端路径切换风险 | 前端切到 `/v1/workbench/*` 前必须完成接口对照、灰度和回归。 |
| 数据不一致 | 当前阶段继续读写同一套租户库,避免双写不一致;后续迁库再做影子读和校验。 |
## 10. 推荐落地顺序
1. 在 `im_workbench` 建服务骨架、配置、健康检查、Redis Token 鉴权。
2. 迁移工作台类型、store interface、MySQL 实现和单测。
3. 迁移 `/v1/workbench`、组织架构、考勤接口。
4. 迁移会议、会议笔记、日报、月报、总经理信箱接口。
5. Nginx/API Gateway 增加 `/v1/workbench/*` 路由,默认关闭或只对测试环境开放。
6. 本地环境用新路径跑通全部工作台接口。
7. 测试环境启用新路由灰度,只切少量测试账号。
8. 全量接口稳定后,确认没有客户端访问旧 `/v1/im/*` 工作台路径。
9. 清理 `im_server` 内工作台业务实现,只保留 IM 主链路。
10. 单独启动数据库迁移专项,新建工作台库、复制数据、影子读、切读写。
## 11. 当前建议先做的第一步
第一步不要急着删 `im_server` 代码,建议先在 `im_workbench` 完成最小可运行服务:
1. 监听端口建议:`7070`。
2. 支持 `GET /healthz`、`GET /readyz`。
3. 支持 Redis Token 鉴权和统一 Header 校验。
4. 接入本地 MySQL、Redis、Kafka 配置。
5. 先迁 `GET /v1/workbench` 一个读接口做样板。
6. Nginx/API Gateway 加 `/v1/workbench` 测试路由,只开放样板接口。
这样第一步上线风险最低,也能验证服务拆分、Redis 鉴权、链路追踪、配置和部署是否顺畅。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
# IM 服务端主动推送消息梳理
> 本文先按当前代码实现梳理,后续确认产品口径后再合并到《IM-前后端对接文档》。
## 1. 术语和通道
服务端主动下行分两层:
1. Tinode 原生实时消息:通过 WebSocket/Long Polling 下发 `{data}`、`{info}`、`{pres}`、`{meta}` 等帧,覆盖聊天消息、回执、输入中、在线状态、通话信令。
2. IM 产品业务事件:HTTP 写操作或业务事务写入 `im_business_events_outbox` / 群业务 outbox 后,由服务端投递为 `{info}` 帧,前端据此刷新业务接口或局部状态。
当前产品业务事件不是聊天消息,不占普通会话消息 `seq`。系统通知列表是否出现记录,取决于是否额外写入 `im_system_notifications`。
## 2. 产品业务事件统一帧格式
### 2.1 用户维度事件
来自 `im_business_events_outbox`,投递到用户自己的在线会话。
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "friend.request.accepted",
"payload": {
"user_id": "usr_actor"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `info.topic` | 目标用户 ID,即事件接收人 |
| `info.from` | 操作人用户 ID;系统事件可能为空 |
| `info.what` | 固定为 `im` |
| `info.event` | 产品事件类型 |
| `info.payload` | 事件载荷,按事件类型变化 |
投递机制:
- 写入 outbox 后会立即尝试投递。
- Hub 不可用或队列满时保持 pending,后续 poller 每秒按在线租户重试。
- 投递成功后标记 delivered。
- `registration.invite_used` 与 `security.password_reset` 是内部消费事件,不作为普通 WS 业务事件下发。
### 2.2 群维度事件
来自群业务 outbox,投递到群 Topic,在线群成员收到。
```json
{
"info": {
"topic": "grp_xxx",
"from": "usr_actor",
"what": "group",
"event": "group.member.joined",
"payload": {
"topic": "grp_xxx",
"user_id": "usr_xxx"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
当前群事件投递时,还会给除操作人外的群成员写入一条 `im_system_notifications`,标题为“群聊动态”,summary 暂为事件类型。
## 3. 好友和关系事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 创建好友申请 `POST /v1/im/friend-requests` | 被申请人 | `info.what="im"`, `event="friend.request.created"` | `{"user_id":"申请人"}` | 有,写给被申请人,标题“新的好友申请” |
| 同意好友申请 `POST /v1/im/friend-requests/{id}/accept` | 申请人 | `info.what="im"`, `event="friend.request.accepted"` | `{"user_id":"通过人"}` | 当前有,写给申请人,标题“好友申请已处理” |
| 同意好友申请 | 通过人本人 | `info.what="im"`, `event="friend.request.accepted"` | `{"user_id":"通过人"}` | 当前无 |
| 拒绝好友申请 `POST /v1/im/friend-requests/{id}/reject` | 申请人 | `info.what="im"`, `event="friend.request.rejected"` | `{"user_id":"拒绝人"}` | 当前有,写给申请人,标题“好友申请已处理” |
| 拒绝好友申请 | 拒绝人本人 | `info.what="im"`, `event="friend.request.rejected"` | `{"user_id":"拒绝人"}` | 当前无 |
| 删除好友 `DELETE /v1/im/friends/{user_id}` | 被删除方 | `info.what="im"`, `event="friend.deleted"` | `{"user_id":"操作人"}` | 有,标题“好友关系已解除” |
| 删除好友 | 操作人本人 | `info.what="im"`, `event="friend.deleted"` | `{"user_id":"操作人"}` | 当前无 |
| 修改好友备注 `PATCH /v1/im/friends/{user_id}` | 操作人本人 | `info.what="im"`, `event="friend.remark.updated"` | `{"user_id":"操作人"}` | 当前无 |
| 拉黑用户 `PUT /v1/im/blocks/{user_id}` | 操作人本人 | `info.what="im"`, `event="relationship.blocked"` | `{"user_id":"操作人"}` | 当前无 |
| 取消拉黑 `DELETE /v1/im/blocks/{user_id}` | 操作人本人 | `info.what="im"`, `event="relationship.unblocked"` | `{"user_id":"操作人"}` | 当前无 |
前端建议动作:
- 收到 `friend.request.created`:刷新新朋友列表 `GET /v1/im/friend-requests`,刷新 `GET /v1/im/bootstrap` 的 `entry_badges.new_friends_pending_count`。
- 收到 `friend.request.accepted/rejected`:刷新新朋友列表;accepted 时刷新好友列表、P2P 会话 context。
- 收到 `friend.deleted/relationship.blocked/relationship.unblocked`:刷新好友列表、黑名单列表、会话 context,重新判断输入区 `can_send`。
- 收到 `friend.remark.updated`:刷新好友列表和会话头显示名。
当前待确认点:
- 好友申请通过/拒绝、删除好友等操作人本人是否也要写入系统消息列表。当前代码只给对方或申请人写系统通知,操作人本人主要靠 WS 业务事件刷新。
- 对接文档现有示例写成 `sys` topic `{data}` 的 `system_event`,但当前代码实际实时推送为 `{info what:"im"}`。后续需要统一口径。
## 4. 消息编辑和撤回事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 撤回消息 `POST /v1/im/messages/{topic}/{seq}/recall` | Topic 在线成员 | `info.what="im"`, `event="message.recalled"` | `{"topic":"...","seq":1,"version":2}` | 无 |
| 编辑消息 `POST /v1/im/messages/{topic}/{seq}/revisions` | Topic 在线成员 | `info.what="im"`, `event="message.edited"` | `{"topic":"...","seq":1,"version":2}` | 无 |
前端建议动作:
- 收到 `message.recalled`:按 `topic + seq + version` 重新拉取或本地标记该消息为已撤回。
- 收到 `message.edited`:按 `topic + seq + version` 重新拉取消息详情或修订记录,更新气泡内容。
注意:
- 这两个事件当前 target_type 是 `topic`,投递到 Topic,而不是某一个用户。
- 数据库层和 HTTP 层都可能写入同类 outbox,依赖 `event_id` 幂等避免重复。
## 5. 群业务事件
| 触发点 | 接收方 | WS 事件 | payload 主要字段 | 系统通知 |
| --- | --- | --- | --- | --- |
| 创建群 | 群成员 | `info.what="group"`, `event="group.created"` | `topic`、群基础信息 | 除操作人外写入 |
| 更新群设置 | 群成员 | `event="group.settings.updated"` | `topic` | 除操作人外写入 |
| 成员资料/权限更新 | 群主、管理员、本人 | `event="group.member.updated"` | `user_id`、`member_sync=single` | 不写群系统消息 |
| 成员加入 | 群成员 | `event="group.member.joined"` | `user_id`、`topic`、`member_sync=single` | 写入群消息并参与个人未读与离线 Push |
| 入群申请创建 | 群主、管理员、申请人 | `event="group.join_request.created"` | `request_id`、`applicant_user_id`、`topic` | 普通成员不可见 |
| 入群申请处理 | 群主、管理员、申请人 | `event="group.join_request.accepted/rejected"` | `request_id`、`status`、`topic` | 普通成员不可见;接受时另发全群加入消息 |
| 群公告更新 | 群成员 | `event="group.announcement.updated"` | `topic`、公告信息 | 除操作人外写入 |
| 群禁言更新 | 群主、管理员、被操作人 | `event="group.mute.updated"` | `topic`、禁言信息、`member_sync` | 普通成员不可见 |
| 成员离群/移除 | 群主、管理员、离群成员 | `event="group.member.left"` | `user_id`、`member_sync=remove` | 普通成员不可见 |
| 群角色更新 | 群主、管理员、被操作人 | `event="group.role.updated"` | `user_id`、角色信息、`member_sync` | 普通成员不可见 |
| 群主转让 | 群成员 | `event="group.owner.transferred"` | `previous_owner`、`new_owner` | 除操作人外写入 |
前端建议动作:
- 首次进入、断线重连、本地无快照或收到 `member_sync=full`:分页拉取完整群成员。
- 收到 `member_sync=single`:调用 `GET /v1/im/groups/{topic}/members/{user_id}` 只刷新一个成员。
- 收到 `member_sync=remove`:从本地成员快照删除 `user_id`;若本地状态无法确认,再全量分页同步。
- 入群申请变化:管理员刷新入群申请列表和群 summary 待处理数。
- 群公告变化:刷新公告区。
- 成员加入/离开:刷新群成员数、成员列表和会话系统提示。
注意:
- 当前群事件实时帧 `what="group"`,不是 `what="im"`。
- 群系统通知的 summary 当前主要是事件类型,文案还比较粗,需要产品确认后优化。
## 6. 会议事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 创建会议 | 所有参会人 | `info.what="im"`, `event="meeting.created"` | `{"meeting_id":"...","version":1,"state":"scheduled"}` | 除操作者外写入,标题“会议通知” |
| 更新会议 | 所有参会人 | `event="meeting.updated"` | `meeting_id/version/state` | 除操作者外写入 |
| 取消会议 | 所有参会人 | `event="meeting.canceled"` | `meeting_id/version/state` | 除操作者外写入 |
| 发送会议提醒 | 待响应参会人 | `event="meeting.reminded"` | `meeting_id/version/state` | 除操作者外写入 |
| 更新参会响应 | 所有参会人 | `event="meeting.response.updated"` | `meeting_id/version/state` | 除操作者外写入 |
前端建议动作:
- 收到会议事件后刷新会议详情或会议列表。
- `meeting.reminded` 可触发本地提醒 UI。
## 7. 安全事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 风险挑战确认/取消 | 操作人本人 | `info.what="im"`, `event="security.risk.continue"` 或 `security.risk.cancel` | `{"risk_challenge_id":"...","decision":"continue|cancel"}` | 有,标题“安全操作确认” |
| 密码重置成功 | 无普通 WS 事件 | 内部事件 `security.password_reset` | `{"user_id":123}` | 当前用于清退该用户在线 session |
前端建议动作:
- 收到 `security.risk.*`:刷新安全验证页面状态。
- 密码重置后旧 token/session 会被服务端清退,客户端按登录失效流程处理。
## 8. 邀请码统计事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 注册成功使用邀请码 | 不下发给普通客户端 | 内部事件 `registration.invite_used` | `{"tenant_id":1,"invite_code_id":123,"user_id":456}` | 无 |
说明:
- 该事件当前由 IM 侧消费,用于把租户库 `im_registration_invite_uses` 聚合回 HotGo 控制库的邀请码统计。
- 普通 App 前端不需要监听。
## 9. Tinode 原生实时下行
这类不是产品 outbox 事件,但也是服务端主动下行,前端需要持续处理。
| 帧类型 | 触发点 | 接收方 | 前端动作 |
| --- | --- | --- | --- |
| `{data}` | 普通聊天消息、文件消息、通话邀请 `webrtc=started`、服务端替换通话结果消息 | Topic 订阅者 | 写入/更新会话时间线、更新未读 |
| `{info what:"read"}` | 对端已读回执 | Topic 订阅者 | 更新消息已读状态 |
| `{info what:"recv"}` | 对端送达回执 | Topic 订阅者 | 更新送达状态 |
| `{info what:"kp"}` | 对端输入中 | Topic 订阅者 | 显示输入中状态,超时自动消失 |
| `{info what:"call"}` | WebRTC `ringing/accept/offer/answer/ice-candidate/hang-up` | 通话对端 | 驱动通话状态机和 SDP/ICE 交换 |
| `{pres}` | 订阅、离线、在线、权限/订阅变化等 presence | 相关订阅者 | 更新在线状态、订阅状态、必要时刷新列表 |
| `{meta}` | 服务端响应 get/sub 中的 desc/sub/data/del/cred 等查询结果 | 请求方 | 合并服务端状态 |
| `{ctrl}` | 客户端请求结果 | 请求方 | 更新请求状态、错误提示、幂等结果 |
## 10. 当前文档与实现差异
当前《IM-前后端对接文档》部分位置把好友关系变化描述为 `sys` topic 的 `{data head.x-im-type="system_event"}`。代码当前更准确的模型是:
- 实时刷新事件:`{info what:"im"}` 或 `{info what:"group"}`。
- 系统通知列表:通过 `im_system_notifications` 暴露给 `GET /v1/im/system-notifications`,并不是所有实时事件都会写入。
- 聊天/系统 Topic `{data}` 仍用于普通消息时间线和 Tinode 原生 Topic 消息,不等同于业务 outbox 实时事件。
建议后续合并到对接文档时明确:
1. 前端必须监听所有 `{info}`,按 `what + event` 分发业务刷新。
2. 系统消息入口的未读数来自 `im_system_notifications`,不是 `{info}` 事件数量。
3. 好友申请通过后是否双方都写系统通知,需要产品确认;若确认双方都有,需要同步补代码。
@@ -0,0 +1,470 @@
# IM 服务端架构与新功能开发指南
> 文档状态:按 2026-09-08 代码实现整理
> 适用项目:`im_server`
> 文档目标:让新接手的开发者能够快速判断一个功能应放在哪里、如何调用、如何存储、如何做多节点一致性,以及如何验收。
## 1. 系统定位
`im_server` 是基于 Tinode 扩展的多租户 IM 业务服务,同时承担两类职责:
1. IM 实时核心:WebSocket 连接、登录会话、Topic 订阅、消息路由、在线状态、音视频信令。
2. C 端产品业务:注册、安全、好友、群治理、收藏、文件、工作台、会议、日报、举报等 HTTP API。
系统不是传统的 `controller -> service -> repository` 三层单体。它的核心是“Topic Actor 实时模型 + HTTP 业务扩展层 + Store 统一持久化边界”。开发新功能时必须先判断它属于实时 Topic 链路,还是 HTTP 业务链路,或者同时涉及两者。
## 2. 系统边界
### 2.1 服务与数据库归属
| 服务 | 直连数据库 | 负责数据 |
| --- | --- | --- |
| IM Server | 租户业务库,默认库为 `tinode` | 用户、认证、Topic、订阅、私聊/群聊消息、群业务、好友关系、文件元数据、C 端业务数据 |
| Admin / HotGo | `hotgo` | 租户注册表、系统配置、字典、后台账号、菜单权限、后台命令 Outbox、管理端摘要数据 |
原则:服务与库一一对应,Admin 不直连租户库,IM 不直连 `hotgo`。跨系统写操作通过 Kafka 命令/事件,必须同步返回的少量查询通过受控的内部 HTTP API。
### 2.2 租户隔离
目标模型是“一个租户一个业务库”:
- `tenant_id` 是请求上下文和路由键,不要因为租户库内没有 `tenant_id` 列就在代码中丢掉租户上下文。
- 所有业务 Store 调用必须显式传递 `types.TenantID`。
- `store.ForTenant` 和 `tenantBusinessAdapter` 负责验证租户与选择租户适配器。
- MySQL 的 `TenantRoutedAdapter.TenantAdapterFor` 负责将租户路由到对应连接池。
- 任何无租户上下文的业务查询都应失败关闭,不允许回退到旧的无租户查询。
## 3. 技术栈
| 领域 | 当前实现 | 用途 |
| --- | --- | --- |
| 语言 | Go 1.26 | 服务端主体 |
| 实时协议 | Tinode JSON Protocol、WebSocket、Long Polling、gRPC | 长连接、Topic 订阅、消息和集群通信 |
| HTTP | `net/http` + `http.ServeMux` | C 端 REST API、文件 API、内部 API |
| 数据库 | MySQL + `database/sql` + `sqlx` | 租户业务最终状态 |
| 热状态/缓存 | Redis 7,`go-redis/v9` | 在线状态、Seq、幂等、群热消息、对象缓存、关系 Set、限流、跨节点 fanout |
| 异步事件 | Kafka,`segmentio/kafka-go` | 跨系统命令、消息异步持久化、审计、缓存失效、失败重试与死信 |
| 文件 | 阿里云 OSS,保留 FS/S3 适配器 | 文件对象存储,业务库只存元数据和引用 |
| 文件安全 | 阿里云内容安全 | 上传完成后的异步审核 |
| 离线推送 | JPush,同时保留 FCM/TNPG 插件能力 | 离线提醒;客户端重连后按 Topic seq 补偿 |
| 音视频 | Tinode 通话信令 + 声网 RTC Token | 呼叫邀请/接听/挂断与 RTC 加入 |
| 认证 | basic/token/code/rest 可插拔认证器 | 密码、Token、验证码和外部认证 |
| 可观测性 | Go `log`、expvar、Prometheus exporter、pprof | 日志、运行指标和性能诊断 |
## 4. 运行时架构
```text
Client
|-- WebSocket /v0/channels
| -> Session -> Hub -> Topic actor -> Store/Redis
| |-> local sessions
| `-> Redis fanout -> other IM nodes -> WebSocket
|
|-- HTTP /v1/im/*
| -> protocol middleware -> bearer/API key/tenant
| -> handler -> Store mapper -> tenant adapter -> tenant MySQL
| |-> Redis cache/short state
| `-> business event -> online WebSocket/Kafka
|
`-- Upload/Download
-> media handler -> OSS
-> file metadata/link -> tenant MySQL
`-> file security task -> Kafka -> Aliyun security
Admin / HotGo
|-- Kafka admin command/config event -> IM consumer -> tenant business mutation
`-- signed internal HTTP <-------> IM internal read API
```
### 4.1 启动顺序
`server/main.go` 是服务入口,主要顺序为:
1. 读取 JSON with comments 格式的 `meetingim.conf`/`tinode.conf`。
2. 初始化日志、expvar、pprof 和集群 worker ID。
3. 打开 Store,校验数据库版本与租户适配能力,同时初始化 Redis runtime。
4. 初始化 Kafka、IM 内部 API 和 Admin 内部配置 API 客户端。
5. 初始化认证器、验证器、文件处理器、Push 和音视频。
6. 创建 `SessionStore` 和 `Hub`,启动 Redis 群消息 fanout 订阅。
7. 启动 Kafka Admin Command、Config Event、Cache Invalidate、Message Persist 和 Dead Letter 消费者。
8. 启动集群、gRPC、HTTP/WebSocket 路由和优雅退出。
任何新增的基础组件都应明确放在这个顺序中,初始化失败时应快速终止启动,不要让必需依赖在不可用状态下继续运行。
## 5. 核心业务模型
### 5.1 Session
`Session` 表示一条客户端连接,保存租户、用户、设备、认证等级、已订阅 Topic 和输出队列。一个用户可以同时有多个 Session。
登录和断线时需同步 Redis 在线状态;心跳用于刷新 presence TTL。不应仅依赖本进程 Session 判断全局在线状态。
### 5.2 Hub
`Hub` 是 Topic 路由中心,核心通道包括:
| 通道 | 用途 |
| --- | --- |
| `join` | 加载/创建 Topic,将 Session 附加到 Topic |
| `routeCli` | 客户端消息路由到 Topic |
| `routeSrv` | 服务端业务消息路由到 Topic/Session |
| `meta` | 未订阅 Topic 的元数据查询 |
| `unreg` | 卸载或删除 Topic |
| `userStatus` | 用户停用/恢复等状态变更 |
Topic 索引不是纯字符串,而是 `types.TopicKey{TenantID, Topic}`,以防止不同租户的同名 Topic 串数据。
### 5.3 Topic Actor
`Topic` 是会话和消息处理单元,通过 channel 串行处理加入、退出、客户端消息、服务端消息和元数据操作。主要分类包括:
| Topic | 含义 |
| --- | --- |
| `me` | 用户主 Topic,会话列表和用户级 presence |
| `fnd` | 搜索/发现 Topic |
| `usr...` | 客户端可见的 P2P 对端标识,入库前需归一化为真实 P2P Topic |
| `grp...` | 群聊 Topic |
| `chn...` | 频道 Topic |
| `slf...` | 个人收藏/保存消息 Topic |
| `sys...` | 系统通知 Topic |
涉及消息发送、已读、撤回、编辑、通话信令、在线投递时,优先复用 Topic/Session 链路,不要另造一套无状态 HTTP 投递逻辑。
## 6. 四类标准调用链路
### 6.1 WebSocket 实时消息
```text
Client packet
-> hdl_websock.go 解码
-> Session 认证/租户上下文
-> Hub.join / Hub.routeCli
-> Topic 权限和业务校验
-> Redis 原子 Seq + client_mid 幂等
-> 本地 Session 实时投递
-> Redis Pub/Sub 跨节点 fanout
-> Kafka persist 异步落 MySQL
```
群消息使用 `im_group_messages`,私聊仍使用私聊消息表。`SeqId` 全链路必须使用 `int64/BIGINT`,群 Topic seq 由 Redis 原子递增,数据库只作基线与最终持久化。
群热消息可在 Redis 保留 24 小时左右,历史补偿最终以 MySQL 为准。离线用户只接收 Push 提醒,重连后按 Topic seq 拉取缺口。
### 6.2 C 端 HTTP 业务请求
```text
/v1/im/*
-> withIMHTTPProtocol
-> API key + X-IM-Schema + X-Request-ID
-> Bearer token -> principal{tenant_id, uid}
-> 写请求 Idempotency-Key 中间件
-> serveIM... handler
-> store.<Domain> mapper
-> TenantBusinessAdapter
-> tenant MySQL / Redis
-> writeIMSuccess / writeIMError
```
所有已认证的 `POST/PUT/PATCH/DELETE` 默认要求 `Idempotency-Key`,幂等结果保留 24 小时。同一 key 使用不同请求体必须返回冲突,已完成请求应重放原响应。
HTTP 统一响应包含 `code/data/reason/message/details/request_id/server_time`。业务错误应返回稳定 `reason`,不应将 SQL 或内部异常直接暴露给客户端。
### 6.3 Admin 后台写操作
```text
Admin API
-> hotgo 本地权限校验
-> 同事务写 command + outbox
-> Kafka im.admin.command
-> IM consumer 校验 tenant/event/actor
-> event_id + consumer_group 幂等抢占
-> 租户库业务变更
-> 缓存失效 + Topic/Session 同步
-> Kafka im.admin.command.result
-> Admin 更新命令结果
```
不可重试的参数、权限、版本冲突应直接返回稳定失败原因。临时故障按退避策略重试,超过上限写 `im.dead.letter` 和租户失败表。人工重放必须生成新 `event_id`,并使用 `parent_event_id` 串起审计链。
### 6.4 文件链路
```text
Client request upload
-> IM 创建文件元数据
-> OSS 上传/回调完成
-> 租户库更新文件状态
-> 绑定消息/群文件/业务引用
-> Kafka im.file.security
-> 阿里云安全检测
-> 更新审核状态并通知用户
```
文件字节流不经 Kafka,Kafka 只传递文件 ID、OSS 对象信息和审核任务。任何业务文件都必须建立引用记录,否则垃圾回收可能将它当作孤儿文件删除。
## 7. 数据分层与一致性
### 7.1 进程内缓存
适合低频变更、全局或租户级、可短时间最终一致的数据:
- 租户基本配置和状态。
- 注册短信开关。
- 字典和第三方配置(OSS、文件安全、短信、JPush、Agora)。
更新来源为定时刷新和 Kafka `im.config.event`/`im.cache.invalidate`。进程内缓存不应与 Redis 重复缓存同一份高频业务对象。
### 7.2 Redis
Redis 用于跨节点共享且高频的数据:
- 用户在线状态、Session 和群成员节点索引。
- 群消息原子 `SeqId`、`client_mid` 唯一占位和状态。
- 24 小时左右的群热消息。
- 用户、Topic、Subscription、群设置、群成员、成员分页快照和未读数缓存。
- 好友/黑名单 Set。
- 验证码、Action Token、临时占位、限流等 TTL 状态。
- 群消息 Redis Pub/Sub 跨节点实时 fanout。
Redis key 必须包含统一前缀和租户维度,不允许跨租户共用业务 key。写 MySQL 成功后必须失效或刷新对应 Redis 数据,多节点通过 Kafka 失效事件收敛。
### 7.3 MySQL
MySQL 是最终业务状态、历史查询和审计依据。以下数据不得仅存 Redis:
- 用户、凭据、好友、黑名单、Topic、订阅和群治理最终状态。
- 私聊/群聊历史消息、撤回和编辑版本。
- 文件元数据、业务引用和安全审核结果。
- HTTP 幂等结果、Kafka 消费幂等、死信和审计数据。
### 7.4 一致性顺序
普通业务写的推荐顺序:
1. 校验租户、身份、权限、幂等键和业务版本。
2. 在同一事务中更新 MySQL 业务状态及 outbox(需要可靠事件时)。
3. 提交事务。
4. 失效本节点 Redis/进程内缓存。
5. 投递 Kafka 失效或业务事件,通知其他节点和客户端。
如果业务要求“先实时到达,再异步落库”,则必须先完成 Redis 幂等占位和 seq 分配,投递 Kafka 持久化事件,并为失败准备重试和死信补偿。
## 8. Kafka 责任边界
| Topic | 主要用途 |
| --- | --- |
| `im.admin.command` | Admin 到 IM 的业务写命令 |
| `im.admin.command.result` | IM 回传命令结果 |
| `im.config.event` | 租户、字典、第三方配置变更 |
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 |
| `im.message.persist` | 私聊消息异步落库 |
| `im.group.message.persist` | 群聊消息异步落库 |
| `im.message.push` | 离线 Push 任务,属增强链路 |
| `im.file.security` | 文件安全检测 |
| `im.cache.invalidate` | 多节点缓存失效 |
| `im.audit.event` | 跨系统审计 |
| `im.dead.letter` | 超过重试上限的事件 |
Kafka 不用于 WebSocket 建连、验证码、在线状态、实时群消息 fanout、OSS 字节流或必须当场返回结果的查询。
统一 Envelope 至少包含:`event_id`、`trace_id`、`tenant_id/tenant_code`、`source_service`、`target_service`、`event_type`、`biz_type`、`biz_id`、`idempotency_key`、`version`、`occurred_at`、`actor`、`payload`。
## 9. 代码组织
| 路径 | 职责 | 新功能常见修改 |
| --- | --- | --- |
| `server/main.go` | 进程入口、组件初始化、HTTP 路由注册 | 新增路由或新基础组件启动 |
| `server/session.go` | 连接、登录、请求分发、设备和 Session 状态 | 账号登录或连接级功能 |
| `server/hub.go` | Topic 加载与全局消息路由 | 路由、Topic 生命周期 |
| `server/topic.go` / `init_topic.go` | Topic actor、权限、订阅和消息处理 | 消息、群权限、Topic 内实时行为 |
| `server/datamodel.go` | WebSocket 客户端/服务端协议结构 | 协议字段或服务端推送类型 |
| `server/im_http.go` | C 端产品 HTTP API 的路由处理与通用约定 | 大部分 C 端业务入口 |
| `server/internal_api.go` | IM 向 Admin 提供的签名内部查询 API | 后台需要同步读取租户数据 |
| `server/admin_internal_config.go` | IM 调用 Admin 内部配置 API | IM 需要 hotgo 配置兜底查询 |
| `server/kafka_*.go` | Kafka runtime、生产、消费、重试、死信和各业务 handler | 跨系统或异步事件 |
| `server/im_business_events.go` | 业务事件持久化与在线投递 | 需主动推给 C 端的状态变更 |
| `server/redis_fanout.go` | 群消息跨节点实时投递 | 群大并发/未加载 Topic 投递 |
| `server/store/types/` | 业务实体、值对象、查询参数和稳定错误 | 先定义领域数据结构 |
| `server/store/store.go` | Store mapper、参数验证、租户边界、缓存编排 | 对 handler 提供统一持久化 API |
| `server/store/infrastructure.go` | HTTP 幂等、业务事件、Kafka 幂等/死信 Store | 可靠基础链路 |
| `server/store/group_message_runtime.go` | Redis seq、唯一 key、热消息、presence、缓存和限流 | Redis 新 key 或新的 TTL 状态 |
| `server/db/adapter.go` | 持久化能力接口 | 新增 Store 数据能力 |
| `server/db/mysql/tenant_adapter.go` | MySQL 租户业务 SQL 实现 | 业务 CRUD/事务 |
| `server/db/mysql/*_schema.go` | 扩展业务表建表/升级逻辑 | 新表、列、索引 |
| `server/media/` | FS/OSS/S3 文件处理插件 | 文件存储能力 |
| `server/filesafety/` | 文件安全适配器 | 文件检测能力 |
| `server/push/` | 离线 Push 插件 | 厂商 Push 接入 |
| `server/auth/` / `server/validate/` | 认证和凭据验证插件 | 登录或验证方式 |
| `server/*_test.go` | 单元与链路测试 | handler、Topic、Kafka、Redis 逻辑测试 |
| `server/db/mysql/*_test.go` | MySQL schema 和 SQL 回归 | 数据库变更测试 |
## 10. 新功能放置决策
| 问题 | 选择 |
| --- | --- |
| 是否是客户端建连、订阅、发消息或通话实时信令? | WebSocket + Session/Hub/Topic |
| 是否是 C 端业务 CRUD、列表、搜索、设置或审批? | `/v1/im/*` HTTP + Store |
| 是否是 Admin 对租户业务的写操作? | Admin 本地 command/outbox + Kafka + IM consumer |
| Admin 是否必须当场读取租户详情? | IM 受控内部 GET API |
| IM 是否需要读取 hotgo 的低频配置? | 进程内缓存 + Kafka 刷新 + Admin 内部 API 兜底 |
| 是否需要跨节点高频共享、TTL 或原子计数? | Redis |
| 是否需要最终可追溯的业务状态? | 租户 MySQL |
| 是否跨系统、可异步、需重试/削峰/审计? | Kafka |
| 是否为在线群消息实时扩散? | Redis Pub/Sub + WS,不用 Kafka 做主链路 |
| 是否为文件本体? | OSS;MySQL 只存元数据/引用 |
## 11. 新功能标准开发流程
### 11.1 需求分析
1. 从前后端对接文档提取请求、响应、权限、状态机、幂等、并发和通知要求。
2. 标出数据归属:`hotgo` 公共数据或租户库业务数据。
3. 选择入口:HTTP、WebSocket、Kafka Admin Command 或内部 API。
4. 列出需要同步的状态:MySQL、Redis、Topic 内存、进程内缓存、C 端推送。
5. 明确失败类型:可重试、不可重试、并发冲突、部分成功和补偿。
### 11.2 领域与数据库
1. 在 `server/store/types/` 按业务域定义实体、状态常量、查询参数和结果。
2. 在 `server/db/adapter.go` 增加最小必要的租户持久化接口,方法必须携带 `TenantID`。
3. 在 MySQL 适配器实现 SQL;复杂写操作使用事务,状态迁移在 SQL `WHERE` 中校验旧状态/版本。
4. 在对应 `*_schema.go` 添加幂等建表/升级 SQL,为高频查询、唯一幂等键和状态扫描建索引。
5. 在 `server/store/store.go` 增加 mapper,负责参数验证、调用 adapter 和缓存编排;handler 不应直接拼 SQL。
表命名遵循当前租户库风格,类型必须与 Go 全链路一致,尤其是 ID、seq、version 使用 `BIGINT/int64`。面向 JavaScript 客户端的 64 位 ID 优先以字符串输出,避免精度丢失。
### 11.3 API 或协议层
HTTP 功能:
1. 在 `server/im_http.go` 定义独立 request/response 结构,不要直接暴露数据库 row。
2. 使用 `decodeIMJSONBody`、`authenticateIMHTTPBearer`、`requireIMGroupMember` 等公共函数做解码、认证和权限检查。
3. 在 `server/main.go` 注册具体路由,路由顺序要避免通配前缀吞掉更具体的路径。
4. 使用 `writeIMSuccess`/`writeIMError` 统一输出,新增稳定业务 `reason`。
5. 资源更新需并发保护时使用 version/`If-Match`,不允许用 `version=0` 绕过乐观锁。
WebSocket 功能:
1. 在 `datamodel.go` 增加最小协议字段,考虑 JSON 和 protobuf 转换兼容。
2. 在 Session 做连接级验证,在 Topic 做资源权限与业务状态验证。
3. 所有可重发消息必须有 `client_mid`/业务幂等键。
4. 成功后同时考虑本节点 Session、跨节点 fanout、离线 Push 与历史补偿。
### 11.4 缓存、事件与审计
1. 对高频读对象选择 cache-aside:先 Redis,miss 后读 MySQL 并回填。
2. 所有写方法列出需失效的 key,包括对象 key、关系 Set、分页快照和未读数。
3. 需可靠异步处理时同事务写 outbox,消费端使用 `event_id + consumer_group` 幂等。
4. 影响管理权限、账号安全、消息内容、文件处置或人工重放的操作要写审计。
5. 业务状态变更需立即告知 C 端时,创建业务事件并通过已认证服务端链路投递,不信任客户端伪造的系统消息。
### 11.5 测试与文档
每个功能至少覆盖:
1. 正常路径、无认证、跨租户、无权限、资源不存在和方法错误。
2. 重复 `Idempotency-Key`/`client_mid` 不产生重复数据。
3. 同一幂等键配不同 payload 返回冲突。
4. version 冲突和并发状态迁移。
5. MySQL 成功后 Redis/Topic 缓存及在线客户端状态正确刷新。
6. Kafka 重复消费、临时失败重试、不可重试失败和死信重放。
7. 64 位 ID/seq 在 JSON 编解码时不丢失精度。
8. 更新前后端对接文档、SQL 变更集和未实现增强项。
## 12. 关键约定
### 12.1 ID 与时间
- 用户 ID 由 Snowflake UID 体系生成,对外使用 `usr...` 编码形式或协议规定的字符串。
- 业务 ID 必须全局或租户内唯一,不能用秒级时间戳直接代替唯一 ID。
- `SeqId`、数据库主键和 version 使用 `int64/BIGINT`。
- 持久化与对外时间使用 UTC,HTTP 输出 RFC3339/RFC3339Nano。
### 12.2 Topic 归一化
客户端传入的 `usr...`、`slf` 等可见 Topic 不一定是数据库真实 Topic。历史查询、上下文、收藏、转发、媒体、回执等接口在查库前必须统一归一化,并在鉴权、查询和响应之间保持明确的 Topic 语义。
### 12.3 权限
- HTTP handler 负责验证主体和请求参数,Store/adapter 仍应使用租户和业务条件限制写入。
- Topic 管理员、群主、普通成员权限以服务端为准,不接受客户端声明的角色。
- 群主转让、设管理员、禁言、踢人等变更后,必须刷新 Topic 内 `perUser` 权限快照和 Redis 订阅/成员缓存,不能等待重连后生效。
- 封禁、冻结、踢设备、全端退出和注销等账号治理必须同步处理已存在 Session。
### 12.4 可观测性
- 日志使用 `server/logs` 的 `Info/Warn/Err`,默认输出 stderr,由容器日志系统收集。
- 日志行前缀为 `I/W/E` 加 Go log flags 生成的日期时间;敏感值、Token、密码、验证码、证件号和第三方密钥不得记录。
- HTTP 日志和异步事件应携带 `request_id/trace_id`、租户、业务类型和业务 ID。
- 新增核心链路应增加请求数、失败数、延迟、队列深度/积压和死信指标。
## 13. 禁止的实现方式
1. 在 HTTP handler 内直接打开 MySQL 或拼接 SQL。
2. 因租户库表没有 `tenant_id` 列,就不向 Store 传递 `TenantID`。
3. Admin 直连 `tinode` 或 IM 直连 `hotgo`。
4. 将 Kafka 当作 HTTP RPC 或实时 WebSocket fanout 使用。
5. 将业务最终状态只存在 Redis。
6. 为同一份高频数据同时增加进程内缓存和 Redis 缓存。
7. 业务写成功后不失效 Redis、Topic 权限快照和成员分页快照。
8. 重试请求没有 `Idempotency-Key/client_mid/event_id`。
9. 使用 `int` 或 JSON number 承载可能超过 JavaScript 安全范围的 ID/seq。
10. 直接信任客户端构造的系统通知、管理员角色或审计信息。
11. 文件上传成功后不创建业务引用和安全审核任务。
12. 新增数据表只在开发者本机手工执行,不写幂等 schema SQL、升级逻辑和回归测试。
## 14. 开发与验收命令
MySQL 构建使用 build tag:
```bash
go build -tags mysql -o bin/tinode ./server
go build -tags mysql -o bin/init-db ./tinode-db
```
建议测试顺序:
```bash
# 先跑修改模块的定向测试
go test ./server -run 'TestName'
# Store 与 MySQL schema/adapter
go test -tags mysql ./server/store/... ./server/db/mysql/...
# 完整服务端回归
go test -tags mysql ./server/...
```
发版前至少检查:
- 配置文件可解析,MySQL、Redis、Kafka、OSS 等必需依赖可连通。
- 数据库版本与代码一致,所有新表/列/索引已在目标环境幂等执行。
- Kafka Topic、消费组、重试和死信链路可用。
- 单节点和多节点下的在线状态、群消息、权限变更均一致。
- 新增接口与前后端对接文档一致,无密钥、密码或测试 Token 进入 Git。
## 15. 新功能提交清单
开发者在提交 PR/代码前应逐项确认:
- [ ] 已确定功能入口是 HTTP、WebSocket、Kafka 还是内部 API。
- [ ] 已确定数据归属 `hotgo` 还是租户库。
- [ ] 所有 Store/adapter 调用都显式携带 `TenantID`。
- [ ] 已设计幂等键、唯一索引、业务版本和状态机。
- [ ] 已明确 MySQL、Redis、进程内缓存的使用和失效顺序。
- [ ] 需跨系统可靠投递时使用 outbox + Kafka,消费端已幂等。
- [ ] 已处理 Topic 内存权限快照和多节点同步。
- [ ] 文件已存 OSS,业务引用和安全审核已建立。
- [ ] `SeqId`/ID/version 已在 Go、JSON、Kafka 和 MySQL 全链路使用 64 位安全类型。
- [ ] 已增加正常、权限、跨租户、幂等、并发、缓存和重试测试。
- [ ] 已补充 API 对接文档、SQL 变更和未完成增强项。
## 16. 相关文档
- `docs/IM-前后端对接文档.md`:C 端业务 API 和交互协议。
- `docs/IM-系统设计文档.md`:原始产品系统设计。
- `docs/IM-Redis三层数据分层改造方案.md`:进程内、Redis、MySQL 分层细节。
- `docs/IM-Kafka接入方案.md`:Admin/IM 边界、Topic、幂等、重试和死信。
- `docs/IM-Kafka接入范围识别.md`:按业务场景判断 Kafka 接入范围。
- `docs/IM-Kafka接入联调测试文档.md`:Admin 与 IM 联调用例。
- `docs/third/IM-文件安全第三方接入记录.md`:OSS 和文件安全链路。
- `docs/IM-未实现后续增强项.md`:已知未完成能力和深度优化项。
+366
View File
@@ -0,0 +1,366 @@
# IM 第一至十一章后续增强项
> 更新日期:2026-08-25
> 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。
> 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。
## 0. 本次核对结论
### 0.1 已从待办移入基线
1. 注册、找回密码和敏感操作验证码在短信未接入时均可用固定测试码 `123456` 完成联调;真实短信通道仍是后续增强项。
2. `GET /v1/im/bootstrap` 已返回 `entries`、`entry_badges`、`modules`、`message_policy`、`system_topic`、`security` 和 `limits.max_file_size/max_text_length/max_forward_targets`。
3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。
4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。
5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。
6. 群文件和群公告路径已从群详情兜底中拆出,群文件基础接口已落地,未实现的子路径会返回明确错误。
7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。
8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。
9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。
10. 第一批增强中的群文件列表/登记/删除、群邀请外部接受、实名更正申诉、组织全局成员接口、月报 reopen 已落地。
11. 第一批增强中的主动创建/获取 P2P 会话、会话设置、多端云草稿已落地。
### 0.2 当前仍明确缺口及分流状态
| 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 |
| --- | --- | --- | --- | --- |
| P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 |
| P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 指定会话撤销 | 10.7 | `POST /v1/im/auth/logout` 已实现;`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
### 0.3 已抽入第一批开发的项目
以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。其中已落地项已移入各章“当前已完成基线”;仍未完全落地的项目继续以第一批开发文档作为实现、验收和同步更新依据。
1. 仍待落地:按 session ID 撤销指定设备;当前 token 登出已完成。
2. 仍待落地:普通消息转发。
3. 仍待落地:合并转发 Bundle 详情。
4. 已落地:群文件列表、登记和删除。
5. 已落地:主动创建/获取 P2P 会话。
6. 已落地:会话设置。
7. 已落地:多端云草稿。
8. 已落地:群邀请外部接受。
9. 已落地:实名更正申诉。
10. 已落地:组织全局成员接口。
11. 已落地:月报 reopen。
12. 仍待统一回归:列表 `has_more` 统一。
13. 仍待落地:风控 token 单次消费接入业务接口。
## 1. 第一章:登录、注册与安全
### 1.1 当前已完成基线
1. 企业码解析、租户状态校验和公开租户配置返回。
2. WebSocket `hi` 租户绑定、`basic` 登录和 `{acc user:new}` 注册已具备。
3. 注册支持用户名/手机号二选一;手机号注册写入 `im_users.phone`,用户名写入 `im_users.username`。
4. 用户名格式校验、手机号规范化、服务端随机用户名生成规则已具备。
5. 注册验证码发码、校验、一次性 `verification_token` 消费已具备。
6. 找回密码挑战验证与重置确认已具备。
7. 敏感操作二次验证挑战和 `action_token` 签发已具备。
8. 短信未接入时注册、找回密码、敏感操作均可用测试码 `123456` 联调。
9. 邀请码主数据在 HotGo 控制库,注册明细在 IM 租户库,支持可复用邀请码。
10. Token 绑定 `auth_version`,密码重置后旧 Token 失效。
11. 实名状态查询与提交已具备,证件信息脱敏/保护存储。
12. 独立密码设置与验证已具备,包含弱密码规则和失败锁定。
13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。
14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。
15. 风险确认可生成安全事件和系统通知。
16. 实名更正申诉 `POST /v1/im/security/realname/appeals` 已具备,申诉记录进入 pending 状态。
### 1.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 |
| P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 指定会话撤销 | `POST /v1/im/auth/logout` 已通过 Redis 撤销当前 token;`DELETE /v1/im/security/sessions/{id}` 尚未实现。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P1 | 实名后台审核流转 | 当前支持实名提交、状态查询和申诉提交,后续需要后台审核、驳回原因、通过状态变更和申诉处理工单。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有处理状态。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
| P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 |
## 2. 第二章:消息首页、全局导航与系统通知
### 2.1 当前已完成基线
1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限、入口、红点和 `message_policy`。
2. `bootstrap.limits` 已返回 `max_file_size`、`max_text_length`、`max_forward_targets`。
3. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。
4. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。
5. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。
6. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。
7. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。
8. 撤回、编辑接口已具备,并接入幂等与 Outbox。
9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read` 已具备。
10. 好友、群、会议和安全模块已可信生产系统通知。
### 2.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P0 | 万人大群离线提醒与重连补偿 | 离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿。 | 离线用户收到极光提醒;重连后按 seq 补齐且不丢不重。 |
| P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 |
| P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P1 | 群成员分页快照深度优化 | 当前已接入 Redis 短 TTL 响应快照,后续大群需要 sorted set、稳定游标和成员变更主动失效。 | 万人大群成员翻页不重复不漏,角色/昵称/退群变更后缓存能及时失效。 |
| P1 | 未读数 Redis 深度优化 | 当前已接入用户总未读 Redis 热缓存,后续需要 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。 | 首页红点、会话未读和多端 read/recv 后的未读数实时一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
## 3. 第三章:单聊关系、权限与会话上下文
### 3.1 当前已完成基线
1. `GET /v1/im/conversations/{topic}/context` 返回双方关系、发送能力、对端公开资料和 P2P Topic。
2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。
3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
6. 非好友资料查询已执行 `profile_visibility`。
7. 主动创建/获取 P2P 会话 `POST /v1/im/conversations/p2p` 已具备。
8. 会话设置 `GET/PATCH /v1/im/conversations/{topic}/settings` 已具备。
9. 多端云草稿 `GET/PUT /v1/im/topics/{topic}/draft` 已具备。
### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P1 | 会话策略与草稿联调回归 | P2P 会话、会话设置和云草稿接口已具备,仍需覆盖多端、冲突、非好友/黑名单状态下的联调回归。 | 多端草稿同步、版本冲突和权限拒绝都符合协议。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 |
| P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
## 4. 第四章:消息输入、媒体内容、转发与检索
### 4.1 当前已完成基线
1. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
2. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
3. 群成员列表支持基础分页、搜索和 `mentionable` 过滤。
4. `GET /v1/im/forward-targets` 已具备,返回文件助手和可转发的会话目标。
5. 消息撤回、编辑、回执、搜索、上下文定位、媒体列表接口已挂在 `/v1/im/topics/` 下。
6. 收藏入口与收藏转发基础接口已具备。
### 4.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 消息转发接口 | 对接文档有 `POST /v1/im/messages/forward`,当前未见路由;收藏转发不等同普通消息转发。 | 单条/多条消息可分别转发到好友、群和文件助手。 |
| P0 | 合并转发 Bundle | 对接文档有 `GET /v1/im/forward-bundles/{id}`,当前未见路由。 | 合并转发卡片可分页查看,权限隔离原会话。 |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在线、离线、多端场景均收到一次提醒。 |
| P0 | 离线 Push Kafka 化 | `im.message.push` 已纳入 Kafka 方案,但本轮暂不实现;离线提醒仍按后续极光 Push 统一接入。 | 消息、@、通话、系统通知等离线场景统一投递 Kafka Push 任务,失败可重试和补偿。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频。 | 超限返回稳定错误码,管理员可配置限额。 |
| P1 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
| P1 | 消息检索稳定游标 | 搜索和媒体列表需要稳定游标,避免新消息插入造成跳页。 | 搜索期间新增消息不影响当前翻页结果。 |
## 5. 第五章:通讯录、好友关系与黑名单
### 5.1 当前已完成基线
1. 好友列表支持查询、首字母、分页,返回备注、头像、显示名来源和会话 Topic。
2. 新朋友列表、好友申请、通过、拒绝链路已具备。
3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。
4. 添加好友二维码支持短期签名 Token 生成与解析。
5. 个人主页支持 `user_id/username/public_account` identifier,返回关系、资料和操作能力。
6. 群内非好友个人主页返回群角色、关系、资料和操作能力。
7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。
8. 黑名单列表和批量移出黑名单已具备。
9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。
10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。
11. 用户搜索已改用昵称、公开账号、登录账号和 `im_users.phone` 等结构化字段,不再依赖用户标签。
### 5.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P1 | 用户搜索租户策略扩展 | 个人添加方式已执行,后续还需增加租户级 `allow_user_search`、通讯录授权和存在性防探测策略。 | 租户关闭搜索或未授权通讯录时,客户端无法绕过且不泄露用户存在性。 |
| P1 | 二维码吊销与扫码审计 | 当前二维码是短期签名 Token,主动失效、刷新覆盖、扫码审计需要落库或接 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 基础可见性已执行,后续需按群关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致。 |
| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 |
| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 |
| P1 | 关系操作审计 | 添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 |
## 6. 第六章:群聊创建、资料维护与成员治理
### 6.1 当前已完成基线
1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。
2. 群设置、个人群设置、退出群、群昵称接口已具备。
3. 群二维码生成与扫码解析支持 `join_group` 场景。
4. 群内邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。
5. 群公告最新查询、发布/更新、详情和已读接口已具备。
6. 群治理配置 `GET/PATCH /governance` 已具备。
7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。
10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。
11. 群文件列表、登记和删除接口 `GET/POST/DELETE /v1/im/groups/{topic}/files` 已具备。
12. 群邀请外部接受 `POST /v1/im/group-invitations/{id}/accept` 已具备。
### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 群文件权限与下载闭环 | 群文件基础接口已具备,后续需与文件 ACL、退群后下载阻断、审核状态、OSS 短链失效联动。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 |
| P1 | 群邀请外部接受体验回归 | 外部接受接口已具备,仍需覆盖邀请卡、多端重复接受、过期和审批模式切换。 | 被邀请人可通过邀请卡入群或进入审批;重复接受幂等返回稳定结果。 |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
| P1 | 群成员 `user_id` 完整性回归 | 测试报告曾发现 owner 成员 `user_id` 为空,需要专门回归确认。 | 群成员列表所有成员均返回稳定 `usr...`。 |
| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 |
## 7. 第七章:文件下载、收藏管理与聊天记录检索
### 7.1 当前已完成基线
1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。
2. 收藏批量创建、批量删除和收藏转发接口已具备。
3. 转发目标列表 `GET /v1/im/forward-targets` 已具备。
4. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。
5. 消息上下文定位接口可围绕指定 seq 返回前后消息。
6. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。
7. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。
### 7.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 普通消息转发与合并转发 | `/favorites/forward` 已有,但 `/messages/forward`、`/forward-bundles/{id}` 未实现。 | 普通消息可单条/多条/合并转发,Bundle 可分页查看。 |
| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 |
| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 |
| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 |
| P1 | 收藏业务去重策略 | 使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 |
| P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 |
| P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 |
| P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 |
## 8. 第八章:实时语音与视频通话
### 8.1 当前已完成基线
1. 通话 invite、accept、offer、answer、ice-candidate、hang-up 等事件沿用 Tinode call event 链路。
2. 通话记录表 `im_call_records` 已具备,记录 `call_id`、Topic、seq、主叫/被叫、类型、状态、时长和结束原因。
3. 通话记录创建、接听和结束状态更新已接入 store/adapter。
4. 通话记录查询接口已具备:全局 `GET /v1/im/calls` 与 Topic 内 `GET /v1/im/topics/{topic}/calls`。
5. 查询结果返回对端用户、方向、通话类型、状态、时长和关键时间点,客户端可先完成通话记录页对接。
### 8.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 |
| P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 |
| P0 | 通话事件 Outbox 与离线 Push | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 |
| P0 | TURN/STUN 配置与媒体准入 | 服务端应按租户/区域返回可用 ICE 配置,并限制未授权用户创建媒体会话。 | 客户端拉取到有效 TURN;无权限通话被服务端拒绝。 |
| P1 | 结束原因标准化 | 需要统一 `missed`、`declined`、`busy`、`timeout`、`network_error` 等原因和客户端展示文案。 | 不同端同一结束原因展示一致,统计口径一致。 |
| P1 | 通话质量指标 | 需要采集建立耗时、ICE 成功率、丢包、抖动、断连等指标。 | 后台可按租户和版本查看通话质量趋势。 |
| P1 | 群通话/会议房间拆分 | 群音视频不宜复用 P2P call,需要会议房间、参与人状态和 SFU 接入。 | 群通话有独立 room_id、成员列表和退出/重进状态。 |
| P1 | 通话权限统一策略 | 通话入口应复用 P2P/群关系策略、黑名单、禁言、青少年模式和企业策略。 | 被拉黑、非好友或受限制用户无法发起通话。 |
## 9. 第九章:企业工作台与办公协同
### 9.1 当前已完成基线
1. 工作台接口 `GET /v1/im/workbench` 已具备,返回模块、布局版本和企业功能开关。
2. `GET /v1/im/bootstrap` 已返回工作台总未读 `entry_badges.workbench_total_unread`。
3. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。
4. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。
5. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。
6. 部门列表、部门成员、组织搜索已具备。
7. 会议通知创建、列表、详情、更新、取消、提醒、参会回应已具备基线。
8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
11. 组织全局成员接口 `GET /v1/im/organization/members` 已具备。
12. 月报 reopen `POST /v1/im/reports/monthly/{id}/reopen` 已具备。
13. 组织架构可见范围已按账号来源裁剪:邀请码注册默认仅本人,手机号注册默认本人和好友组织路径,上架测试账号可通过 `im_org_visibility_overrides.visibility_scope=all` 放开全量。
### 9.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 |
| P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织字段级隐私策略 | 组织架构范围裁剪已具备,成员手机号、邮箱、职位等字段后续仍需按组织角色、隐私和企业策略进一步细化。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织全局成员接口深度联调 | `GET /v1/im/organization/members` 已具备,仍需按部门、关键词、权限和分页做测试环境回归。 | 前端可直接分页查询并按部门过滤,字段裁剪符合权限。 |
| P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 |
| P1 | 月报 reopen 审计与权限扩展 | reopen 接口已具备,后续需要后台权限、原因记录、审计和通知闭环。 | 已提交月报可按权限退回草稿并保留审计。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
## 10. 第十章:个人设置与客户端能力
### 10.1 当前已完成基线
1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。
2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。
3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,版本策略为全局配置,不依赖租户解析。
4. 青少年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。
7. 用户搜索/加好友、个人资料、消息回执及 Tinode P2P 最后在线输出已执行对应隐私设置。
### 10.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 指定会话撤销 | `auth/logout` 已通过 Redis token 摘要黑名单精准实现;`security/sessions/{id}` 仍未实现。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 |
| P0 | 青少年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 |
| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 |
| P1 | 隐私策略入口扩展 | 组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 |
| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 |
## 11. 第十一章:反诈安全、举报与意见反馈
### 11.1 当前已完成基线
1. URL 安全检查 `POST /v1/im/security/urls/check` 已接入风控 challenge 创建。
2. 风控确认 `POST /v1/im/security/risk-challenges/{id}/confirm` 已具备,`continue` 返回短时 `risk_action_token`。
3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。
4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。
5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。
6. 风险确认结果已生成稳定 `security.risk.*` 事件和用户可见系统通知。
### 11.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 风控策略引擎 | 当前 URL 检查仅基础规则,后续需要按动作、关系、内容、设备、频率和租户策略综合判定。 | 高风险发消息、加好友、打开链接等动作统一返回风控 challenge。 |
| P0 | 风控 token 消费接入业务接口 | 当前确认可签发 `risk_action_token`,后续需要原业务接口校验并单次消费该 token。 | 二次确认后只允许重试原动作一次,重复使用返回 Token 已消费。 |
| P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 |
| P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 |
| P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 |
| P1 | 主动反诈风险提示 | 风险确认通知已由服务端可信生产;低风险主动提示、通知文案策略和 Push 优先级仍需扩展。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 |
| P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 |
| P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 |
## 12. 统一处理建议
1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。
2. 第二优先级补齐已落地接口的联调闭环:P2P 会话、会话设置、云草稿、群文件、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。
3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。
4. 万人大群未实现增强项仅保留离线能力:离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿;实时推送能力归入 `IM-Redis三层数据分层改造方案.md` 后续直接实现。
5. Redis 读性能深度优化继续保留:群成员分页 sorted set/稳定游标/主动失效、Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。
6. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。
## 13. Kafka 联调后续增强项
来源:`IM-Kafka接入联调测试文档.md` 10.2。本轮已完成后端 Kafka 主链路联调,但端到端页面与真实客户端仍需专项补测;同时 Kafka 断线恢复暴露出进程稳定性问题。
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | Admin Kafka 断线自动恢复 | 本地联调中停止 Kafka 后,Admin consumer 报 `client has run out of available brokers` 并 fatal 退出。 | Kafka 停止/恢复期间 Admin 进程不退出,consumer 退避重连,恢复后继续消费积压消息。 |
| P0 | IM Kafka consumer 自动恢复 | Kafka 恢复后 IM 仍 healthy,但 consumer 未立即消费新事件,重启 IM 后才补偿消费。 | Kafka broker 重启后无需重启 IM,`im.admin.command`、`im.message.persist`、`im.cache.invalidate` 等 Topic 自动恢复消费。 |
| P1 | Admin 前端页面人工验收 | 后端接口、Kafka、数据库状态已测;仍需在浏览器逐个菜单验收操作单、死信、审计和状态流转展示。 | 后台页面可展示 pending/running/succeeded/failed/dead,死信可查询、忽略、解决、重放,审计列表与详情可查看。 |
| P1 | 真实 C 端 WebSocket 联调 | 后台命令已生成业务事件/系统通知;仍需真实客户端在线观察 WS 实时刷新。 | 在线客户端收到群治理、用户治理、工作台、系统通知等事件;断线重连后可按 seq/bootstrap 补偿。 |
@@ -0,0 +1,550 @@
# IM 注册、邀请码与认证数据模型改造需求文档
## 0. 统一验证码输入变更(2026-09-16)
本节为最新规则,覆盖本文后续章节中“独立邀请码输入框”“短信开启时邀请码不能替代验证码”等旧描述:
1. 注册页面取消独立邀请码输入框,邀请码与短信验证码共用同一个输入框。
2. 5 位大小写英文字母按邀请码处理,6 位数字按短信验证码处理,其他格式拒绝。
3. 有效邀请码可以作为注册放行凭证;注册成功后写入 `im_registration_invite_uses`,建立邀请码与新用户的推荐使用关系。
4. 短信验证码注册会建立已完成的 `tel credential`;邀请码注册只保存手机号,不把手机号标记为短信已验证。
5. 新客户端不再提交独立 `invite_code`,而是在 `{acc}.cred[].resp` 中提交用户在统一输入框填写的 5 位邀请码。旧 `invite_code` 字段仅保留向后兼容。
6. `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 输入邀请码时返回 `{"verification_mode":"invite_code","invite_accepted":true}`;输入短信验证码时继续返回一次性 `verification_token`。
> 版本:V1.0 评审稿
> 编写日期:2026-08-18
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
> 关联文档:IM-前后端对接文档.md 1.4、IM-系统设计文档.md
## 1. 文档目的
本文统一定义以下改造:
1. 注册账号支持“用户名、手机号、用户名加手机号”三种输入组合。
2. 手机号注册支持短信验证,后端固定补中国国家号 +86。
3. HotGo 后台增加注册短信发送开关。
4. 邀请码作为无短信验证时的注册放行凭证。
5. 邀请码支持单个生成、批量生成、自定义有效期、撤销和使用审计。
6. im_users 增加非空 username,phone 改为可空。
7. im_auth 删除数据库字段 scheme 和 uname,只按 userid 保存 Basic 密码凭据。
## 2. 核心业务定义
| 名称 | 定义 |
| --- | --- |
| 用户名输入 | 注册页面独立的用户名输入框 |
| 手机号输入 | 注册页面独立的手机号输入框,只接受 11 位 ASCII 数字 |
| 放行优先级 | 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭或未填写手机号时以邀请码为注册放行依据 |
| 已验证手机号 | 已成功消费 register 场景 verification_token,并建立已完成 tel credential 的手机号 |
| 用户名注册 | 保存用户填写的 username;未填写手机号时依靠有效邀请码放行,phone 为 NULL |
| 手机号注册 | 手机号字段非空的注册;保存用户填写的 username 和规范化 phone,未填写 username 时由服务端随机生成 username;短信验证状态由 tel credential 区分 |
| 注册短信开关 | 控制服务端是否真正调用短信服务商的全局后台配置 |
| 邀请码 | 租户内生成的 5 位大小写英文字母明文码,区分大小写、不可重复,有效期内可供多人使用、可撤销 |
## 3. 用户账号数据规则
### 3.1 im_users 字段
目标字段:
| 字段 | 约束 | 说明 |
| --- | --- | --- |
| username | 非空、租户内唯一 | 主登录账号;用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`。未填写 username 但填写手机号时,由服务端随机生成 3 位小写字母 + 6 位数字的系统 username |
| phone | 可空、租户内唯一 | 用户填写手机号时的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 |
落库规则:
| 注册方式 | username | phone |
| --- | --- | --- |
| 用户名加手机号,验证码注册 | alice | +8613800138000 |
| 仅手机号,验证码注册 | abc123456 | +8613800138000 |
| 普通用户名加邀请码注册 | alice | NULL |
| 用户名加手机号,短信关闭邀请码注册 | alice | +8613800138000 |
| 仅手机号,短信关闭邀请码注册 | abc123456 | +8613800138000 |
普通用户名注册规则:
1. 必须匹配 `^[a-z0-9]{1,10}$`,长度为 1~10 位。
2. 只允许 ASCII 小写英文字母 `a-z` 和数字 `0-9`,允许纯字母、字母数字组合或纯数字。
3. 大写字母、中文、空格、点、下划线、冒号及其他符号均不允许;服务端校验原始输入,不通过静默转小写来放行不合规注册。
4. 前端按相同规则限制输入并提示,服务端必须再次校验,不能只依赖客户端。
5. 该规则只约束用户填写的普通用户名。服务端随机生成的系统 username 固定为 9 位 `^[a-z]{3}[0-9]{6}$`,属于系统值例外;存量迁移生成的内部保留账号也不适用此正则,不能作为普通用户名注册。
### 3.2 字段保存和手机号规范化
1. 页面分别展示用户名输入框和手机号输入框。
2. 用户名字段非空时必须按原值校验并保存到 `im_users.username`,不能因为同时填写手机号而忽略用户名。
3. 手机号字段非空时必须校验并保存到 `im_users.phone`;手机号字段为空时 `phone` 保持 NULL。
4. 手机号字段使用 ^[0-9]{11}$ 校验;手机号非空但格式错误时直接拒绝,不能降级为用户名方式。
5. 发送短信和落库前,后端把 11 位数字规范化为 +86xxxxxxxxxxx。
6. 用户名和手机号同时填写时,`username` 保存用户填写的用户名,`phone` 保存规范化手机号。
7. 仅手机号注册且 username 为空时,服务端随机生成 `^[a-z]{3}[0-9]{6}$` 格式的 username,检查租户内唯一,冲突时重新生成;`phone` 保存规范化手机号。
8. 不提供国家和区号选择,本期固定中国 +86。
9. 登录页面仍使用统一的“用户名/手机号”输入框;先精确查询 username,未命中时将 11 位输入补 +86 后查询 phone。
10. 注册、绑定手机号时必须检查 username 和 phone 两个命名空间,防止同一输入命中两个用户。
11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;用户名字段非空且格式错误时必须拒绝,不能因手机号合法而跳过用户名校验。
12. 注册创建公开资料时,账户昵称暂时默认等于最终保存的 username;客户端未传 `desc.public.fn` 或传入值为空时,服务端使用最终 username 填充昵称。
## 4. 注册放行规则
### 4.1 决策表
| 用户名字段 | 手机号字段 | 短信开关 | 有效验证票据 | 邀请码 | 服务端结果 |
| --- | --- | --- | --- | --- | --- |
| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,保存 username,phone=NULL |
| 已填 | 空 | 任意 | 不适用 | 未填或无效 | 拒绝 |
| 已填 | 11 位 | 开 | 有 | 未填 | 放行,保存 username 和 phone |
| 已填 | 11 位 | 开 | 有 | 有效 | 放行,保存 username 和 phone,并记录邀请码使用关系 |
| 空 | 11 位 | 开 | 有 | 未填或有效 | 放行,随机生成 username,phone 写手机号 |
| 任意 | 11 位 | 开 | 无 | 任意 | 拒绝,邀请码不能绕过已开启的短信验证 |
| 已填 | 11 位 | 关 | 无 | 有效 | 放行,保存 username 和 phone |
| 空 | 11 位 | 关 | 无 | 有效 | 放行,随机生成 username,phone 写手机号 |
| 任意 | 11 位 | 关 | 无 | 未填或无效 | 拒绝 |
| 已填 | 格式错误 | 任意 | 任意 | 任意 | 拒绝,不能降级为用户名方式 |
| 空 | 空 | 任意 | 任意 | 任意 | 拒绝,必须填写用户名或手机号 |
补充规则:
1. 有效 verification_token 的优先级高于当前短信开关。开关关闭不能让已验证票据失效。
2. 短信开关开启时,邀请码不能绕过手机号验证。
3. 用户主动填写邀请码后,服务端必须校验;不能在邀请码无效时静默忽略。
4. 手机号字段非空且短信开关开启时,以验证码为准,邀请码可不填;手机号字段为空或短信开关关闭时,必须填写有效邀请码。
5. 短信关闭时邀请码替代短信验证作为注册授权,但不建立已完成的 tel credential。
6. 用户创建、租户用户配额校验、邀请码使用记录、手机号票据消费应原子完成。
### 4.2 客户端页面行为
1. 页面分别显示“请输入用户名”和“请输入手机号”。
2. 用户名和手机号是独立字段;两个都填写时都参与注册,用户名保存为主登录账号,手机号保存为绑定号码。
3. 手机号为空时不发送短信,使用用户名加邀请码方式注册。
4. 手机号为 11 位数字时允许点击“获取验证码”。
5. 用户名方式不发送短信,验证码字段不作为提交项。
6. 邀请码字段始终显示,文案为“请输入邀请码(非必填)”。
7. 短信开关关闭时,发码接口仍返回正常倒计时,前端进入与开关开启时相同的倒计时状态。
8. 前端不自行决定最终是否可以免验证码注册,最终放行完全由服务端判断。
9. 用户名方式注册前提示:未绑定手机号时无法自助找回密码,只能联系企业管理员重置。
## 5. 注册短信发送开关
### 5.1 HotGo 配置
HotGo 后台增加全局配置:
~~~text
group = im
key = register_sms_enabled
type = bool
value = 1 / 0
~~~
实现约定:
1. 管理界面的开关选项复用 HotGo 已有 sys_switch 字典。
2. 当前开关值保存到 hg_sys_config;字典只负责提供开/关选项,不作为配置值真源。
3. 只有平台管理员可以修改。
4. 修改操作写 HotGo 后台审计日志。
5. IM 服务端在可控缓存时间内读取最新值。
6. 本期为全局开关;未来需要按企业控制时再迁移到 im_tenant_config。
### 5.2 开关开启
1. 服务端生成强随机验证码。
2. 创建 register 场景挑战并执行限频。
3. 调用短信服务商发送验证码。
4. 短信服务商失败时返回稳定错误 SMS_DELIVERY_FAILED。
5. 禁止继续使用固定测试码 123456。
### 5.3 开关关闭
1. 发码接口仍创建限频挑战。
2. 返回 challenge_id、expires_in_seconds 和 resend_after_seconds。
3. 不调用短信服务商。
4. 不生成客户端可猜测或可验证的固定验证码。
5. 前端仍正常显示倒计时,但用户不会收到短信。
6. 记录内部 delivery_suppressed 审计事件,不向客户端暴露开关状态。
## 6. 邀请码管理
### 6.1 基本规则
1. 邀请码按租户隔离,不能跨企业使用。
2. 邀请码固定为 5 位 ASCII 大小写英文字母,格式为 `^[A-Za-z]{5}$`,不包含数字和符号。
3. 邀请码区分大小写,`AbCdE` 与 `abcde` 是两个不同的邀请码;客户端和服务端不得自动转换大小写。
4. 邀请码由服务端使用密码学安全随机数从 `A-Z`、`a-z` 字符集中生成,不能使用递增数字、用户 ID 或批次 ID。
5. 同一租户内邀请码不能重复,数据库唯一索引是最终约束;单个或批量生成发生冲突时必须重新生成,不得返回重复码。
6. 邀请码以明文落库,并使用完整明文进行大小写敏感的精确查询和注册校验。
7. 每个邀请码在有效期内可以成功注册多个账号,不设置使用次数上限。
8. 管理员创建时自行设置 expires_at,必须晚于当前时间。
9. 状态包括 active、expired、revoked,不存在 used 状态。
10. expired、revoked 状态不可恢复为 active。
11. 邀请码校验必须同时检查租户、完整明文、状态和到期时间,不因已有其他用户使用记录而失效。
### 6.2 单个生成
请求示例:
~~~json
{
"expires_at": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职"
}
~~~
服务端返回一个 5 位邀请码。具备邀请码查看权限的租户管理员可在后续列表和详情中查看完整明文。
### 6.3 批量生成
请求示例:
~~~json
{
"quantity": 100,
"expires_at": "2026-09-01T00:00:00+08:00",
"remark": "2026年8月新员工入职"
}
~~~
规则:
1. 一次批量生成共用 batch_id 和 expires_at。
2. 每个邀请码均为独立生成的 5 位大小写英文字母,租户内唯一且区分大小写。
3. 批量请求必须支持幂等键,重试不能生成重复批次。
4. 单批数量由后台配置限制,建议默认上限 1000。
5. 生成成功后支持明文导出;后续再次查询或导出必须校验邀请码查看/导出权限并写审计。
### 6.4 查询、撤销和审计
管理后台支持:
1. 按完整邀请码进行大小写敏感的精确查询。
2. 按状态、批次、创建人、创建时间、到期时间查询。
3. 列表和详情返回邀请码明文,接口必须校验当前租户的邀请码查看权限。
4. 单个撤销仍处于 active 的邀请码;撤销不删除历史使用记录。
5. 按 batch_id 批量撤销仍处于 active 的邀请码。
6. 查看邀请码的累计使用次数、使用用户、使用时间和请求 ID。
7. 所有生成、查询、查看、导出、撤销操作写后台审计日志。
### 6.5 HotGo 管理接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | /imInvite/create | 单个生成 |
| POST | /imInvite/batchCreate | 批量生成 |
| POST | /imInvite/list | 分页或明文精确查询 |
| POST | /imInvite/revoke | 单个撤销 |
| POST | /imInvite/batchRevoke | 批量撤销 |
| GET | /imInvite/batchView | 批次详情 |
| POST | /imInvite/export | 按过滤条件导出 CSV |
HotGo 根据可信登录上下文取得 `im_tenant.id`,通过 `im_tenant.db_dsn/db_name` 选择租户业务库并直接维护邀请码管理数据。浏览器不能提交可信 DSN 或决定实际租户库。
IM 不再提供 `/admin/v1/im/*` HMAC 管理接口。注册校验和 `im_registration_invite_uses` 写入仍由 IM 在用户注册事务中完成,HotGo 不参与注册事务。
管理员重置密码时,HotGo 在同一个租户库事务中更新 `im_auth.secret`、递增 `im_user_security.auth_version`,并写入 `im_business_events_outbox` 的 `security.password_reset` 事件。IM 当前轮询此事件并清退在线会话;后续迁移 Kafka 时保持事件类型和 payload 不变,只替换投递与消费通道。
## 7. 客户端注册协议
### 7.1 发码
~~~http
POST /v1/im/auth/verification-codes
~~~
~~~json
{
"scene": "register",
"phone": "13800138000"
}
~~~
后端补 +86 后发送并保存手机号哈希。开关关闭时返回字段和倒计时保持一致。
### 7.2 提交注册
在现有 WebSocket acc 消息中增加 username、phone 和 invite_code:
~~~json
{
"acc": {
"id": "acc-1",
"user": "new",
"scheme": "basic",
"username": "alice",
"phone": "",
"secret": "base64(account:password)",
"invite_code": "AbCdE",
"login": true,
"cred": []
}
}
~~~
用户名方式时,phone 为空,secret 使用 username:password,cred 为空且 invite_code 必须有效。
手机号字段非空时,短信开关开启则必须通过验证码放行,cred 传递一次性 verification_token;短信开关关闭则走邀请码放行,cred 可为空且 invite_code 必须有效。用户名字段非空时保存用户名并使用 username:password 生成 secret;用户名字段为空时由服务端随机生成 3 位小写字母 + 6 位数字的 username,客户端提交 secret 时仍使用 phone:password。短信关闭路径不建立已完成的 tel credential。
服务端必须校验 secret 中的登录名与客户端可确定的注册账号一致:username 非空时必须匹配 username;username 为空且 phone 非空时必须匹配规范化手机号。服务端生成的随机 username 在账号创建后返回给客户端,并作为后续登录主账号。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。
账户昵称暂时与最终 username 保持一致:服务端创建用户资料时以最终 username 写入 `desc.public.fn` 或资料表昵称;仅手机号注册时昵称等于随机生成的 username,不使用手机号作为昵称。
客户端不得提交 phone_account、sms_enabled、skip_verification 等服务端决策字段。
## 8. im_auth 改造
### 8.1 改造边界
当前只有 Basic 密码凭据写入 im_auth:
1. token 是无状态签名,不写 im_auth。
2. code 使用临时缓存,不写 im_auth。
3. anonymous 不保存凭据。
4. rest 调用外部认证服务。
因此数据库中的 scheme=basic 是冗余信息。登录账号真源迁移到 im_users 后,uname 和 basic: 前缀同样冗余。
协议层仍保留 acc.scheme 和 login.scheme,用于选择 basic 或 token 认证处理器。本次只删除数据库字段。
这里删除的 uname 仅指 `im_auth.uname` 数据库列。Basic 协议仍从 `secret` 中解析登录账号和密码,登录账号改为查询 `im_users.username` 或 `im_users.phone`;除非后续同时改造客户端和协议消息结构,否则 `account:password` 格式及用户名禁止冒号的技术约束继续保留。
### 8.2 目标表结构
~~~sql
CREATE TABLE im_auth (
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
authlvl INT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY (id),
UNIQUE INDEX auth_userid (userid),
CONSTRAINT fk_im_auth_user
FOREIGN KEY (userid) REFERENCES im_users(id)
);
~~~
变更内容:
1. 删除 scheme。
2. 删除 uname。
3. 删除 auth_uname 和 auth_userid_scheme。
4. 增加 UNIQUE(userid)。
5. 保留 authlvl,以兼容普通用户和 ROOT 账号。
6. 保留 secret 和 expires。
### 8.3 登录和密码处理
1. Basic 登录先规范化账号输入。
2. 先从 im_users.username 查询 userid。
3. 未命中且输入为 11 位数字时,补 +86 后从 im_users.phone 查询 userid。
4. 按 userid 从 im_auth 读取 bcrypt 密码摘要。
5. 修改密码、重置密码、删除认证和 ROOT 提升都按 userid 操作。
6. Store 和 Adapter 接口不再传递 scheme、unique 或 basic: 前缀。
### 8.4 密码找回
1. im_users.phone 非空的账号可以使用短信找回密码。
2. 找回流程向 im_users.phone 发送 reset_password 场景验证码,成功消费验证码后才允许设置新密码。
3. 如果该手机号注册时未建立完成状态的 tel credential,成功完成密码找回验证后可以补建该凭证。
4. im_users.phone 为 NULL 的用户名账号不提供短信或其他自助找回方式。
5. 无手机号账号忘记密码后,只能联系当前租户管理员执行后台密码重置。
6. 管理员重置密码必须递增 auth_version、撤销旧 Token 和在线 Session,并写安全审计。
7. 管理员只能重置当前租户用户,不能通过用户名跨租户查找或操作。
### 8.5 密码与登录 Token
1. 客户端按现有 Basic 协议提交 `base64(account + ":" + password)`。Base64 只是协议编码,不是密码加密;账号注册和登录必须使用 HTTPS/WSS。
2. 服务端解析出密码后使用 bcrypt 单向哈希,当前成本参数为 `bcrypt.DefaultCost=10`;`im_auth.secret` 只保存 bcrypt 哈希,不保存可逆密文或明文。
3. 登录 Token 继续沿用现有 Tinode HMAC 签名 Token,默认有效期为 14 天,服务端不保存 Token 明文。
4. 客户端保存登录响应中的 `params.token` 和 `params.expires`,Token 必须放入 iOS Keychain 或 Android Keystore,不得写入普通数据库、日志或埋点。
5. 每次 Basic 登录或有效 Token 登录成功后,服务端都返回新的 14 天 Token;客户端必须用新 Token 覆盖旧 Token。
6. 当前不增加 Refresh Token 和独立刷新接口。Token 未过期时可通过 `login.scheme=token` 登录并取得新 Token;Token 已过期或校验失败时必须重新进行账号密码登录。
7. Token 内绑定 `tenant_id`、`uid`、`auth_version` 和过期时间。密码重置、管理员重置或账号安全状态变化后递增 `auth_version`,使旧 Token 失效。
## 9. 数据库设计
### 9.1 邀请码批次
~~~sql
CREATE TABLE im_registration_invite_batches (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
quantity INT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key)
);
~~~
### 9.2 邀请码
~~~sql
CREATE TABLE im_registration_invite_codes (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
batch_id BIGINT,
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
expires_at DATETIME(3) NOT NULL,
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
last_used_at DATETIME(3),
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code),
INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at),
INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id)
);
~~~
### 9.3 邀请码使用记录
~~~sql
CREATE TABLE im_registration_invite_uses (
id BIGINT NOT NULL,
invite_code_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
used_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
INDEX idx_im_registration_invite_use_code (invite_code_id),
UNIQUE INDEX uk_im_registration_invite_use_user (user_id)
);
~~~
`im_registration_invite_batches` 和 `im_registration_invite_codes` 位于 HotGo 控制库,使用 `tenant_id` 隔离租户,便于平台统一查询、导出和管理。`usage_count`、`last_used_at` 是租户使用明细的查询投影,不是使用事实源。
`im_registration_invite_uses` 位于对应租户业务库,是邀请码使用事实源。注册成功时,IM 在同一租户库事务中写入使用明细和 `registration.invite_used` outbox 事件;当前由 IM 数据库轮询器按该邀请码的租户明细重新聚合并覆盖控制库统计,重试不会重复计数。后续切换 Kafka 时保持事件类型和 payload 不变。三张表的 BIGINT 主键均使用服务端雪花 ID。
邀请码以明文保存在 HotGo 控制库中。`ascii_bin` 保证查询和唯一索引区分大小写;所有校验必须同时使用 `tenant_id` 和完整 5 位邀请码精确匹配,禁止 `LOWER(code)`、`UPPER(code)` 或模糊查询。
## 10. 主要错误码
| 业务码 | 含义 |
| --- | --- |
| ACCOUNT_REQUIRED | 账号输入为空 |
| ACCOUNT_POLICY_VIOLATION | 账号违反最低技术约束 |
| ACCOUNT_ALREADY_EXISTS | 用户名或手机号命名空间冲突 |
| PHONE_VERIFICATION_REQUIRED | 手机号注册缺少有效验证票据 |
| INVITE_CODE_REQUIRED | 用户名注册缺少邀请码 |
| INVITE_CODE_INVALID | 邀请码不存在 |
| INVITE_CODE_EXPIRED | 邀请码已过期 |
| INVITE_CODE_REVOKED | 邀请码已撤销 |
| INVITE_CODE_RATE_LIMITED | 邀请码校验过于频繁 |
| SMS_DELIVERY_FAILED | 短信服务商发送失败 |
| USERNAME_INVALID_FORMAT | 用户名不是 1~10 位小写英文字母或数字 |
邀请码无效、过期、已撤销可对客户端统一显示“邀请码无效”,详细原因仅写安全审计,降低枚举风险。
## 11. 存量迁移
采用“扩容、回填、切换、收缩”方式:
1. 为 im_users 增加可空 username。
2. 去除 im_auth.uname 中的 basic: 前缀后回填 im_users.username。
3. 将现有 phone 统一规范化为 E.164。
4. 对无 Basic 认证记录的内部、已删除或异常用户生成 reserved_<userid>,并输出迁移报告供人工复核。
5. 检查重复 username、重复 phone 和跨字段冲突。
6. 新版本短期双读旧 im_auth 和新 im_users 账号字段。
7. 切换注册、登录、修改密码、密码重置和 ROOT 工具链。
8. 将 username 改为非空并增加唯一索引。
9. 观察期结束后删除 im_auth.scheme、im_auth.uname 和旧索引。
回滚时在观察期内继续保留旧列和旧读路径,不允许通过重新拼接 basic: 猜测恢复数据。
## 12. 安全与已知风险
1. 5 位邀请码属于短授权码,必须使用密码学安全随机数生成、设置有效期、可撤销并可审计;注册校验必须严格限频以降低枚举风险。邀请码允许多人使用意味着泄露后的影响面更大,应缩短有效期并及时撤销异常邀请码。
2. 邀请码校验按租户、IP、设备和账号输入限频。
3. 邀请码虽按业务要求明文落库和授权查询,但不得写入普通日志、埋点或错误响应;密码、短信验证码和 verification_token 同样禁止记录。
4. 邀请码使用记录、用户创建、票据消费和租户配额扣减应在同一事务完成;邀请码本身不核销、不切换为 used。
5. 短信开关关闭时不能保留固定测试验证码。
6. 批量生成、批量导出和批量撤销必须限制数量并单独授权。
### 12.1 邀请码手机号注册风险
短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号写入 phone;如果用户同时填写 username,username 保存用户填写值,否则由服务端随机生成 3 位小写字母 + 6 位数字的 username。
为避免把“已填写手机号”和“已验证手机号”混为一谈:
1. im_users.phone 保存手机号方式注册时的号码。
2. 只有成功消费 verification_token 时才建立完成状态的 tel credential。
3. 短信关闭加邀请码注册不建立完成状态的 tel credential。
4. phone 非空账号可以通过 reset_password 短信挑战证明当前持有手机号后自助重置密码。
5. phone=NULL 的账号不能自助找回密码,只能由当前租户管理员重置。
6. 其他要求“已验证手机号”的敏感操作必须检查 tel credential,不能只检查 phone 非空。
7. 后续完成短信验证后,可为同一用户补建 tel credential。
8. 邀请码不证明手机号所有权,允许该路径意味着平台接受手机号被提前占用的业务风险。
## 13. 验收用例
### 13.1 注册
1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。
2. 只填写手机号,短信开启但没有验证票据,即使邀请码有效也不能注册。
3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。
4. 只填写手机号,短信关闭且不填邀请码不能注册。
5. 只填写用户名并提供有效邀请码可以注册,phone=NULL。
6. 只填写用户名但不填邀请码不能注册。
7. 用户名和手机号同时填写且短信开启时,验证码验证成功后可以注册,username 保存用户填写的用户名,phone 保存规范化手机号。
8. 用户名和手机号同时填写且短信关闭时,邀请码有效即可注册,username 保存用户填写的用户名,phone 保存规范化手机号。
9. 手机号非空但格式错误时拒绝,不能回退到用户名方式。
10. 用户名和手机号同时为空时拒绝注册。
11. 过期、已撤销邀请码均不能注册。
12. 同一有效邀请码并发注册时可以有多个不同用户成功,每个用户分别写入一条使用记录,邀请码保持 active。
13. 用户名账号 phone=NULL 时,短信找回接口不能重置密码。
14. 管理员可以重置 phone=NULL 用户的密码,重置后旧 Token 和 Session 全部失效。
15. 普通用户名 `alice01`、`a`、`1234567890` 可以进入注册校验。
16. 普通用户名 `Alice`、`alice_1`、`alice.1`、`中文`、空字符串或超过 10 位时返回 `USERNAME_INVALID_FORMAT`。
17. 用户名格式不合法但手机号合法且非空时仍拒绝,不能忽略用户名输入。
18. 仅手机号注册时,随机 username 必须在租户内唯一;发生唯一索引冲突时服务端重新生成并重试。
19. 注册成功后的账户昵称等于最终 username:填写用户名时昵称等于该用户名;仅手机号注册时昵称等于随机 username。
### 13.2 短信开关
1. 开关关闭后发码接口不调用短信服务商。
2. 开关关闭后发码接口仍返回正常倒计时。
3. 开关从关闭改为开启后,新请求恢复调用短信服务商。
4. 开关修改在规定缓存时间内对 IM 服务生效。
### 13.3 邀请码管理
1. 单个生成可设置有效期。
2. 单个和批量生成的邀请码均匹配 `^[A-Za-z]{5}$`,不包含数字或符号。
3. 批量生成数量正确,同一租户所有邀请码互不重复;并发生成冲突时由唯一索引拦截并重试。
4. `AbCdE` 和 `abcde` 按两个不同邀请码精确查询和校验,不得自动转换大小写。
5. 有权限的管理员可以按完整邀请码查询,并在列表、详情和导出中取得明文。
6. 批量请求重试不会生成第二批数据。
7. 单个和批量撤销立即生效,历史使用用户不受影响。
8. 无权限用户不能查看、查询或导出邀请码明文。
9. 管理员可以查看批次使用统计和单码使用记录。
### 13.4 认证回归
1. 存量 Basic 用户迁移后可使用原账号和密码登录。
2. 登录、注册、修改密码、密码重置和 ROOT 提升不再依赖 im_auth.scheme、im_auth.uname 或 basic: 前缀。
3. 同一 username 或 phone 在不同租户仍可独立注册。
4. im_auth 每个 userid 最多保留一条记录。
## 14. 评审结论
本方案的核心决策为:
1. 注册页的用户名和手机号是两个独立输入框,用户名非空时必须保存,手机号非空时必须保存。
2. username 是所有用户非空的主登录账号。
3. 用户名和手机号同时填写时,username 保存用户名,phone 保存规范化手机号;仅手机号注册时 username 由服务端随机生成 3 位小写字母 + 6 位数字。
4. 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭时以邀请码为注册放行依据。
5. 短信关闭时,手机号输入搭配有效邀请码可注册,但不建立已完成的 tel credential。
6. 邀请码为 5 位大小写英文字母,区分大小写,以明文落库并在租户内唯一;有效期内可供多人使用,支持单个、批量、自定义有效期、明文查询/导出、撤销和使用审计。
7. im_auth 只按 userid 保存 Basic 密码凭据,数据库删除 scheme、uname 和 basic: 前缀。
8. 协议层仍保留 basic/token scheme,不与数据库结构混淆。
9. phone=NULL 的用户名账号不能自助找回密码,只能由租户管理员重置。
10. HotGo 与 IM 当前以共享租户业务库集成,不再使用内部 HMAC 管理 HTTP;跨进程安全事件先经数据库 outbox,后续迁移 Kafka。
评审通过后,实施工作拆分为数据库迁移、IM 注册与认证、HotGo 后台、客户端对接和全量回归五个任务。
@@ -0,0 +1,223 @@
# IM 测试环境接口审核报告(2026-08-20)
## 1. 审核范围
- 对照文档:`docs/IM-前后端对接文档.md`
- 测试环境:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels`
- API Key:已使用测试环境普通 Key,报告中不展开。
- 租户:`default`
- 测试账号:本轮通过短信验证码固定码 `123456` 和 WebSocket `{acc}` 临时注册,用户 ID `usrIPhpZJeJDMI`。
- 本轮只新增本报告,不修改原前后端对接文档。
## 2. 总体结论
测试环境已能跑通基础启动、注册、登录、bootstrap、通讯录空列表、建群、工作台、日报/月报草稿、个人资料、隐私、反馈、设备推送 Token 等主链路。整体方向和产品需求基本匹配:Tinode WebSocket 承担消息与 Topic,`/v1/im/*` 承担产品化业务接口。
但当前仍不建议直接按整份对接文档全量联调。主要风险是:部分文档接口没有路由或路由兜底错误,部分配置类接口在测试库空数据时返回 500/502,部分通用响应约定没有统一落到所有列表接口,办公协同和转发/群文件相关接口还存在明显缺口。
## 3. P0/P1 问题
处理备注(2026-08-20):
- 已在提交 `6c6d2f6 fix: stabilize im audit endpoints` 中处理本节前 6 项,以及 `bootstrap.limits` 缺失问题。
- 已用本地 Docker 启动服务并调用本地 API 验证通过;测试环境需发布该提交后再复测公网结果。
- 当前短信供应商未接入时,注册、找回密码和敏感操作验证码均按测试码 `123456` 验证;生产接入短信供应商后应切回真实短信策略。
| 优先级 | 问题 | 测试结果 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| P0 | 密码重置发码 `scene=reset_password` 返回 502 | `POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"15509271990"}`,带 `Idempotency-Key`,返回 Cloudflare `502` | 忘记密码无法联调,且不是统一 JSON 错误 | 已处理:短信未接入时保留 challenge,并用测试码 `123456` 验证;本地 API 验证返回 200 |
| P0 | 登录后二次身份验证挑战返回 502 | `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`,带 `Idempotency-Key`,返回 Cloudflare `502` | 关闭青少年模式、敏感操作二次验证不可用 | 已处理:短信发送失败不再使 challenge 失败;带 `Idempotency-Key` 创建和 `123456` verify 本地均返回 200 |
| P0 | 总经理信箱配置和提交返回 500 | `GET /v1/im/executive-mailbox/config` 返回 `INTERNAL_ERROR`;按文档提交也返回 500 | 工作台入口开启但页面不可用 | 已处理:修复 `im_mailbox_config`、`im_mailbox_submissions` 的 `SELECT *` 映射问题;本地 config/submit 均返回 200 |
| P1 | 举报原因返回 500 | `GET /v1/im/reports/abuse/reasons?target_type=user` 返回 `INTERNAL_ERROR` | 举报页无法打开 | 已处理:补齐 `AbuseReason` 数据库字段映射;本地返回 200 和 `items` |
| P1 | 转发目标接口不存在 | `GET /v1/im/forward-targets?query=` 返回 404 | 4.17~4.20 转发目标页无法联调 | 已处理:新增 `/v1/im/forward-targets`,返回文件助手和可转发的 P2P/群会话;本地返回 200 |
| P1 | 群文件接口被群详情兜底吞掉 | `GET /v1/im/groups/{topic}/files` 返回群详情 JSON,而不是文件列表或 404 | 前端会误判接口成功,群文件页数据结构错误 | 已处理:未知一级 tail 不再落到群详情;`/files` 未实现时本地返回 404 |
## 4. 章节审核
### 0. 公共对接约定
通过项:
- `POST /v1/im/tenants/resolve` 可未登录调用,返回统一 `code/data/request_id/server_time`。
- `endpoints` 已返回正确公网地址:`https://api.118im.com/v1/im`、`wss://api.118im.com/v0/channels`、`https://api.118im.com/v0/file`。
- 缺 token 访问 `GET /v1/im/bootstrap` 返回统一 JSON:`401 AUTH_REQUIRED`。
问题:
- 公共约定写分页统一返回 `items,next_cursor,has_more`,但很多接口只返回 `items,next_cursor`,例如 `friends`、`friend-requests`、`system-notifications`、`favorites`、`calls`。
- 写请求确实依赖 `Idempotency-Key`,但部分章节没有重复强调;前端实现时必须按公共约定统一加。
### 1. 账号注册、登录与身份安全
通过项:
- 企业解析通过,且不泄漏 `tenant_id`。
- 注册短信发码通过:返回 `challenge_id/expires_in_seconds/resend_after_seconds/phone_mask`。
- 固定验证码 `123456` 验证通过:返回 `verification_token`。
- WebSocket `hi(tenant=default)` 返回 `201`,`acc user:new login:true` 注册成功,返回 `user/token/expires/username`。
- `GET /v1/im/security/realname` 返回 `status=unverified, can_submit=true`。
问题:
- 已处理待发版复测:`reset_password` 发码不再因短信供应商未接入返回 502,本地返回 200。
- 已处理待发版复测:`POST /v1/im/security/challenges` 不再因短信供应商未接入返回 502,本地创建和校验均返回 200。
- 文档目标错误 reason 很完整,但 WS 登录/注册仍主要是 Tinode `ctrl.text/code`;稳定 `params.reason` 是否全链路返回还需要继续核。
### 2. 消息首页、全局导航与系统通知
通过项:
- `GET /v1/im/bootstrap` 已返回 `features/permissions/message_policy/security/modules/system_topic`。
- 最新测试环境已补 `entries` 和 `entry_badges`,包括 `new_friends/groups/file_assistant/system_messages` 入口与未读数字段,能支撑通讯录和首页入口显示。
- `GET /v1/im/system-notifications?limit=5` 返回 `items,next_cursor`。
- `GET /v1/im/search/conversations?q=a` 可返回已创建群会话,包含 `seq/read/unread/public/private`。
问题:
- 已处理待发版复测:`bootstrap` 已补 `limits.max_file_size/max_text_length/max_forward_targets`,本地返回 200。
- 系统通知没有聚合未读接口,当前依赖 `bootstrap.entry_badges.system_unread_count`,需要确认其真实计算来源和实时更新事件。
### 3. 单聊会话、会话建立与消息状态
通过项:
- 路由存在 `GET /v1/im/conversations/{topic}/context`。
- 好友申请接口存在:`POST/GET /v1/im/friend-requests`,列表空数据返回正常。
- 好友列表 `GET /v1/im/friends` 返回正常空列表。
问题:
- 文档出现 `POST /v1/im/conversations/p2p`、`GET /v1/im/conversations/{topic}/messages`、`PATCH /v1/im/conversations/{topic}/settings`,路由清单未看到对应明确 handler,需要进一步实测或补实现。
- 单聊发送约束最终仍要靠 WebSocket `pub` 校验,HTTP 文档里的关系状态和 WS 发布路径需要做端到端用例。
### 4. 消息输入、媒体内容与消息操作
通过项:
- 收藏基础接口存在,`GET /v1/im/favorites?limit=5` 正常返回空列表。
- 消息撤回/编辑/回执类路由挂在 `/v1/im/topics/` 下,代码有统一 handler。
问题:
- 已处理待发版复测:`GET /v1/im/forward-targets?query=` 已新增,本地返回 200。
- 文档里的 `POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{bundle_id}` 未在路由表看到。
- 文件上传仍主要使用 `/v0/file`,但业务 `purpose`、附件 ACL、群文件登记接口还没有从测试结果证明可用。
- 已处理待发版复测:`bootstrap.limits.max_forward_targets/max_file_size/max_text_length` 本地已返回。
### 5. 通讯录、好友关系与黑名单
通过项:
- `GET /v1/im/friends`、`GET /v1/im/friend-requests`、`GET /v1/im/blocks` 均返回统一成功空列表。
- `GET /v1/im/users/search` 可调用,空结果返回 `items:[]`。
- `bootstrap.entries.new_friends` 和 `entry_badges.new_friends_pending_count` 已能控制入口和未处理数。
问题:
- 本轮新注册用户搜索自己的 username 返回空列表,可能是“排除自己”的合理策略,也可能是搜索索引/隐私导致,需要用第二个用户互搜确认。
- 新朋友未处理数现在在 `bootstrap.entry_badges` 返回,但需要确认好友申请创建/处理后是否实时准确更新。
### 6. 群聊创建、资料维护与成员治理
通过项:
- `POST /v1/im/groups` 已支持截图里的 `name + description` 创建群聊,返回 `topic/name/description/owner/member_count/version/permissions`。
- `GET /v1/im/groups`、`GET /v1/im/groups/{topic}`、`GET /summary`、`GET /members`、`POST /qrcodes`、`GET /governance`、`GET /role-limits`、`GET /mutes` 均可用。
- 群二维码返回 `qr_token/qr_payload/expires_at/group_snapshot`,符合需求。
问题:
- `GET /v1/im/groups/{topic}/members` 返回的 owner 成员 `user_id` 为空字符串,这不适合前端成员列表和后续管理员/转让操作,应返回 `usr...`。
- 已处理待发版复测:`GET /v1/im/groups/{topic}/files` 不再返回群详情,本地返回 404。
- 已处理待发版复测:`GET /v1/im/groups/{topic}/announcements` 不再返回群详情,未知一级 tail 统一返回 404。
- 文档里的 `POST /v1/im/group-invitations/{invitation_id}/accept` 未在路由表看到;当前更像是走群内 `/invitations` 和扫码 join-request。
### 7. 文件下载、收藏管理与聊天记录检索
通过项:
- `GET /v1/im/favorites` 可用。
- 收藏批量、删除、转发相关 handler 在 `/favorites` 和 `/favorites/` 下存在。
问题:
- 群文件接口不可用,且被错误兜底。
- 文档中的聊天记录搜索、媒体筛选、上下文接口挂在 `/v1/im/topics/{topic}/...`,需要有真实消息后再端到端验证;本轮未创建聊天消息。
- 文件下载鉴权复用 `/v0/file`,本轮未上传真实附件,ACL 未验证。
### 8. 实时语音与视频通话
通过项:
- `GET /v1/im/calls?limit=5` 返回正常空列表。
- 文档对通话主体设计合理:WebSocket 信令为主,HTTP 只做记录查询。
问题:
- 需要真机或至少两个 WS 用户做 call `started/ringing/accept/hang-up` 端到端;本轮只验证 HTTP 通话记录列表。
- Push、好友关系、拉黑、并发通话校验没有通过测试环境验证。
### 9. 企业工作台与办公协同
通过项:
- `GET /v1/im/workbench` 返回模块列表和 `layout_version`。
- `GET /v1/im/organization/departments` 返回正常空列表。
- 日报列表、日报统计、日报草稿创建通过。
- 月报列表、月报统计、月报草稿创建通过。
- 会议创建按文档 Body 通过。
问题:
- `GET /v1/im/meetings?limit=5` 返回 405;文档没明确会议列表接口,但会议通知页面通常需要列表,设计上应补 `GET /v1/im/meetings`。
- `GET /v1/im/organization/search?q=` 空关键词返回 400;文档示例写 `q=`,需明确前端空关键词应不请求还是后端返回空列表。
- `GET /v1/im/executive-mailbox/config` 和提交均返回 500,总经理信箱不可联调。
- `modules[].unread_count` 当前全 0,需要确认是否真实 badge,还是静态占位。
### 10. 个人资料、通用设置、隐私与青少年模式
通过项:
- `GET /v1/im/me`、`GET /v1/im/me/profile`、`GET /v1/im/me/privacy`、`GET /v1/im/settings/youth-mode` 均可用。
- `POST /v1/im/me/qrcodes` 可用,返回 `qr_token/qr_payload/display_profile/expires_at`。
- `PUT /v1/im/devices/{device_id}/push-token` 可用,能登记 APNs 测试 token。
- `GET /v1/im/app/releases/latest?platform=ios&version=1.0.0` 可用。
问题:
- 文档写应用更新参数为 `current_version`,测试使用 `version` 也能返回;实现似乎不消费当前版本参数,建议统一字段名,避免客户端多套逻辑。
- 青少年模式关闭依赖二次验证,而二次验证挑战当前 502。
- Android 系统权限说明多为客户端能力,本轮无法通过服务端验证。
### 11. 反诈安全、举报与意见反馈
通过项:
- `POST /v1/im/security/urls/check` 可用,安全 URL 返回 `decision=allow/risk_level=low`。
- `GET /v1/im/feedback/config` 可用。
- `POST /v1/im/feedback` 可用,返回 `feedback_id/status=received`。
问题:
- `GET /v1/im/reports/abuse/reasons?target_type=user` 返回 500,举报页阻塞。
- 风控 challenge confirm 路由存在,但需要先有业务接口返回 `RISK_CONFIRMATION_REQUIRED` 后才能完整验证。
- 举报提交需要真实目标对象和原因数据,本轮因原因接口 500 未继续提交。
### 12. 联调实施清单
建议把当前验收顺序调整为:
1. 已处理待发版复测:密码重置 502、二次验证 502、总经理信箱 500、举报原因 500。
2. 部分已处理待发版复测:转发目标已补、群文件路径错误兜底已修;消息转发/Forward Bundle 缺路由、群成员 `user_id` 为空仍待处理。
3. 统一公共协议:所有列表是否必须返回 `has_more`;所有写接口是否都必须 `Idempotency-Key`;`bootstrap.limits` 是否需要补。
4. 两用户端到端:好友申请、同意、P2P context、普通消息、已读、拉黑、删除好友。
5. 群端到端:邀请成员、入群审批、公告、群文件、管理员、禁言、群主转让。
6. 真机端到端:文件上传下载、APNs/FCM 推送、WebRTC 通话。
## 5. 代表性实测结果
| 接口 | 方法 | 状态 | 结论 |
| --- | --- | --- | --- |
| `/v1/im/tenants/resolve` | POST | 200 | 企业解析正常,endpoint host 正确 |
| `/v1/im/auth/verification-codes` register | POST | 200 | 注册发码正常 |
| `/v1/im/auth/verification-codes/{id}/verify` | POST | 200 | 固定验证码 `123456` 正常 |
| WebSocket `hi + acc` | WS | 201/200 | 注册并登录成功 |
| `/v1/im/auth/verification-codes` reset_password | POST | 502 | 已处理待发版复测;本地返回 200 |
| `/v1/im/security/challenges` | POST | 502 | 已处理待发版复测;本地创建和 `123456` 校验返回 200 |
| `/v1/im/bootstrap` | GET | 200 | 已包含 `entries/entry_badges`;已补 `limits` 待发版复测 |
| `/v1/im/groups` | POST | 200 | 创建群聊含 `description` 正常 |
| `/v1/im/groups/{topic}/members` | GET | 200 | 可用,但成员 `user_id` 为空 |
| `/v1/im/groups/{topic}/files` | GET | 200 | 已处理待发版复测;未实现时本地返回 404,不再误返回群详情 |
| `/v1/im/forward-targets` | GET | 404 | 已处理待发版复测;本地返回 200 |
| `/v1/im/executive-mailbox/config` | GET | 500 | 已处理待发版复测;本地 config/submit 返回 200 |
| `/v1/im/reports/abuse/reasons` | GET | 500 | 已处理待发版复测;本地返回 200 |
| `/v1/im/feedback` | POST | 200 | 意见反馈提交正常 |
| `/v1/im/devices/{device_id}/push-token` | PUT | 200 | 推送 token 登记正常 |
## 6. 本轮测试产生的数据
- 创建临时用户:`usrIPhpZJeJDMI`
- 创建群:`grpcNdeRoa_VO0`
- 创建会议:`mtg_vN4182ncLyi5wp4A`
- 创建日报草稿:`dr_MdAFCj2fYTkBjiq8`
- 创建月报草稿:`mr_eWjm2hpuzSsqcIcH`
- 创建反馈:`fb_TEJYaStZn9i0JSun`
- 登记设备:`audit-device`
这些数据均在测试租户 `default` 下,可按需要由测试库清理。
+313
View File
@@ -0,0 +1,313 @@
# IM 独立库多租户改造方案
> 编写日期:2026-08-13
> 适用项目:`im_server`
> 方案状态:替代原“共享库共享表 + tenant_id”方案,后续多租户开发以本文为准。
## 1. 方案结论
多租户调整为:
> 共享服务集群 + 控制库保存租户元数据 + 每个租户独立业务库
业务库中的 `users`、`auth`、`credentials`、`topics`、`subscriptions`、`messages`、`fileuploads` 以及后续产品业务表都不再增加 `tenant_id` 字段。租户隔离通过运行时选库实现:客户端先用企业码解析租户,服务端得到 `tenant_id` 后,根据控制库中的 `db_name` 或 `db_dsn` 选择对应租户业务库。
`TenantID` 仍然要保留,但它的职责从“业务表行级隔离字段”调整为“运行时租户上下文”:
1. 登录前企业码解析;
2. Token 签名和校验;
3. Session 当前租户固定;
4. Hub、缓存、集群路由、Push、文件路径和插件上下文隔离;
5. 选择当前请求使用哪个租户业务库;
6. 后台平台侧管理租户、配置配额、停启租户。
## 2. 为什么独立库仍需要 TenantID
独立库解决的是数据库物理隔离,不代表服务端运行时没有租户。服务进程仍然同时服务多个企业,如果内存 Topic、用户缓存、系统 Topic、Push、文件下载授权、Token 校验不带租户上下文,仍可能出现:
1. 两个租户都使用逻辑 Topic `sys` 时共用同一个内存 Topic;
2. HTTP 文件下载只拿到 `file_id`,无法判断该去哪个租户库查授权;
3. Token 只包含 UID 时,无法知道该 UID 属于哪个租户库;
4. 定时任务、插件调用、Push 回调无法定位租户库;
5. 后台接口可能把平台租户 ID 和业务租户数据混在一起。
所以新方案的边界是:
```text
TenantID 负责运行时路由和授权上下文
tenant_id 字段不进入租户业务库表结构
```
## 3. 数据库拆分
### 3.1 控制库
控制库可以使用现有 `hotgo`,只保存平台级租户元数据和配额配置。控制库表需要保留 `tenant_id`,因为这些表本身是在管理所有租户;这里的 `tenant_id` 不是业务库行级隔离字段。
```sql
CREATE TABLE im_tenant (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_tenant_code (code),
KEY idx_im_tenant_db_name (db_name)
);
CREATE TABLE im_tenant_config (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id),
CONSTRAINT fk_im_tenant_config_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id)
ON DELETE CASCADE
);
```
`db_name` 适合所有租户共用同一个 MySQL 实例时使用,例如 `im_tenant_1001`。`db_dsn` 适合某些租户需要独立 MySQL 实例时使用。优先级建议为:`db_dsn` 非空时使用完整 DSN,否则使用主 DSN + `db_name`。
如果要求 `db_name` 不能重复,建议在租户创建服务里校验“非空 db_name 唯一”。MySQL 普通唯一索引不能表达“只对非空字符串唯一”,直接给 `db_name` 建唯一索引会导致多个使用独立 `db_dsn` 的租户因为空 `db_name` 冲突。
### 3.2 租户业务库
每个租户一个业务库,例如:
```text
hotgo
├─ im_tenant
└─ im_tenant_config
im_tenant_1001
├─ users
├─ auth
├─ credentials
├─ topics
├─ subscriptions
├─ messages
├─ fileuploads
└─ 后续产品业务表
```
租户业务库表结构恢复为租户内唯一约束,不加 `tenant_id`。同一个手机号、用户名可以在两个租户库中各自存在,不会冲突。
## 4. 当前代码如何调整
当前代码已经有一部分独立库路由雏形,可以继续利用:
1. `server/store/store.go` 已经预留 `TenantRoutedAdapter` 分支;
2. `server/db/adapter.go` 已定义 `TenantAdapterFor(tenantID)`;
3. `server/db/mysql/adapter.go` 已支持 `tenant_registry_db`、`tenant_db_routing`、`db_name`、`db_dsn`;
4. Token、Session、Hub TopicKey、UserKey、插件上下文已经带 `TenantID`。
需要调整的重点是:
| 模块 | 旧共享表方案 | 新独立库方案 |
| --- | --- | --- |
| 控制库 | 与业务表混在一个库 | 单独保存 `im_tenant`、`im_tenant_config` |
| 业务库 schema | 所有表加 `tenant_id` | 所有业务表不加 `tenant_id` |
| MySQL Adapter | SQL 使用 `WHERE tenant_id=?` | 先根据 TenantID 选库,再执行租户库内 SQL |
| Store 接口 | TenantID 作为 SQL 条件 | TenantID 作为选库参数 |
| 唯一索引 | `(tenant_id, business_key)` | 租户库内 `business_key` 唯一 |
| 外键 | 复合外键带 `tenant_id` | 普通外键 |
| 数据迁移 | 共享库内补列回填 | 新租户直接建独立库,无历史迁移 |
## 5. 代码落地步骤
### 5.1 拆分 schema
把 MySQL schema 拆成两个层次:
1. `registry_schema.sql`:只创建控制库表 `im_tenant`、`im_tenant_config`;
2. `tenant_schema.sql`:创建 Tinode 核心表和产品业务表,不包含 `tenant_id`;
3. `schema.sql` 可以作为文档索引,说明不能直接混用两套 schema。
`CreateDb(reset)` 也要拆分:
1. 初始化控制库;
2. 初始化默认租户业务库;
3. 根据 `im_tenant.db_name/db_dsn` 初始化指定租户库;
4. 禁止在同一个事务中同时创建控制库和多个租户业务库,避免失败后状态难恢复。
### 5.2 改 MySQL Adapter 路由
保留 `TenantAdapterFor(tenantID)`,但租户 Adapter 内执行的 SQL 应该是无 `tenant_id` 版本。
推荐结构:
```text
mysql.adapter
├─ registryDB: 连接控制库 hotgo
├─ defaultDB: 默认租户库
├─ tenantAdapters: map[TenantID]*adapter
└─ TenantAdapterFor(tenantID): 从 registryDB 读取 db_name/db_dsn 并返回租户库 adapter
```
租户业务方法仍保留 `TenantUserGet(tenantID, uid)` 这类签名,但实现改为“先选库,后执行租户库内原 SQL”。实际写代码时要避免递归,可以把“无租户 SQL”的方法放在租户库 adapter 上,把“路由包装方法”放在根 adapter 上。
### 5.3 删除业务表 tenant_id 依赖
需要回退或重写这些位置:
1. `server/db/mysql/schema.sql` 中核心业务表的 `tenant_id` 字段;
2. `server/db/mysql/tenant_adapter.go` 中所有 `WHERE tenant_id=?`、`INSERT tenant_id`、复合 JOIN;
3. `server/db/mysql/tests/mysql_test.go` 中依赖共享表隔离的测试;
4. `docs/sql/mysql-multitenant-tenant-id-review.sql` 这份共享表迁移脚本应标记为废弃;
5. 后续产品业务表设计也不再带 `tenant_id`。
不建议删除 Store、Session、Token、Hub、Push 和插件里的 `TenantID`,这些属于运行时隔离边界。
### 5.4 租户创建流程
后台创建租户时建议按以下顺序:
1. 在控制库 `im_tenant` 创建记录,状态为 `provisioning`;
2. 生成业务库名,例如 `im_tenant_{id}`;
3. 创建业务库并执行 `tenant_schema.sql`;
4. 写入默认系统数据,例如 `kvmeta.version`、系统账号、`sys` Topic;
5. 回写 `im_tenant.db_name`,状态改为 `active`;
6. 创建失败时租户保持不可登录状态,并记录错误供后台重试。
第一阶段可以不做自动建库接口,先由运维执行建库 SQL,再在控制库写入 `im_tenant`。
### 5.5 认证与登录
登录入口仍然是:
1. 客户端携带 `X-Tinode-APIKey` 请求准入;
2. `hi` 或登录前 HTTP 接口提交企业码;
3. 服务端在控制库查 `im_tenant.code`;
4. 租户状态必须是 `active`;
5. Session 固定 `TenantID`;
6. 登录认证根据 `TenantID` 选择租户业务库,然后查该库内 `auth`、`credentials`、`users`;
7. Token 签名内容继续包含 `TenantID`,HTTP 后续请求可从 Token 反推出该使用哪个租户库。
客户端不能通过 URL、Header、Body 直接指定业务库名或 `tenant_id`。
## 6. 产品业务表如何设计
后续根据《IM 产品原型需求文档》补充的业务表也按“租户库内表”设计,不加 `tenant_id`。所有新业务表仍统一要求:
```sql
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
```
例如好友申请:
```sql
CREATE TABLE im_friend_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id CHAR(26) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(256),
status SMALLINT NOT NULL,
handled_by BIGINT,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_im_friend_requests_request_id (request_id),
KEY idx_im_friend_requests_target_status (target_id, status, created_at),
KEY idx_im_friend_requests_applicant_status (applicant_id, status, created_at)
);
```
## 7. 文件和对象存储
数据库独立库后,文件元数据在租户库内隔离,但对象存储路径仍建议带租户目录:
```text
tenants/{tenant_id}/yyyy/mm/{file_id}
```
对象存储通常仍是共享 Bucket。带租户目录便于权限策略、容量统计、清理和迁移。
文件下载流程:
1. HTTP Token 解析得到 `tenant_id` 和 `uid`;
2. 根据 `tenant_id` 选择租户业务库;
3. 在该库查 `fileuploads` 和 `filemsglinks`;
4. 校验当前用户是否有 Topic 或消息访问权;
5. 返回文件或预签名 URL。
## 8. 测试策略
需要把旧共享表测试改成独立库测试:
| 用例 | 准备 | 预期 |
| --- | --- | --- |
| 企业码解析 | 控制库有 A、B 两个租户 | A、B 返回不同 `tenant_id` 和不同 `db_name` |
| 同手机号注册 | A、B 租户库分别注册同手机号 | 两边都成功 |
| 登录选库 | A 用户用 B 企业码登录 | B 库查不到该用户,登录失败 |
| Token 选库 | A Token 请求文件接口 | 只访问 A 租户库 |
| 内存 Topic 隔离 | A、B 都使用 `sys` | Hub 中是两个 TopicKey |
| 租户停用 | 控制库把 A 设为 suspended | 新登录失败 |
| 租户库不可用 | A 的 DSN 错误或库不存在 | A 请求失败,B 不受影响 |
| 连接池复用 | 多次访问 A | 只创建一个 A 租户 adapter,后续复用 |
| 配额检查 | A 配置 max_users=1 | A 超额注册失败,B 不受影响 |
## 9. 对旧方案的处理
以下内容停止继续开发:
1. 给核心业务表增加 `tenant_id`;
2. 把唯一索引改成 `(tenant_id, xxx)`;
3. 所有业务 SQL 加 `WHERE tenant_id=?`;
4. 共享库内的数据迁移、回填、复合外键改造;
5. 产品业务表中的 `tenant_id` 字段设计。
以下内容继续保留:
1. 企业码识别;
2. 控制库 `im_tenant`、`im_tenant_config`;
3. `TenantID` 类型;
4. Session TenantID;
5. Token TenantID;
6. Hub、缓存、集群、Push、插件上下文中的 TenantID;
7. `TenantAdapterFor` 选库机制。
## 10. 推荐下一步
下一步代码改造建议从 MySQL 层开始:
1. 把 `server/db/mysql/schema.sql` 拆为控制库 schema 和租户业务库 schema;
2. 把 `tenant_adapter.go` 改成路由包装,不再写 `tenant_id` SQL;
3. 恢复或复用原 `adapter.go` 中无租户 SQL 作为租户库真实执行逻辑;
4. 修改 MySQL 测试,创建两个独立业务库验证隔离;
5. 再继续补产品业务表,所有业务表按租户库内表设计。
## 11. 当前代码落地状态
2026-08-13 已开始按独立库方案改造 MySQL 底座:
1. 默认业务库为 `tinode`;
2. 控制库为 `hotgo`;
3. `CreateDb` 会在 `hotgo` 创建 `im_tenant`、`im_tenant_config`,默认租户 `id=1/code=default/db_name=tinode`;
4. `CreateDb` 会在 `tinode` 创建核心业务表,核心表不再包含 `tenant_id`;
5. `TenantAdapterFor` 开启路由后默认租户也会从控制库解析,不再直接退回根连接;
6. 租户库 adapter 禁用二次路由,避免递归;
7. 用户、认证、凭证、Topic、Subscription、消息、设备、文件等核心 Tenant 方法已改为“先选库,再调用租户库内无 tenant_id 方法”;
8. 旧共享表 `tenant_id` 迁移 SQL 已标记废弃。
剩余项:
1. 产品扩展表和 CRUD,例如好友、收藏、实名、青少年、系统通知,还需要继续从 `tenant_id` SQL 改成租户库内无 `tenant_id` SQL;
2. `server/db/mysql/schema.sql` 仍是参考文件,已标注正在从旧共享表草稿拆分,后续应拆成 `registry_schema.sql` 和 `tenant_schema.sql`;
3. 旧版本 `UpgradeDb` 迁移链仍包含共享表历史步骤。因为当前项目不考虑历史数据,优先使用新建库 `CreateDb`;如后续需要升级老库,应单独重写迁移链。
@@ -0,0 +1,377 @@
# IM 现有实现与新需求差异对照总结
> 对照日期:2026-08-10
> 新需求来源:[IM 产品原型需求文档](./IM-产品原型需求文档.md)
> 详细改造分析:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)
> 对照对象:`chat-0.25.3` 当前服务端源码
## 1. 文档目的
本文从“现有服务端已经实现了什么”出发,对照新产品需求逐项给出处理结论:
- **保留**:现有实现已基本满足需求,只补配置或测试;
- **需修改**:现有能力可复用,但协议、数据模型、权限或流程不完整;
- **新增**:现有系统没有对应实现,需要新增模块;
- **客户端为主**:主要是移动端交互,服务端只提供状态、配置或资源;
- **废弃**:现有产品能力或接入方式在新需求中没有对应章节,不再作为新产品能力继续建设。
“新需求无直接章节”的数据库、对象存储、集群、监控等运行基础设施不按产品功能废弃,而标记为“保留基础设施”。真正需要废弃的能力单列在第 5 节。
## 2. 总体结论
当前系统可以继续作为新产品的即时通讯内核,不需要重写。现有 P2P Topic、群 Topic、Subscription ACL、消息序号、消息广播、回执、文件上传、Push 和 WebRTC 信令均可复用。
新需求与现状的主要关系如下:
| 新需求章节 | 功能域 | 当前覆盖程度 | 结论 |
| --- | --- | --- | --- |
| 第 1 章 | 账号、企业码、短信、实名、独立密码 | 部分覆盖 | 认证内核保留,产品流程需修改,企业码/实名需新增 |
| 第 2 章 | 会话首页、网络状态、消息操作、系统消息 | 部分覆盖 | 会话与连接保留,产品状态和系统通知需修改 |
| 第 3 章 | 单聊、好友状态、回执、媒体和名片 | 部分覆盖 | P2P/回执保留,好友约束和结构化消息需修改 |
| 第 4 章 | 输入、@、语音、文件、收藏、转发 | 少量覆盖 | 文件和通用消息可复用,大部分产品语义需新增 |
| 第 5 章 | 通讯录、好友申请、主页、黑名单 | 少量覆盖 | 用户搜索可改造,关系状态机需新增 |
| 第 6 章 | 群创建、审批、公告、管理、禁言、群文件 | 部分覆盖 | 群和 ACL 保留,群治理业务需新增 |
| 第 7 章 | 收藏、下载、聊天记录搜索 | 少量覆盖 | `slf` 和文件可复用,授权和索引必须修改 |
| 第 8 章 | 语音/视频通话 | 部分覆盖 | P2P WebRTC 信令保留,通话业务状态需修改 |
| 第 9 章 | 组织、会议、日报、月报、信箱 | 未覆盖 | 全部新增业务模块 |
| 第 10 章 | 资料、设置、版本、隐私、青少年模式 | 少量覆盖 | 通用资料可改造,其余多数新增或客户端实现 |
| 第 11 章 | 反诈、举报、反馈 | 未形成业务闭环 | 插件机制保留,业务模型和处置流程新增 |
必须优先处理三个基础问题:
1. 消息发布缺少客户端消息 ID 幂等校验,断线重试可能产生重复消息;
2. 文件下载只校验登录,没有校验用户对文件关联 Topic/消息的读取权限;
3. 当前核心表没有 `tenant_id`,共享数据库部署多个企业前必须完成全链路租户隔离。
## 3. 现有核心实现与新需求关系
| 现有实现 | 源码位置 | 对应新需求章节 | 结论 | 修改说明 |
| --- | --- | --- | --- | --- |
| basic 用户名/密码认证 | `server/auth/basic` | 1.2、1.3、1.4、1.6 | 需修改 | 保留认证器,增加协议确认、密码策略、错误次数、锁定、找回密码编排和旧 Token 撤销 |
| HMAC Token 登录 | `server/auth/token` | 1.2、1.6;2.2 | 保留 | 继续作为登录态;补设备会话、单设备撤销和密码重置后的 Token 失效 |
| 电话号码及短信验证器 | `server/validate/tel` | 1.4、1.5、1.6、1.7 | 需修改 | 保留验证码能力,增加用途隔离、发送频控、`retry_after`、一次性验证票据和审计 |
| REST 外部认证器 | `server/auth/rest` | 1.1、1.2 | 需修改 | 可连接企业账号中心;企业码解析仍需新增独立入口,认证后绑定企业上下文 |
| Session/WebSocket 协议分发 | `server/session.go` | 2.2;第 3、4、8 章 | 保留 | 作为 App 的主实时入口;补幂等、产品策略和结构化错误 |
| Topic 消息顺序、保存和广播 | `server/topic.go` | 第 2、3、4、6、7 章 | 需修改 | 保留核心循环;持久化前增加好友、黑名单、禁言、青少年和内容风险校验 |
| P2P Topic | `server/topic.go` | 3.1-3.12;5.2-5.14 | 需修改 | 保留单聊容器;是否允许创建、发送和呼叫改由好友/黑名单/隐私策略控制 |
| Group Topic | `server/topic.go` | 6.1-6.28;4.2-4.4 | 需修改 | 保留群消息和成员基础;增加审批、公告、二维码、禁言和群文件业务层 |
| Subscription ACL | `server/store/types/types.go`、`server/topic.go` | 3.1-3.8;6.4-6.26 | 需修改 | 保留 Join/Read/Write/Admin/Owner;不能用 ACL 代替好友申请、待审批和禁言记录 |
| Presence、输入状态 | `server/topic.go`、`server/datamodel.go` | 2.1;3.1-3.4;4.9 | 需修改 | 在线/输入状态可复用;增加隐私控制、节流和过期清理 |
| `recv`、`read` 回执 | `server/topic.go` | 3.4、3.7 | 保留 | 定义客户端状态映射;群聊是否显示具体已读人由产品规则控制 |
| 通用 JSON `head/content` 与 Drafty | `server/datamodel.go`、`server/drafty` | 3.6、3.10-3.12;4.1-4.20 | 需修改 | 增加消息类型、schema 版本、字段白名单、引用、@、转发和审核状态,不再允许各端自定义语义 |
| 消息删除 | `server/session.go`、`server/topic.go` | 2.4;3.8;4.15 | 需修改 | 明确仅自己删除、全员撤回、撤回期限、管理员权限和审计,不直接复用模糊的硬删除语义 |
| `slf` 自收藏 Topic | `server/store/types/types.go`、`server/topic.go` | 4.16;7.1-7.8 | 需修改 | 继续保存收藏消息,同时增加收藏索引、来源、去重、类型筛选和源消息删除策略 |
| 文件上传与消息附件关联 | `server/hdl_files.go`、`server/store/store.go` | 3.6、3.10-3.12;4.10-4.20;6.27-6.28;7.3、7.7-7.11 | 需修改 | 保留上传;增加附件所有权、病毒扫描、群文件索引、短时下载 URL 和资源级授权 |
| Push Handler | `server/push.go`、`server/push` | 2.1、2.5;4.4;8.1-8.11;9.3-9.4;10.18 | 需修改 | 保留 Push;扩展 @、审批、会议、通话和安全通知,增加锁屏内容隐私设置 |
| P2P WebRTC 信令 | `server/calls.go` | 8.1-8.11 | 需修改 | 保留 Invite/Offer/Answer/ICE/Hang-up;新增 `call_id`、设备抢答、结束原因、详单和 TURN 指标 |
| Plugin Firehose | `server/plugins.go` | 11.1-11.4;2.5 | 需修改 | 用于外部内容审核和反诈;增加超时、熔断、失败策略和审核结果落库 |
| User Public/Trusted/Tags | `server/store/types/types.go` | 5.1、5.5-5.8;10.1-10.6 | 需修改 | 可保存基础展示资料;生日、可见性、实名和组织关系应使用明确字段/业务表 |
| Topic Public/Trusted/Aux/Tags | `server/store/types/types.go` | 6.10-6.16 | 需修改 | 可保存群基础展示资料;公告、审批、二维码和禁言不能只存任意 JSON |
| `fnd` 用户/Topic 查找 | `server/topic.go` | 5.1、5.3-5.5 | 需修改 | 改成租户内精准 ID/受控搜索;执行隐私策略并防止手机号、用户 ID 枚举 |
| `sys` 系统 Topic | `server/store/types/types.go`、`server/topic.go` | 2.5;第 9、11 章通知 | 需修改 | 作为可信系统消息通道;定义消息类型、模板版本、跳转参数和去重键 |
| MySQL 核心表和 Adapter | `server/db/mysql` | 所有服务端需求的存储基础 | 需修改 | schema 118 已建立 `im_tenant`;仍需为核心表增加 tenant_id,并新增产品表、幂等、搜索索引和文件授权查询 |
| 文件系统/S3 媒体后端 | `server/media/fs`、`server/media/s3` | 第 3、4、6、7、11 章附件 | 保留基础设施 | 生产使用 S3/兼容对象存储,本地文件系统仅开发;补扫描和私有签名地址 |
## 4. 按新需求章节逐项对照
### 4.1 第 1 章:账号注册、登录与身份安全
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 1.1 | 企业码登录 | 无企业/租户模型 | 新增 | 增加企业码解析 API、企业状态、服务地址和认证策略;共享库模式需先加 `tenant_id` |
| 1.2-1.3 | 账号密码登录及错误提示 | basic + token 已有 | 需修改 | 增加协议确认、统一错误码、失败计数、账号锁定、设备登录记录和安全通知 |
| 1.4-1.5 | 短信注册和倒计时 | 电话验证器已有 | 需修改 | 新增注册流程 API;验证码按用途隔离,返回有效期和重发时间,服务端强制限频 |
| 1.6 | 忘记密码 | 验证器与密码更新能力分散 | 需修改 | 建立“发码 → 临时票据 → 重置”状态机;成功后撤销旧 Token |
| 1.7 | 敏感操作二次验证 | 无统一业务流程 | 新增 | 增加验证挑战、一次性票据、有效期、错误次数和操作绑定 |
| 1.8-1.10 | 实名认证及状态提醒 | 无 | 新增 | 独立实名表、字段加密、审核状态、脱敏查询、审核回调和提醒消息 |
| 1.11 | 四位独立密码 | 无 | 新增 | 保存强哈希、失败锁定和找回流程;禁止写入普通设置 JSON 或明文日志 |
### 4.2 第 2 章:消息首页、全局导航与系统通知
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 2.1 | 会话列表、摘要、未读数 | Subscription/Topic 已有基础 | 需修改 | 增加置顶、免打扰、隐藏、草稿和结构化消息摘要;文件助手使用固定系统 Topic |
| 2.2 | 断网、重连、增量同步 | Session 与序号拉取已有 | 需修改 | 明确同步游标、重连退避、重复消息幂等和连接状态指标 |
| 2.3 | 快捷菜单 | 无服务端页面逻辑 | 客户端为主 | 服务端只返回能力/权限开关,菜单和关闭交互由客户端实现 |
| 2.4 | 引用、复制、收藏、编辑、转发、撤回、删除、多选、群回执 | 仅删除、通用消息、回执基础 | 需修改 | 增加引用/编辑/转发协议,撤回规则和批量操作校验;复制/多选 UI 在客户端 |
| 2.5 | 新设备登录等系统消息 | `sys` Topic 可用,无完整事件生产 | 需修改 | 建立可信系统账号、设备/IP 审计事件、模板、跳转参数和防伪造规则 |
### 4.3 第 3 章:单聊会话、会话建立与消息状态
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 3.1-3.5 | 好友申请前后单聊状态 | P2P Topic 有,无好友状态机 | 新增/修改 | 新增好友申请和关系表;申请通过后再开放 Topic Write 与媒体能力 |
| 3.4、3.7 | 发送中、送达、已读、失败 | 消息序号和 read/recv 已有 | 需修改 | 增加客户端消息 ID、稳定状态映射和业务失败原因;拉黑消息不得持久化给对方 |
| 3.6 | 图片/语音/视频进度 | 文件上传与消息可用 | 需修改 | 统一 `file_id`、消息类型和上传完成确认;进度主要由客户端维护 |
| 3.7-3.8 | 拉黑、删除好友后的发送限制 | 无关系策略 | 新增 | 在建会话、发布、通话、名片和文件入口统一执行关系策略 |
| 3.9 | 键盘收起布局 | 无服务端职责 | 客户端为主 | 服务端无需修改 |
| 3.10-3.11 | 多类型文件/混合消息 | 通用 JSON + 文件已有 | 需修改 | 定义 text/image/voice/video/file 类型和字段校验,禁止任意 URL |
| 3.12 | 名片消息 | 无标准类型 | 新增 | 增加 `contact_card`,只传用户 ID 与安全快照,打开时重新检查资料权限 |
### 4.4 第 4 章:消息输入、媒体内容与消息操作
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 4.1 | 键盘、草稿 | 无专用服务端草稿 | 客户端为主 | 草稿默认本地;如需多端同步,增加每用户每 Topic 的草稿设置 |
| 4.2-4.4 | @成员、@所有人 | 无结构化 @语义 | 新增 | 消息保存稳定用户 ID;校验群成员和 @所有人权限,生成定向未读与 Push |
| 4.5-4.6 | 表情面板和表情消息 | Drafty 可保存文本 | 保留/客户端为主 | Unicode 表情按文本发送;独立表情包需要资源 ID 和版本,不保存本地路径 |
| 4.7-4.12 | 录音、图片/语音/视频上传与展示 | 文件和通用消息基础已有 | 需修改 | 增加媒体类型、时长/尺寸/缩略图、扫描状态;录音交互和播放进度由客户端实现 |
| 4.13-4.14 | 文件上传和文件卡片 | 已有上传与附件关联 | 需修改 | 补格式/大小策略、所有权、病毒扫描、可预览信息和安全下载 |
| 4.15 | 消息多选批量操作 | 无服务端批量语义 | 需修改 | 客户端选择;服务端逐条鉴权,限制批量数量并返回逐项结果 |
| 4.16 | 批量收藏 | `slf` 可承载 | 需修改 | 增加收藏唯一约束、来源索引、类型和批量幂等 |
| 4.17-4.19 | 单选/多目标逐条转发 | 无标准转发模型 | 新增 | 校验每个目标发送权和附件权;限制目标数;每条新消息使用独立幂等 ID |
| 4.20 | 合并转发 | 无 | 新增 | 创建不可变 `merged_forward` 快照,限制子项和总大小,清理会话 ID 与敏感字段 |
### 4.5 第 5 章:通讯录、好友关系与黑名单
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 5.1 | 好友通讯录和企业成员 | `fnd`/Tags 仅提供基础查找 | 需修改 | 新增好友关系和组织成员查询;备注优先;支持字母/拼音索引 |
| 5.2 | 新朋友列表与审批 | 无 | 新增 | 新增好友申请状态机、幂等接受/拒绝、过期和系统通知 |
| 5.3-5.5 | ID、扫码添加好友 | 基础查找可部分复用 | 需修改 | 限定租户内精准搜索;二维码使用签名短 Token;执行隐私和反枚举策略 |
| 5.6 | 好友验证留言 | 无明确模型 | 新增 | 好友申请表保存验证信息、来源、过期时间和风控结果 |
| 5.7-5.8 | 好友/非好友个人主页 | Public/Trusted 可显示基础资料 | 需修改 | 根据关系、企业和隐私设置返回不同字段,不让客户端自行裁剪敏感资料 |
| 5.9-5.10 | 拉黑开关 | 无完整黑名单模型 | 新增 | 关系表记录单方 block;立即影响消息、搜索、呼叫和资料访问 |
| 5.11 | 删除好友 | 无好友关系真源 | 新增 | 事务更新双方关系;默认保留历史但禁止继续发送,规则需产品确认 |
| 5.12-5.14 | 黑名单列表及提示 | 无 | 新增 | 提供黑名单查询、解除接口和统一业务错误,不向被拉黑人泄露过多信息 |
### 4.6 第 6 章:群聊创建、资料维护与成员治理
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 6.1-6.2 | 群列表和空状态 | Group Topic 已有 | 需修改 | 复用订阅列表;增加群业务状态、群头像摘要和已解散处理 |
| 6.3-6.6 | 加群、邀请与审批 | ACL 可邀请成员,但无申请状态机 | 新增/修改 | 新增入群申请/邀请表;审批通过后才更新 Subscription,写 Outbox 通知 |
| 6.7-6.9 | 群公告和已读 | 无公告模型 | 新增 | 公告主表、版本、发布权限、置顶和成员已读表;变更通过群系统消息通知 |
| 6.10-6.16 | 群设置、资料、头像、昵称、简介、标识、二维码 | Public/Aux/Private 可部分承载 | 需修改 | 定义可索引字段和修改权限;二维码新增签名 Token、有效期、使用次数和撤销 |
| 6.17-6.22 | 管理员、群主转让 | Admin/Owner ACL 已有 | 需修改 | 复用权限位;补管理员上限、二次确认、操作审计和可靠通知 |
| 6.23-6.24 | 成员禁言 | 当前通知静音不等于发言禁用 | 新增 | 新增全员/成员禁言规则,在消息持久化前强制执行,定义管理员豁免 |
| 6.25-6.26 | 选择新群主及确认 | 已有 Owner 转移逻辑 | 需修改 | 增加风险校验、幂等、候选人有效性、二次确认和审计 |
| 6.27-6.28 | 群文件及上传 | 文件消息可用,无群文件目录 | 新增/修改 | 建群文件索引,支持类型/时间/上传者查询和管理员删除,复用安全上传链路 |
### 4.7 第 7 章:文件下载、收藏管理与聊天记录检索
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 7.1-7.8 | 收藏分类、查看和分享 | `slf` Topic 只有基础容器 | 需修改 | 增加收藏索引、类型、去重和源消息引用;分享时重新执行附件和目标会话权限 |
| 7.9-7.11 | 文件下载状态 | 已有 HTTP 下载 | 必须修改 | 下载前检查上传者或关联 Topic Read ACL;使用短时签名 URL;进度/完成状态在客户端 |
| 7.12 | 关键词搜索并定位上下文 | 现有查询只按序号范围 | 新增 | 建消息搜索索引,返回 Topic/SeqId 后重新检查 ACL,并支持上下文拉取 |
| 7.13-7.16 | 文件、链接、图片、视频分类检索 | 无类型索引 | 新增 | 建 `message_type`、`mime_group`、file/link 字段和游标分页,支持历史回填 |
### 4.8 第 8 章:实时语音与视频通话
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 8.1-8.4 | 邀请、等待、接听、语音通话 | P2P WebRTC 信令已有 | 需修改 | 增加独立 `call_id`、呼叫类型、响铃/接听时间、忙线/拒接/超时原因和通话记录 |
| 8.5-8.6 | 麦克风与扬声器状态 | 信令可传临时事件 | 客户端为主 | 本地媒体控制由客户端负责,仅在对端需要展示时同步状态 |
| 8.7-8.10 | 视频等待、摄像头关闭、视频中 | WebRTC Offer/Answer/ICE 已有 | 需修改 | 增加视频类型和摄像头状态事件;配置 TURN,监控 ICE 成功率 |
| 8.11 | 通话悬浮状态 | 无服务端职责 | 客户端为主 | 服务端保持呼叫会话和重连状态,悬浮窗由客户端实现 |
当前实现仅适合一对一通话。新需求没有群音视频章节,因此首期不新增 SFU 或群会议通话。
### 4.9 第 9 章:企业工作台与办公协同
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 9.1 | 工作台入口 | 无业务模块 | 新增 | 新增工作台应用配置和用户可见权限;页面布局由客户端实现 |
| 9.2 | 部门组织架构 | 无 | 新增 | 新建组织单元和成员表,支持树、搜索、主部门、职位、在职状态和数据范围 |
| 9.3-9.4 | 会议通知 | 仅能发送普通消息 | 新增 | 新建会议和参与人模型;创建/变更/取消通过 Outbox 发送系统消息和定时提醒 |
| 9.5-9.8 | 日报及普通/管理员视图 | 无 | 新增 | 建报告表和提交状态机;管理员查询按组织数据权限过滤 |
| 9.9-9.11 | 月报及详情 | 无 | 新增 | 复用报告模型,以类型/周期区分;增加汇总和附件能力 |
| 9.12-9.13 | 总经理信箱 | 无 | 新增 | 建信箱工单、匿名展示、附件、回复和审计;匿名对业务角色隐藏但保留受控追溯 |
工作台 CRUD 建议走 `/v1/im/*` HTTP API,状态提醒走系统 Topic,不把报告和组织树直接塞进聊天消息协议。
### 4.10 第 10 章:个人资料、通用设置、隐私与青少年模式
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 10.1-10.5 | 我的、个人信息、昵称、生日、签名 | Public/Trusted 有通用字段 | 需修改 | 定义资料字段、格式、可见性、敏感词、变更版本和审计;实名信息单独存储 |
| 10.6 | 个人二维码 | 无标准 Token | 新增 | 生成短时签名 Token,扫码后仍执行租户、隐私和账号状态校验 |
| 10.7-10.8 | iOS/Android 设置页 | 无服务端页面逻辑 | 客户端为主 | 服务端提供用户设置和能力开关 |
| 10.9 | 清空缓存 | 不属于服务端数据删除 | 客户端为主 | 只清理设备缓存,不删除云端消息;无需服务端接口 |
| 10.10-10.11 | 版本更新 | 无版本配置服务 | 新增 | 提供平台/渠道最低版本、推荐版本、强更开关、下载地址和文案 API |
| 10.12-10.13 | 隐私设置及添加方式 | 无统一隐私模型 | 新增 | 建隐私设置表,并在搜索、加好友、资料和在线状态接口中强制执行 |
| 10.14-10.17 | 青少年模式 | 无 | 新增 | 服务端保存启用状态、PIN 强哈希、失败锁定和策略版本;所有关键入口强制检查 |
| 10.18 | 推送权限 | Push Token 能力已有 | 需修改/客户端为主 | 系统授权由客户端申请;服务端保存 Token、开关、免打扰和预览策略 |
| 10.19-10.21 | 提醒、相机、麦克风说明 | 无服务端职责 | 客户端为主 | 权限说明和系统授权由客户端实现,可由配置服务下发版本化文案 |
### 4.11 第 11 章:反诈安全、举报与意见反馈
| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 |
| --- | --- | --- | --- | --- |
| 11.1-11.2 | 反诈一次/二次提示 | Plugin 可拦截消息,无产品策略 | 新增/修改 | 建风险事件和策略版本;同步硬规则本地执行,外部审核设置超时/熔断;客户端按等级弹窗 |
| 11.3-11.4 | 举报提交和分类 | 无举报工单 | 新增 | 建举报、证据、处理状态和审计;服务端在有权限时固化消息快照与哈希 |
| 11.5 | 意见反馈 | 无 | 新增 | 建反馈工单、分类、附件、联系方式和处理状态;附件复用私有文件授权 |
## 5. 新需求未覆盖的现有能力:废弃清单
以下能力在新需求文档中没有对应章节。按新产品范围标注为“废弃”,但应采用“停止新接入 → 观察存量 → 迁移 → 删除”的方式处理,不能在未确认线上使用情况时直接删除源码或数据。
| 现有能力 | 源码/配置 | 新需求章节 | 处理结论 | 废弃方式 |
| --- | --- | --- | --- | --- |
| 匿名登录 | `server/auth/anon` | 无 | **废弃** | 新环境不配置、不向客户端公开;确认无匿名会话后移除注册和文档 |
| 邮箱注册/邮箱验证码/邮箱找回 | `server/validate/email` | 无,新需求只定义手机号短信 | **废弃** | 产品 API 不再暴露 email credential;存量账号先绑定手机号再停用 |
| Channel 只读广播 Topic | `TopicCatGrp` 的 channel 模式 | 无 | **废弃** | 禁止新建 Channel;如有存量,迁移为普通群或公告业务后再移除入口 |
| Long Poll 客户端接入 | `/v0/channels/lp` | 无,新移动端按 WebSocket 设计 | **废弃** | 先记录调用量并公告;无流量后关闭路由,WebSocket 保留 |
| 客户端 gRPC 接入 | `grpc_listen`、客户端 gRPC 服务 | 无 | **废弃** | App 不再接入;注意保留插件所需的 gRPC 能力,两者不能一起误删 |
| 示例 Python Chat Bot | `tinode.conf` 中 `python_chat_bot` | 无 | **废弃** | 从生产配置删除;插件框架因第 11 章风控继续保留 |
| 静态网页托管 | `main.go` 静态文件路由/配置 | 无 Web 产品章节 | **废弃** | 新产品服务端不托管 Web UI;确认运维页面用途后关闭 |
| stdout Push Handler | `server/push/stdout` | 无生产需求 | **废弃(生产)** | 仅允许本地测试使用,生产配置删除;FCM/TNPG 等真实 Push 保留 |
| PostgreSQL Adapter | `server/db/postgres` | 无数据库兼容要求 | **废弃(产品版候选)** | 若确认生产统一 MySQL,则停止产品功能适配和验收;删除前核查部署 |
| MongoDB Adapter | `server/db/mongodb` | 无数据库兼容要求 | **废弃(产品版候选)** | 同上,不继续为新增 `im_*` 业务表实现双写逻辑 |
| RethinkDB Adapter | `server/db/rethinkdb` | 无数据库兼容要求 | **废弃(产品版候选)** | 同上;保留上游代码还是物理删除由仓库维护策略决定 |
以下能力虽然没有独立的新需求章节,但属于满足新需求所需的技术底座,不废弃:
| 基础能力 | 保留原因 |
| --- | --- |
| `me` Topic 和设备同步 | 会话列表、未读数、多端状态需要 |
| `fnd` 查找 Topic | 第 5 章精准用户搜索可在其基础上收紧 |
| `sys` Topic | 第 2、9、11 章的可信系统通知需要 |
| `slf` Topic | 第 4、7 章收藏同步可复用 |
| gRPC Plugin/Firehose | 第 11 章内容审核和反诈外部服务需要 |
| FCM/TNPG Push | 第 2、4、8、9、10 章离线提醒需要,生产只选择实际使用的 Handler |
| 文件系统/S3 Media Adapter | 第 3、4、6、7、11 章附件需要,按环境选择 |
| 集群、缓存、监控、TLS | 属于可用性和安全基础,不是产品页面功能 |
| root/后台管理能力 | 实名审核、举报处置、组织管理和审计需要,但必须收紧权限 |
## 6. 需要新增的服务端模块
| 新模块 | 对应新需求章节 | 主要职责 |
| --- | --- | --- |
| `imapi` | 第 1、5、6、7、9、10、11 章 | HTTP API、认证中间件、输入校验、统一错误和游标分页 |
| `impolicy` | 3.7-3.8;5.9-5.14;6.23-6.24;10.12-10.17 | 好友、黑名单、禁言、隐私和青少年同步授权 |
| `immessage` | 2.4;3.6、3.10-3.12;第 4 章 | 消息类型、schema 校验、@、引用、编辑、转发和摘要 |
| `imbiz/relationship` | 第 5 章 | 好友申请、联系人、备注、拉黑和删除状态机 |
| `imbiz/group` | 6.3-6.28 | 群审批、公告、二维码、角色、禁言和群文件 |
| `imbiz/identity` | 1.7-1.10 | 二次验证、实名认证、审核和脱敏 |
| `imbiz/workbench` | 第 9 章 | 组织、会议、日报、月报和信箱 |
| `imbiz/safety` | 第 11 章 | 风险事件、举报证据、处置、反馈和审计 |
| `imsearch` | 7.12-7.16 | 消息索引、历史回填、检索和 ACL 二次过滤 |
| `imnotify` | 2.5;第 5、6、9、11 章 | Outbox 消费、系统消息和 Push 通知 |
| `imjobs` | 1.5;5.2;6.3-6.6;9.3-9.4 | 申请过期、会议提醒、重试、清理和索引回填 |
## 7. 主要数据修改
### 7.1 核心表必须修改
| 对象 | 对应新需求章节 | 修改内容 |
| --- | --- | --- |
| Message | 2.2、2.4;第 3、4、7 章 | 增加或关联客户端幂等 ID、类型、审核状态、搜索索引;重复发送返回同一 SeqId |
| FileDef/File links | 第 3、4、6、7、11 章 | 增加扫描状态、业务用途和访问查询;下载按消息/Topic ACL 授权 |
| User | 1.1-1.10;5.1;9.2;第 10 章 | 共享 SaaS 时加 tenant;基础资料字段规范化,敏感身份数据独立存储 |
| Topic/Subscription | 第 2、3、5、6 章 | 共享 SaaS 时加 tenant;会话个性设置、群策略和关系状态使用独立业务表 |
### 7.2 新增业务表
| 数据域 | 建议表 | 对应新需求章节 |
| --- | --- | --- |
| 好友与会话 | `im_user_relations`、`im_friend_requests`、`im_conversation_settings`、`im_privacy_settings` | 第 2、3、5 章;10.12-10.13 |
| 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_mutes`、`im_group_announcements`、`im_group_announcement_reads`、`im_group_invite_tokens` | 第 6 章 |
| 消息与收藏 | `im_message_idempotency`、`im_message_index`、`im_favorites` | 2.2、2.4;第 4、7 章 |
| 身份与设置 | `im_identity_verifications`、`im_user_settings`、`im_teen_mode` | 第 1、10 章 |
| 企业办公 | `im_org_units`、`im_org_members`、`im_meetings`、`im_meeting_participants`、`im_work_reports`、`im_mailbox_messages` | 第 9 章 |
| 安全治理 | `im_reports`、`im_report_evidence`、`im_feedback`、`im_risk_events`、`im_audit_logs` | 第 11 章 |
| 可靠事件 | `im_outbox`、`im_idempotency_keys` | 第 5、6、9、11 章的审批和通知 |
共享数据库的多企业模式下,核心表和全部 `im_*` 表都必须包含 `tenant_id`,并把租户放入唯一索引、查询条件、缓存键和文件路径。若采用一企一实例/一企一库,则企业码负责服务发现,物理隔离优先。
## 8. 主要代码修改位置
| 文件/目录 | 对应新需求章节 | 修改内容 |
| --- | --- | --- |
| `server/datamodel.go` | 第 2、3、4、7、8 章 | 定义 `x-im-schema`、消息类型、幂等 ID、引用、转发、搜索和通话扩展 |
| `server/session.go` | 第 2、3、4、5、8、10、11 章 | 消息幂等、策略调用、租户上下文和统一业务错误 |
| `server/topic.go` | 第 2、3、4、6、7 章 | 保存前执行好友/黑名单/禁言/青少年/风险规则,发送结构化系统消息 |
| `server/calls.go` | 第 8 章 | call_id、设备抢答、标准结束原因、通话记录和指标 |
| `server/hdl_files.go` | 第 3、4、6、7、11 章 | 抽取 HTTP 认证、资源 ACL、签名下载 URL 和扫描状态检查 |
| `server/plugins.go` | 第 11 章 | 审核超时、熔断、失败开放/关闭策略和可观测性 |
| `server/push.go` | 2.5;4.4;第 8、9、10、11 章 | 产品消息类型、@、会议、审批、通话和隐私 Push |
| `server/main.go` | 第 1、5、6、7、9、10、11 章 | 注册 `/v1/im/*` API、业务服务和后台任务;逐步下线废弃路由 |
| `server/store` | 所有服务端章节 | 新增核心事务、幂等、文件授权、搜索接口;明确产品版支持的数据库 |
| `server/db/mysql` | 所有服务端章节 | 从当前 schema 118 继续迁移核心 tenant_id、业务表、索引和 MySQL 实现 |
| `server/tinode.conf` | 所有章节 | 企业、产品模块、搜索、审核、TURN、文件扫描、限流和废弃能力开关 |
## 9. 修改优先级
### P0:先保证新系统不会产生安全和数据问题
| 工作 | 对应新需求章节 |
| --- | --- |
| 确定一企一实例或共享 SaaS | 1.1、5.1、9.2 |
| 确定产品版只支持 MySQL 还是继续支持多数据库 | 所有服务端章节的存储基础 |
| 消息客户端 ID 幂等 | 2.2;3.4、3.7;第 4 章 |
| 文件下载资源级授权 | 3.10-3.12;4.13-4.20;6.27-6.28;7.3、7.7-7.11 |
| 消息类型、错误码和兼容协议 | 2.4;第 3、4、7、8 章 |
| 停止新客户端使用匿名、Long Poll、Channel 和客户端 gRPC | 新需求无对应章节,废弃项 |
### P1:完成即时通讯产品主链路
| 工作 | 对应新需求章节 |
| --- | --- |
| 企业码、短信注册/找回、二次验证、实名 | 第 1 章 |
| 好友申请、联系人、隐私、黑名单 | 第 5 章;3.1-3.8 |
| 结构化消息、@、引用、转发、收藏 | 2.4;第 3、4 章 |
| 会话设置和可信系统通知 | 第 2 章 |
### P2:完成群、文件和搜索
| 工作 | 对应新需求章节 |
| --- | --- |
| 群申请、公告、管理员、禁言、二维码、群文件 | 第 6 章 |
| 收藏分类和分享 | 7.1-7.8 |
| 关键词和媒体类型搜索、历史回填 | 7.12-7.16 |
### P3:完成通话、设置和安全
| 工作 | 对应新需求章节 |
| --- | --- |
| 通话 ID、跨设备状态、TURN 和详单 | 第 8 章 |
| 资料、版本、隐私和青少年模式 | 第 10 章 |
| 反诈、举报、证据、反馈和后台处置 | 第 11 章 |
### P4:完成企业办公
| 工作 | 对应新需求章节 |
| --- | --- |
| 组织与部门 | 9.2 |
| 会议通知 | 9.3-9.4 |
| 日报/月报 | 9.5-9.11 |
| 总经理信箱 | 9.12-9.13 |
## 10. 废弃实施规则
废弃项不要直接删除,应按以下顺序执行:
1. 在配置和对接文档中标记 deprecated,禁止新客户端接入;
2. 为废弃入口增加调用指标,至少观察一个完整客户端版本周期;
3. 对匿名账号、邮箱账号、Channel 等有数据的能力先做存量扫描;
4. 给存量用户提供迁移方案,例如邮箱账号绑定手机号、Channel 迁移普通群;
5. 新版本服务端默认关闭,保留临时回退开关;
6. 确认无调用和无未迁移数据后,再删除路由、配置、代码和测试;
7. 数据库删除列或表必须单独发布,保留备份和回滚窗口。
客户端 gRPC 和 Plugin gRPC 必须分开处理:新需求没有客户端 gRPC,因此前者废弃;第 11 章需要外部审核插件,因此 Plugin Firehose 及其 gRPC 通道保留。
## 11. 验收口径
每项改造只有同时满足以下条件才算完成:
1. 文档中的新需求章节有对应 API/协议、数据表和权限规则;
2. 旧客户端兼容行为有明确结论,不依赖未定义 JSON 字段;
3. 重试、并发、断网恢复不会产生重复业务数据;
4. 用户不能通过直接构造协议绕过好友、黑名单、禁言、隐私和青少年策略;
5. 文件、搜索、转发和收藏均重新执行当前用户的资源权限;
6. 共享 SaaS 模式下通过跨租户负向测试;
7. 审批、实名、举报、群管理和匿名信箱敏感操作有审计记录;
8. 废弃入口有调用量证据和存量迁移结果,不能仅凭新原型没有页面就直接删除;
9. MySQL schema、升级脚本、回滚脚本和代码中的建表定义保持一致;
10. 服务端单元测试、MySQL 集成测试、WebSocket/HTTP 端到端测试和真实设备通话测试全部通过。
## 12. 最终处理建议
新系统应保留 Tinode 的实时消息内核,重点修改认证流程、消息协议、权限策略、文件授权和通话状态;好友、群治理、搜索、企业工作台、实名、青少年和举报作为新的产品业务层建设。
新需求未覆盖的匿名登录、邮箱注册验证、Channel、Long Poll、客户端 gRPC、示例聊天机器人和静态网页托管标记为废弃。多数据库 Adapter 是否物理删除取决于产品数据库决策;若产品版确定只支持 MySQL,则 PostgreSQL、MongoDB 和 RethinkDB 不再承担新业务适配与验收。
改造顺序应先处理租户、消息幂等和文件授权,再完成好友与基础消息,随后完成群治理、搜索、通话与安全,最后建设企业工作台。这样可以最大限度复用现有实现,同时避免继续维护新产品不需要的入口和功能。
+220
View File
@@ -0,0 +1,220 @@
# IM 监控运维对接文档
本文档用于 IM Server 与 IM Manager 发版、健康检查和 Prometheus 监控对接。
## 1. 接入范围
| 服务 | 端口 | 健康检查 | 指标接口 | 说明 |
|---|---:|---|---|---|
| im-server | 6060 | `GET /healthz` | `GET /metrics` | 对外 IM API 与 WebSocket 服务。 |
| im-manager | 8000 | `GET /healthz` | 暂无 | 后台管理服务。 |
两个接口均不需要业务鉴权,不需要 `X-MeetingIM-APIKey`、`Authorization`、租户 Header。
## 2. 应用配置
本轮监控改造没有新增业务配置项,应用配置文件不需要额外增加开关。
需要运维侧配置的是:
| 配置位置 | 配置内容 |
|---|---|
| Docker / K8s 健康探针 | 调用 `/healthz`,不要再用 `nc -z` 只探测端口。 |
| Prometheus | 抓取 `im-server:6060/metrics`。 |
| 发版脚本 | 蓝绿切换前调用新实例 `/healthz`,返回 200 后再切流。 |
| 网关/Nginx | `/healthz`、`/metrics` 建议只允许内网或监控网段访问。 |
## 3. 健康检查
### 3.1 im-server
请求:
```bash
curl -fsS http://im-server:6060/healthz
```
正常响应:
```json
{
"status": "ok",
"checks": {
"mysql": "ok"
}
}
```
异常响应 HTTP 状态码为 `503`,示例:
```json
{
"status": "error",
"checks": {
"mysql": "dial tcp 127.0.0.1:3306: i/o timeout"
}
}
```
检测内容:
| 依赖 | 检测方式 |
|---|---|
| MySQL | `PingContext`,包含租户注册库连接检查。 |
### 3.2 im-manager
请求:
```bash
curl -fsS http://im-manager:8000/healthz
```
正常响应:
```json
{
"status": "ok",
"checks": {
"mysql": "ok",
"redis": "ok",
"kafka": "ok"
}
}
```
如果 Kafka 未启用,响应为:
```json
{
"status": "ok",
"checks": {
"mysql": "ok",
"redis": "ok",
"kafka": "disabled"
}
}
```
检测内容:
| 依赖 | 检测方式 |
|---|---|
| MySQL | `SELECT 1`。 |
| Redis | `PING`。 |
| Kafka | 临时 Sarama client 拉取 broker metadata,可发现网络、认证、broker 不可用问题。 |
## 4. Docker 健康检查
镜像内已改为请求 `/healthz`:
```dockerfile
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
```
```dockerfile
HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \
CMD wget -q -O /dev/null http://localhost:8000/healthz || exit 1
```
运维如果使用 K8s,建议:
| 探针 | 路径 | 初始延迟 | 周期 | 超时 | 失败次数 |
|---|---|---:|---:|---:|---:|
| im-server readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-server livenessProbe | `/livez` | 15s | 20s | 3s | 3 |
| im-manager readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-manager livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
## 5. Prometheus 指标
目前只在 `im-server` 暴露:
```bash
curl -fsS http://im-server:6060/metrics
```
已支持指标:
| 指标 | 类型 | 说明 |
|---|---|---|
| `ws_connections_active` | Gauge | 当前 WebSocket 连接数。 |
| `http_requests_total{method,path,status}` | Counter | HTTP 请求量,按方法、归一化路径、状态码统计。 |
| `http_request_duration_seconds{method,path,status}` | Histogram | HTTP 请求耗时分布。 |
| `db_query_duration_seconds{driver,operation,status}` | Histogram | MySQL 操作耗时分布。 |
路径中的用户 ID、Topic ID、UUID 等动态片段会归一化为 `{id}`,避免 Prometheus label 过多。
Prometheus 示例配置:
```yaml
scrape_configs:
- job_name: im-server
metrics_path: /metrics
scrape_interval: 15s
scrape_timeout: 5s
static_configs:
- targets:
- im-server:6060
```
## 6. 发版脚本建议
蓝绿部署或滚动发布时,切流前执行:
```bash
curl -fsS --max-time 3 http://127.0.0.1:6060/healthz
```
后台服务:
```bash
curl -fsS --max-time 3 http://127.0.0.1:8000/healthz
```
只有 HTTP 状态码为 `200` 才允许切流。返回 `503` 时应保留旧实例,并输出响应 body 方便定位具体依赖。
## 7. 告警建议
| 告警项 | 建议规则 |
|---|---|
| im-server 健康检查失败 | `/healthz` 连续 3 次非 200。 |
| im-manager 健康检查失败 | `/healthz` 连续 3 次非 200。 |
| WebSocket 连接数异常 | `ws_connections_active` 突降 50% 以上或接近 0。 |
| HTTP 5xx | `rate(http_requests_total{status=~"5.."}[5m]) > 0`。 |
| HTTP 延迟 | P95 超过业务阈值,例如 1s。 |
| DB 延迟 | `db_query_duration_seconds` P95 超过 200ms 或 500ms。 |
## 8. 安全要求
`/healthz` 和 `/metrics` 不带业务鉴权,必须通过网络层控制访问范围。
建议:
1. 只允许内网、K8s 探针、Prometheus、运维跳板机访问。
2. 不要直接暴露到公网。
3. 如必须公网访问,需要网关加 IP 白名单或 Basic Auth。
4. 不在日志中记录 `/metrics` 的完整响应内容。
## 9. 验收命令
im-server:
```bash
curl -i http://im-server:6060/healthz
curl -s http://im-server:6060/metrics | grep -E 'ws_connections_active|http_requests_total|http_request_duration_seconds|db_query_duration_seconds'
```
im-manager:
```bash
curl -i http://im-manager:8000/healthz
```
预期:
1. `/healthz` 正常返回 HTTP 200,`status=ok`。
2. 任一依赖不可用时返回 HTTP 503,并在 `checks` 中标明具体原因。
3. `/metrics` 返回 Prometheus 文本格式。
4. Docker 或 K8s 健康状态不再只依赖端口探测。
@@ -0,0 +1,326 @@
-- IM 第一至七章数据库增量审核稿
-- 仅供审核,不要直接在生产库盲执行。
-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。
-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。
-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。
-- 本文件中的非自增 BIGINT 主键由服务端雪花算法生成。
SET NAMES utf8mb4;
-- hotgo 公共后台库需要先存在;如果你审核通过,可先执行:
-- CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- USE hotgo;
--
-- 租户业务库 tinode 也需要存在并完成 Tinode 基础表初始化:
-- CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- ============================================================
-- 1. 租户、认证、安全基础表
-- ============================================================
CREATE TABLE IF NOT EXISTS im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'tenant business database name, e.g. tinode_acme',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'optional full tenant DB DSN, empty means reuse base MySQL config',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0)
ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3);
CREATE TABLE IF NOT EXISTS im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
action VARCHAR(64) NOT NULL DEFAULT '',
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_security(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_security_tenant_user(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id),
INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at),
INDEX idx_im_realname_id_hash(tenant_id, id_number_hash),
CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id),
INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at),
CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_youth_mode_settings(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_settings_tenant_user_purpose(tenant_id, user_id, purpose),
CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id),
INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC),
CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 2. 好友、黑名单
-- ============================================================
CREATE TABLE IF NOT EXISTS im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friend_requests_request(tenant_id, request_id),
INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at),
CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE UNIQUE INDEX uk_im_friend_requests_idempotency
ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_friendships(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_tenant_pair(tenant_id, user_id, peer_id),
INDEX idx_im_friendships_peer(tenant_id, peer_id, status),
CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_blocks(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_tenant_pair(tenant_id, blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id),
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 3. 收藏、转发、消息增强
-- ============================================================
CREATE TABLE IF NOT EXISTS im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE UNIQUE INDEX uk_im_favorites_idempotency
ON im_favorites(tenant_id, user_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_message_revisions(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY(id),
UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version),
CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 4. Tinode 基础表需补齐的字段/索引
-- 如果你的库是用当前 schema.sql 初始化,这些已经存在。
-- 如果是旧库,需要逐项核对后再 ALTER。
-- ============================================================
-- im_users:租户隔离字段和索引。
-- ALTER TABLE im_users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_users_tenant_id_id ON im_users(tenant_id, id);
-- im_topics:租户隔离、群治理 Aux、租户维度唯一索引。
-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 im_topics.aux JSON。
-- 后续若要强状态机和审计,应拆 im_group_join_requests、im_group_announcements、im_group_mutes 等表。
-- ALTER TABLE im_topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_topics ADD COLUMN aux JSON AFTER tags;
-- CREATE UNIQUE INDEX im_topics_tenant_name ON im_topics(tenant_id, name);
-- CREATE INDEX im_topics_tenant_owner ON im_topics(tenant_id, owner);
-- im_subscriptions:租户字段和按租户查成员索引。
-- ALTER TABLE im_subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_subscriptions_tenant_userid ON im_subscriptions(tenant_id, userid);
-- CREATE INDEX im_subscriptions_tenant_topic ON im_subscriptions(tenant_id, topic);
-- CREATE INDEX im_subscriptions_userid_topic_deletedat ON im_subscriptions(userid, topic, deletedat);
-- im_messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。
-- ALTER TABLE im_messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`;
-- ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id;
-- ALTER TABLE im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version;
-- ALTER TABLE im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at;
-- CREATE INDEX im_messages_tenant_topic ON im_messages(tenant_id, topic);
-- CREATE UNIQUE INDEX messages_sender_client_mid ON im_messages(tenant_id, `from`, client_msg_id);
-- im_dellog:用户侧删除过滤需要 tenant_id。
-- ALTER TABLE im_dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_dellog_tenant_topic ON im_dellog(tenant_id, topic);
-- CREATE INDEX im_dellog_topic_deletedfor_low_hi ON im_dellog(topic, deletedfor, low, hi);
-- im_fileuploads:文件 ACL、租户隔离、ETag。
-- ALTER TABLE im_fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_fileuploads ADD COLUMN etag VARCHAR(128) AFTER size;
-- CREATE INDEX im_fileuploads_tenant_status ON im_fileuploads(tenant_id, status);
-- im_filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。
-- ALTER TABLE im_filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid;
-- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic;
-- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid);
-- im_usertags 已废弃;im_users.phone 是手机号真源,邮箱存放于 im_user_profiles.email。
-- im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id;
-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。
+918
View File
@@ -0,0 +1,918 @@
# IM 系统设计文档
> 版本:v1.1<br>
> 日期:2026-08-11<br>
> 状态:后续开发基线<br>
> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md)<br>
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](obsolete/IM-服务端多租户改造方案.md)
| 版本 | 日期 | 变更 |
| --- | --- | --- |
| v1.0 | 2026-08-11 | 建立总体架构和业务模块设计 |
| v1.1 | 2026-08-11 | 冻结 MySQL 范围;补齐认证、配额、消息变更、好友/群真源、审核、事务、Outbox、搜索和测试设计 |
## 1. 文档目的
本文作为后续 IM 产品开发的系统设计指导文件,用于统一服务端、客户端和测试团队对产品架构、模块边界、数据模型、接口风格、权限规则、消息事件和开发顺序的理解。
本文不替代详细接口文档和数据库 DDL。后续每个业务模块进入开发前,应在本文定义的边界内补充独立的 API 文档、状态机文档、表结构 SQL 和测试用例。
本文是产品业务开发的设计基线。关联分析报告用于记录调研过程;表名、唯一约束、状态机或技术选择与本文冲突时,以本文为准。设计发生变化时,必须先修改本文并记录版本,再修改接口和代码。
## 2. 设计目标
系统面向企业内部沟通与协作场景,核心目标如下:
1. 支持企业码进入租户,所有账号、好友、群、消息、文件、组织和办公数据均按 `tenant_id` 强隔离。
2. 复用现有 Tinode 实时通信内核,保留 WebSocket 消息、Topic、Subscription、回执、文件、Push 和 WebRTC 信令能力。
3. 在实时内核外新增产品业务层,承载好友关系、群治理、收藏、搜索、组织、会议、日报、月报、实名、青少年、举报和反诈等业务。
4. 服务端必须成为权限和状态机的唯一可信执行方,客户端只负责交互展示和本地体验优化。
5. 所有敏感动作具备幂等、防重、审计和可测试的错误码。
6. 支持 iOS、Android 多端一致同步,网络断开后可增量恢复。
### 2.1 首期技术范围
1. 产品版首期只支持 MySQL 8,共享服务、共享数据库,通过 `tenant_id` 隔离租户。
2. PostgreSQL、MongoDB 和 RethinkDB 不在首期产品业务支持范围内。启用产品模块但 Adapter 未实现完整租户业务接口时,服务必须拒绝启动,不能静默退回无租户接口。
3. 实时消息继续使用现有 Tinode WebSocket 协议;事务型产品业务使用 HTTP API。
4. 搜索首期使用 MySQL,达到第 11.3 节定义的切换条件后再引入独立搜索引擎。
## 3. 总体架构
系统采用“实时通信内核 + 产品业务服务 + 异步事件”的分层架构。
```mermaid
flowchart TB
APP["iOS / Android 客户端"]
subgraph Gateway["接入层"]
WS["WebSocket 实时协议"]
API["HTTP /v1/im/* 业务 API"]
FILE["HTTP 文件上传下载"]
end
subgraph Core["通信内核"]
SESSION["Session"]
HUB["Hub / Topic Actor"]
STORE["Tenant Store"]
CALL["WebRTC Call Signaling"]
end
subgraph Product["产品业务层"]
POLICY["策略引擎"]
REL["好友与黑名单"]
GROUP["群治理"]
FAV["收藏与转发"]
SEARCH["搜索索引"]
ORG["组织与工作台"]
SAFE["实名 / 青少年 / 举报 / 反诈"]
end
subgraph Infra["基础设施"]
DB["MySQL"]
CACHE["内存缓存 / 持久化缓存"]
OUTBOX["Outbox 异步事件"]
PUSH["Push / 短信"]
MEDIA["对象存储"]
PLUGIN["审核 / 风控插件"]
end
APP --> WS
APP --> API
APP --> FILE
WS --> SESSION --> HUB --> STORE --> DB
API --> Product --> DB
FILE --> MEDIA
HUB --> CALL
HUB --> POLICY
POLICY --> REL
POLICY --> GROUP
POLICY --> SAFE
Product --> OUTBOX --> PUSH
OUTBOX --> HUB
POLICY --> PLUGIN
```
## 4. 核心分层职责
| 层 | 职责 | 禁止事项 |
| --- | --- | --- |
| 接入层 | 解析 Token、Session、文件认证和租户上下文;做基础参数校验 | 不从客户端自报字段信任内部 `tenant_id` |
| 通信内核 | 维护连接、Topic 路由、消息顺序、广播、回执、Push 触发和通话信令 | 不承载复杂审批、报表、组织树等事务业务 |
| 产品策略层 | 发消息前校验好友、黑名单、群禁言、青少年、文件权限和内容风险 | 不把策略只放客户端 |
| 业务服务层 | 处理好友申请、群审批、公告、收藏、组织、会议、日报、举报等 CRUD 和状态机 | 不把主数据塞进 Topic Public/Aux 任意 JSON |
| 存储层 | 所有业务表带 `tenant_id`,更新删除必须带租户条件 | 不保留无租户业务查询入口 |
| 异步层 | 系统消息、Push、搜索索引、审核任务、定时提醒和审计导出 | 不在 Topic 事件循环中执行耗时远程调用;核心审计记录仍在业务事务内落库 |
## 5. 多租户基础规则
多租户是本系统的底座,后续所有业务模块必须遵守:
1. 首次 `hi.tenant` 使用企业码解析租户,Session 固定 `TenantID`。
2. Token、认证记录、设备、Push、文件和插件调用均携带服务端可信 `TenantID`。
3. 普通业务 Store 只能通过租户作用域访问,禁止全局查用户、Topic、消息、文件。
4. 新增租户业务表必须包含 `tenant_id BIGINT NOT NULL`。平台级表和租户根表 `im_tenant` 例外;例外表必须在 DDL 评审中明确说明作用域。
5. 唯一索引优先使用 `(tenant_id, business_key)`。
6. 子表外键优先使用 `(tenant_id, parent_id)` 复合约束。
7. 跨租户通信首期禁止;搜索、加好友、入群、转发和文件访问均限制在当前租户。
8. 越权访问统一返回 NotFound/Denied,不泄露目标对象是否存在于其他租户。
### 5.1 租户配置与配额
`im_tenant_config` 是租户配额的配置真源,首期使用以下字段:
| 字段 | 含义 | `0` 的含义 |
| --- | --- | --- |
| `max_users` | 租户有效用户数上限 | 不限制 |
| `max_groups` | 租户有效群数上限 | 不限制 |
| `max_group_members` | 单群有效成员数上限 | 不限制 |
配额必须由服务端在事务内强制执行:
1. 注册或启用用户时锁定租户配置记录,统计有效用户后再创建账号。
2. 创建群时锁定租户配置记录,统计有效群后再创建 Topic 和群业务数据。
3. 邀请、扫码入群或审批入群时锁定目标群业务记录,统计有效成员并校验 `max_group_members` 后再写成员关系和 Subscription。
4. 配额检查和资源创建必须在同一事务中完成,禁止先查询、提交后再创建。
5. 超限返回稳定错误码,并记录 `tenant_id`、当前用量、限制值和操作类型;响应不得泄露其他租户信息。
## 6. 通道与接口风格
### 6.1 WebSocket 实时协议
继续使用现有 Tinode 协议承载以下实时能力:
| 能力 | 协议方向 | 说明 |
| --- | --- | --- |
| 登录、注册、Token 登录 | client -> server | 已租户化,继续复用 |
| 发消息 | `pub` | 增加产品策略校验、消息类型和客户端幂等 ID |
| 拉消息、拉元数据 | `get` | 保留原序号查询;复杂搜索走 HTTP |
| 已读、送达、输入中 | `note` | 保留并增加隐私和节流规则 |
| 订阅/退订 Topic | `sub` / `leave` | 由好友、群审批等业务控制是否允许 |
| 通话信令 | `note` 或现有 call 信令 | 增加 `call_id`、通话类型和结束原因 |
| 系统消息 | 服务端发布的 `system` 类型消息 | 个人事件进入服务端管理的系统通知 Topic;群内事件进入对应群 Topic;普通用户不能发布该类型 |
### 6.2 HTTP 业务 API
新增业务 API 使用 `/v1/im/*` 前缀,按模块划分:
```text
/v1/im/friends/*
/v1/im/groups/*
/v1/im/favorites/*
/v1/im/search/*
/v1/im/org/*
/v1/im/meetings/*
/v1/im/reports/*
/v1/im/profile/*
/v1/im/settings/*
/v1/im/security/*
/v1/im/reports-abuse/*
/v1/im/feedback/*
```
HTTP API 通用规则:
1. HTTP 请求使用 `Authorization: Bearer <token>`;统一认证中间件校验 Token 后生成只读 Principal,并从中获取 `tenant_id` 和 `uid`。请求参数中的同名字段不参与授权。
2. 写接口必须支持幂等,使用 `Idempotency-Key` 或业务唯一键。HTTP 幂等键作用域固定为 `tenant_id + uid + method + route + key`;相同键但请求体摘要不同返回冲突错误。
3. 列表接口统一使用游标分页,不使用不稳定的大 offset。
4. 错误响应使用稳定业务错误码,客户端不解析数据库错误。
5. 敏感接口记录审计日志。
### 6.3 文件 API
继续复用现有文件上传下载入口,但必须满足:
1. 文件记录带 `tenant_id`。
2. 上传路径使用 `tenants/{tenant_id}/yyyy/mm/{file_id}`。
3. 下载前校验文件上传者或关联消息/Topic 读取权限。
4. 群文件、收藏文件、举报证据和反馈附件复用同一文件基础设施,但业务用途单独记录。
5. 客户端不可见内部 `location`。
## 7. 核心领域与认证设计
### 7.1 已有通信模型
| 模型 | 继续复用 | 新增约束 |
| --- | --- | --- |
| User | 用户账号和基础资料 | 租户归属、实名状态、隐私设置、青少年状态 |
| Topic | 单聊、群聊、系统 Topic、收藏 Topic | 租户归属、产品业务类型、群治理策略 |
| Subscription | 用户与 Topic 的关系和 ACL | 好友/群状态不能只靠 ACL 表达 |
| Message | 话题内顺序消息 | 消息类型、幂等 ID、审核状态、搜索索引 |
| FileDef | 上传文件元数据 | 租户归属、业务用途、访问授权、扫描状态 |
### 7.2 新增产品模型
| 模块 | 目标表 | 主要职责 |
| --- | --- | --- |
| 好友关系 | `im_friendships`、`im_friend_requests`、`im_user_blocks` | 好友申请、同意、拒绝、删除、拉黑 |
| 用户资料 | `im_users`、`im_user_profiles`、`im_user_privacy_settings` | 注册手机号、昵称、邮箱、生日、签名、可见性和添加方式 |
| 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_announcements`、`im_group_mutes`、`im_group_admin_logs` | 入群审批、公告、禁言、管理员、转让群主 |
| 群文件 | `im_group_files` | 群文件目录、搜索、删除权限 |
| 收藏 | `im_favorites` | 收藏去重、分类、来源、搜索和分享 |
| 搜索 | `im_message_index` 或外部搜索索引 | 关键词、类型、附件、链接、图片、视频检索 |
| 通话 | `im_call_records`、`im_call_participants` | 通话记录、状态、时长、结束原因 |
| 组织 | `im_org_units`、`im_org_members` | 部门树、成员、岗位、数据范围 |
| 工作台 | `im_workbench_apps`、`im_workbench_user_apps` | 企业应用入口和权限 |
| 会议 | `im_meetings`、`im_meeting_participants` | 会议通知、确认状态、提醒和取消 |
| 日报月报 | `im_reports`、`im_report_read_states` | 日报/月报填写、提交、查看和统计 |
| 总经理信箱 | `im_manager_mailbox` | 意见提交、匿名规则、处理状态 |
| 实名认证 | `im_realname_verifications` | 实名信息加密、审核状态、脱敏展示 |
| 青少年模式 | `im_youth_mode_settings` | 启用状态、PIN 哈希、策略版本 |
| 举报反馈 | `im_abuse_reports`、`im_feedbacks`、`im_evidence_files` | 举报、证据、反馈和处理状态 |
| 审计 | `im_audit_logs` | 敏感操作追加日志 |
| 异步事件 | `im_outbox_events` | 系统消息、Push、搜索索引、定时任务 |
新增以下基础模型,避免各业务模块重复实现认证、会话设置和消息变更:
| 模块 | 表 | 主要职责 |
| --- | --- | --- |
| 账号安全 | `im_user_security` | `auth_version`、密码更新时间、登录失败和锁定状态 |
| 短信验证 | `im_verification_codes` | 验证码用途、目标号码哈希、有效期、错误次数和发送频控 |
| 二次验证 | `im_auth_challenges` | 敏感操作挑战、一次性票据哈希、绑定操作和过期时间 |
| 登录设备 | `im_login_sessions` | 设备会话、Token 标识、最近活动和撤销状态 |
| 会话设置 | `im_conversation_settings` | 置顶、免打扰、隐藏和用户侧会话状态 |
| 群成员 | `im_group_members` | 群成员业务状态、角色、入群来源和操作者 |
| 消息版本 | `im_message_revisions` | 编辑前内容、版本、操作者和变更原因 |
MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键。`im_user_security`、`im_kvmeta`、用户资料/隐私、设备 Push Token、好友/黑名单、青少年模式以及群设置/公告已读/禁言等原先使用自然键或复合主键的表,统一改为服务端雪花算法生成的 `BIGINT id` 主键;原业务键保留唯一索引,查询和幂等语义不变。服务启动时会对当前租户库执行幂等迁移,为存量行补齐雪花 ID 后再替换主键。迁移期间不得同时运行不写入 `id` 的旧版本服务。
用户身份字段约定:`im_users.username` 是非空的主登录账号,`im_users.phone` 是手机号方式的注册号码,允许为 `NULL`;是否完成短信验证由已完成的 `tel credential` 判断。用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`,服务端按原始输入校验;手机号方式写入的 E.164 账号和迁移生成的内部保留账号属于系统值例外。注册页面分别展示用户名和手机号,手机号非空时优先走手机号方式并忽略用户名输入:规范化后的 E.164 手机号同时写入 `username` 和 `phone`;只有手机号为空时才使用用户名方式并只写 `username`。短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册并写两个字段,但不建立已完成的 `tel credential`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。
注册邀请码固定为 5 位 ASCII 大小写英文字母,使用密码学安全随机数生成并区分大小写。邀请码明文存入租户业务库,`code` 字段使用 `ascii_bin` 排序规则和唯一索引;注册时按完整明文精确查询,不允许大小写转换或模糊匹配。邀请码查看和导出必须授权并审计,普通日志不得记录邀请码。
### 7.3 企业码与登录流程
1. 未认证客户端先通过 `hi.tenant` 提交企业码;服务端只按 `im_tenant.code` 查找启用租户并把 `TenantID` 固定到 Session。
2. 企业不存在、已停用或不允许登录时返回统一企业不可用错误,不能暴露内部状态和数据库信息。
3. 后续注册、登录、验证码和找回密码全部使用该服务端租户上下文;不接受客户端在业务请求中重新指定租户。
4. HTTP 场景使用 `/v1/im/tenants/resolve` 完成企业码解析,只返回公开品牌信息和服务地址。首期不设计 `tenant_ticket`;注册、登录等未认证请求由服务端根据企业码重新解析租户,认证成功后只信任正式用户 Token。
5. 用户 Token 必须包含可信 `tenant_id`、`uid`、`auth_version`、签发时间和过期时间。Token 租户与当前接入租户不一致时拒绝认证。
### 7.4 验证码、密码重置和二次验证
验证码按 `register`、`login`、`reset_password`、`sensitive_action` 用途隔离,相同验证码不能跨用途使用。数据库只保存验证码强哈希和手机号规范化哈希,明文只在发送时短暂存在。
`im_users.phone` 非空的账号可以通过 `reset_password` 短信挑战自助找回密码;成功验证即证明当前持有该号码,不要求注册时已经存在完成状态的 `tel credential`,并可在重置成功后补建凭证。`phone=NULL` 的用户名账号不提供自助找回,只能由当前租户管理员重置。管理员不得跨租户操作,重置行为必须进入安全审计。
密码重置必须在同一事务中完成:
1. 消费有效验证码或二次验证挑战。
2. 更新密码强哈希和密码更新时间。
3. 递增 `im_user_security.auth_version`。
4. 撤销该用户现有 `im_login_sessions`。
5. 写安全审计和 Outbox 通知。
二次验证成功后签发一次性操作票据。票据必须绑定 `tenant_id + uid + action + target + expires_at`,只保存票据哈希,成功使用后立即失效。青少年模式独立密码不与登录密码共用,哈希保存在 `im_youth_mode_settings`。
### 7.5 认证安全约束
1. 用户名和手机号唯一约束均包含 `tenant_id`。
2. 验证码发送和验证按租户、手机号、IP、设备分别限频。
3. 登录错误使用统一提示;服务端仍按账号、IP 和设备记录失败次数并执行指数退避或临时锁定。
4. 新设备登录、密码修改、会话撤销和异常登录通过 Outbox 发送安全通知。
5. 手机号、验证码、密码、票据、Token 和证件信息不得写入普通日志或消息内容。
## 8. 消息体系设计
### 8.1 消息类型
服务端需要定义稳定的消息类型,不允许各端随意约定 JSON 字段。
产品消息协议 v1 使用现有 `pub`,固定字段放在 `head`:
| 字段 | 规则 |
| --- | --- |
| `x-im-schema` | 产品消息必填,首版固定为 `1` |
| `x-im-type` | 产品消息必填,值必须在下表白名单中 |
| `x-im-client-mid` | 普通客户端消息必填,对应服务端 `client_msg_id` |
| `x-im-reply-to` | 引用消息时填写同 Topic 的 SeqId |
| `x-im-forward-mode` | 转发时为 `single` 或 `merged` |
| `x-im-risk-state` | 仅服务端生成;客户端提交时删除并记录安全日志 |
`content` 只保存对应消息类型的业务内容。每种类型进入开发前必须冻结字段、长度、必填条件和兼容规则;客户端未知字段应忽略,未知消息类型显示安全占位而不能崩溃。
| 类型 | 用途 | 关键字段 |
| --- | --- | --- |
| `text` | 普通文本和 Unicode 表情 | `text`、`mentions` |
| `image` | 图片消息 | `file_id`、`width`、`height`、`thumb_file_id` |
| `voice` | 语音消息 | `file_id`、`duration_ms`、`waveform` |
| `video` | 视频消息 | `file_id`、`duration_ms`、`thumb_file_id` |
| `file` | 普通文件 | `file_id`、`name`、`size`、`mime` |
| `contact_card` | 名片 | `user_id`、`display_name_snapshot`、`avatar_snapshot` |
| `call` | 通话结果消息 | `call_id`、`call_type`、`result`、`duration_sec` |
| `system` | 系统提示 | `event_type`、`actor_id`、`target_ids`、`params` |
| `forward_single` | 单条转发 | `source_message_ref`、`snapshot` |
| `forward_merged` | 合并转发 | `summary`、`items`、`source_refs` |
### 8.2 消息幂等
客户端发消息必须带不超过 64 字符的 `client_msg_id`,推荐使用 UUID/ULID。服务端在当前租户和发送人范围内保证唯一,不把 Topic 放入唯一键,避免客户端误把同一个发送请求投递到多个会话:
```text
UNIQUE(tenant_id, from_uid, client_msg_id)
```
断线重试时,如果服务端发现重复 `client_msg_id`,返回原消息的 Topic、`seq_id`、状态和响应摘要,不能再次插入消息。服务端生成的系统消息也必须生成稳定幂等 ID,并以 Outbox 事件 ID 作为去重来源。
### 8.3 发消息前策略校验
每条消息持久化前执行统一策略链:
1. Session 租户和 Topic 租户一致。
2. 发送人是同租户有效用户。
3. Topic 存在且未删除。
4. Subscription 具备写权限。
5. 单聊检查好友关系、黑名单、删除状态和隐私策略。
6. 群聊检查禁言、全员禁言、群状态和成员身份。
7. 文件消息检查文件归属、扫描状态和目标 Topic 权限。
8. 青少年模式和反诈策略检查。
9. 内容审核通过或进入待审核策略。
### 8.4 消息编辑、引用、撤回与删除
消息在 Topic 中的 `seq_id` 和原发送人不可变。产品消息增加 `message_type`、`client_msg_id`、`version`、`moderation_state`、`recalled_at` 和 `recalled_by` 等明确字段,不能依赖客户端任意 JSON 决定服务端状态。
| 操作 | 服务端规则 | 多端同步 |
| --- | --- | --- |
| 引用 | 保存 `topic + seq_id` 引用和发送时的安全摘要;禁止跨租户引用 | 新消息正常广播;原消息删除后仍显示受控摘要 |
| 编辑 | 仅原发送人、允许类型、规定时限内可编辑;事务内递增 `version` 并写 `im_message_revisions` | 广播带版本号的编辑事件,旧版本事件不得覆盖新版本 |
| 全员撤回 | 校验发送人或群管理员权限及撤回时限;保留受保护审计内容,普通查询只返回撤回占位 | 广播撤回事件,收藏、搜索和引用按保留策略更新 |
| 仅自己删除 | 使用现有用户侧删除日志,不改变其他成员可见性 | 只同步当前用户的其他设备 |
| 批量删除/撤回 | 限制单次数量,逐项授权,返回逐项结果 | 每个成功项产生独立事件和审计记录 |
编辑、撤回与索引更新必须使用乐观版本控制。请求携带期望 `version`,版本不一致返回冲突错误,客户端重新拉取当前消息状态。
### 8.5 内容审核时序
发送链路先执行本地权限、限频和硬规则,再按租户及消息类型选择以下一种审核模式:
| 模式 | 处理方式 | 适用场景 |
| --- | --- | --- |
| `sync_block` | 在短超时内同步审核,通过后持久化和广播;超时按租户配置拒绝或降级 | 高风险文本、小请求 |
| `async_hold` | 以 `pending_review` 持久化但不广播;Outbox 审核通过后发布,拒绝后标记 `rejected` | 文件、图片和高风险内容 |
| `async_post_audit` | 先发布,异步审核;命中后执行系统撤回并审计 | 租户明确允许的低风险内容 |
状态转换固定为:
```text
pending_review -> approved -> published
pending_review -> rejected
published -> recalled_by_review
```
客户端提交的审核状态一律忽略。审核 Worker 必须幂等,同一消息只能完成一次终态转换。
## 9. 好友与黑名单设计
好友关系是单聊产品状态的真源,不直接用 P2P Topic 是否存在表示好友。
### 9.1 好友申请与关系状态
```mermaid
stateDiagram-v2
[*] --> none
none --> requested: 发起申请
requested --> accepted: 通过
requested --> rejected: 拒绝
requested --> expired: 过期
accepted --> friends: 事务创建好友关系
friends --> deleted: 删除好友关系
deleted --> requested: 重新申请
rejected --> requested: 重新申请
```
`im_friend_requests` 记录申请状态,`im_friendships` 记录双方好友关系,`im_user_blocks` 记录单向拉黑。拉黑不修改好友关系状态;取消拉黑只删除当前用户的黑名单记录,不自动恢复已经删除的好友关系。
### 9.2 服务端规则
1. 申请、同意、拒绝、删除、拉黑必须幂等。
2. 好友申请保存验证留言、来源、过期时间和处理人。
3. 拉黑是单向行为,唯一约束为 `(tenant_id, blocker_id, blocked_id)`;被拉黑人发消息、通话、名片、查看资料时按策略阻断。
4. 删除好友后历史消息默认保留,但双方不能继续发送好友消息。
5. 好友资料字段由关系和隐私设置共同决定。
6. 接受申请必须在一个事务内更新申请、创建好友关系、创建或激活 P2P Topic/Subscription,并写入 Outbox。
7. 单聊策略按“双方账号有效 -> 好友关系有效 -> 任一方向黑名单 -> 隐私/青少年/风控”的顺序判定。
## 10. 群治理设计
现有 Group Topic 继续作为群消息容器。`im_group_settings` 与 `im_group_members` 是群业务状态真源;Topic、Subscription 和 ACL 是通信内核投影。所有成员和角色变化必须通过产品业务接口完成,并在同一 MySQL 事务内同步业务真源与通信投影。
### 10.1 群角色
| 角色 | 业务真源 | 通信投影 | 权限 |
| --- | --- | --- | --- |
| 群主 | `im_group_settings.owner_user_id` + `im_group_members.role=owner` | Topic Owner + Owner ACL | 全部群管理、转让、解散 |
| 管理员 | `im_group_members.role=admin` | Admin ACL | 审批、公告、禁言、成员管理 |
| 普通成员 | `im_group_members.role=member` | 有效 Subscription | 收发消息、查看公告和群文件 |
| 游客/待审批 | `im_group_join_requests` | 无 Subscription | 不具备群消息读写权限 |
禁止客户端直接修改群 Topic Owner 或管理员 ACL。服务启动或定时一致性任务发现业务真源与通信投影不一致时,记录告警并以业务真源修复投影。
### 10.2 群能力
| 能力 | 设计 |
| --- | --- |
| 创建群 | 同一事务创建 Topic、群设置、群主成员记录、Subscription、ACL、审计和 Outbox |
| 入群邀请 | 写邀请/申请记录,审批通过后在同一事务写成员记录和 Subscription |
| 入群验证 | 群设置控制是否需要审批 |
| 群公告 | 公告表保存版本,群内发送系统消息 |
| 群二维码 | 签名短 Token,包含群 ID、租户、过期时间和用途 |
| 管理员 | 更新 `im_group_members.role`,同一事务同步 ACL |
| 禁言 | 全员禁言和成员禁言都在发消息前强制检查 |
| 群文件 | 文件消息成功后进入群文件索引,按群权限查询 |
| 转让群主 | 二次确认;同一事务更新 owner、双方角色、Topic Owner/ACL、审计和 Outbox |
| 解散群聊 | 标记群状态、踢出成员、停用二维码和审批入口 |
### 10.3 群成员列表读取与缓存
群成员列表按需分页读取,不作为进入会话的必拉数据。普通聊天页只依赖群详情、当前用户权限和 `member_count`;成员页、候选人选择器和 `@` 搜索才调用成员列表接口。
服务端成员列表查询接口为 `GET /v1/im/groups/{topic}/members`。参数含义:`query` 用于搜索成员,空值表示读取当前筛选条件下的成员页;`cursor` 必须来自上一页 `next_cursor`;`limit` 默认 `30`,最大 `100`;`mentionable=true` 用于 `@` 选择器,只返回可被提及成员;`role=owner|admin|member` 用于按现有角色筛选;`eligible_role=admin|owner` 用于添加管理员和转让群主候选视图;`mute_eligible=true` 用于禁言候选视图。候选视图的 `eligible/ineligible_reason` 在 HTTP 响应阶段按当前登录人的权限和目标成员状态计算。
成员页按 `im_group_members.id` 游标分页,常规列表走 `(topic, member_status, id)` 索引,角色筛选走 `(topic, role, member_status, id)` 索引,搜索和候选列表必须复用分页查询,不得提供一次返回全群成员的接口。万人群场景中,客户端打开群聊不拉成员列表;打开成员页只拉第一页;搜索、`@`、管理员候选、禁言候选和转让群主候选都带 `query` 缩小结果集。
成员页缓存使用 Redis,缓存维度为 `topic + role + eligible_role + query + cursor + limit + mentionable`,缓存内容为成员页结果和 `next_cursor`。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人补齐。成员加入、退出、角色变更、群昵称变更、成员状态变化、资料投影刷新等写操作必须调用 `invalidateGroupMemberPages(topic)` 清理该群所有成员页缓存。
## 11. 收藏、转发与搜索设计
### 11.1 收藏
收藏使用独立 `im_favorites` 表作为查询和去重真源,不仅依赖 `slf` Topic。
收藏记录需要保存:
1. 租户、收藏人、来源 Topic、来源 SeqId、来源消息 ID。
2. 消息类型、摘要、文件 ID、发送人、发送时间。
3. 收藏时间、去重键和删除状态。
4. 原消息删除后的可见策略。
### 11.2 转发
逐条转发和合并转发都必须重新执行目标会话权限和文件权限。
| 转发类型 | 规则 |
| --- | --- |
| 逐条转发 | 每条源消息生成一条新消息;保持顺序;每条使用新 `client_msg_id` |
| 合并转发 | 生成不可变快照;限制条数、总大小和敏感字段;支持点击查看详情 |
| 文件转发 | 源文件和目标 Topic 必须同租户;必要时创建新的引用关系 |
### 11.3 搜索
搜索分三类:
1. 用户和群搜索:使用租户内规范化 Tags,并执行隐私策略。
2. 聊天记录搜索:`im_message_index` 保存 `tenant_id`、Topic、SeqId、消息类型、发送人、时间、规范化检索文本和资源元数据,返回前二次校验 ACL。
3. 文件/图片/视频/链接搜索:依赖消息类型索引和文件元数据索引。
首期固定使用 MySQL 8:结构化条件使用 B-Tree 复合索引,消息正文使用独立 `im_message_index.search_text` 和适合中文的 FULLTEXT/ngram 索引。分页游标使用稳定的 `created_at + message_id`,不能使用大 Offset。
搜索索引不是授权来源。查询必须先限定 `tenant_id` 和当前用户可读 Topic 集合,返回前再次校验 Topic Read ACL、成员状态、用户侧删除、全员撤回和审核状态。退群、拉黑或权限变更后必须立即阻断查询,即使异步索引尚未删除。
出现以下任一情况时重新评估 OpenSearch/Elasticsearch:单租户可检索消息达到千万级、MySQL 搜索 P95 超过 500ms、需要复杂中文相关性/高亮,或索引写入明显影响消息主库。切换时保持 HTTP 搜索契约不变。
## 12. 通话设计
现有 P2P WebRTC 信令继续复用,新增业务通话记录。
### 12.1 通话状态
```mermaid
stateDiagram-v2
[*] --> ringing: 发起呼叫
ringing --> accepted: 接听
ringing --> canceled: 发起方取消
ringing --> rejected: 接收方拒绝
ringing --> timeout: 超时
ringing --> busy: 对方忙线
accepted --> ended: 正常挂断
accepted --> interrupted: 异常断开
```
### 12.2 关键字段
`im_call_records` 至少包含:
```text
tenant_id, call_id, topic, caller_id, callee_id, call_type,
state, start_at, accepted_at, ended_at, duration_sec,
end_reason, created_at, updated_at
```
通话结束后生成 `call` 类型消息,双方看到的文案由统一结果码生成。
## 13. 企业工作台设计
工作台是独立业务域,不能依赖聊天 Topic 保存主数据。
| 模块 | 入口 | 通知方式 |
| --- | --- | --- |
| 组织架构 | `/v1/im/org/*` | 组织变更一般不发聊天消息,必要时发系统通知 |
| 会议通知 | `/v1/im/meetings/*` | 创建、变更、取消和提醒通过系统消息 + Push |
| 日报/月报 | `/v1/im/reports/*` | 未提交提醒、提交通知和管理员提醒通过 Outbox |
| 总经理信箱 | `/v1/im/manager-mailbox/*` | 提交成功给本人通知;处理结果按匿名规则通知 |
| 工作台应用 | `/v1/im/workbench/apps` | 返回租户配置和用户权限范围内的入口 |
组织数据权限用于日报、月报、会议统计和通讯录查询。管理员只能查看权限范围内成员数据。
## 14. 个人资料、隐私与青少年模式
### 14.1 个人资料
个人资料以 `im_user_profiles` 为唯一业务真源;User Public 与 `im_subscriptions.public` 仅作为 MeetingIM 协议展示投影/缓存,由服务端在资料变更后同步刷新,业务接口不得把这些投影当作可写真源。
| 字段 | 存储建议 |
| --- | --- |
| 头像、昵称、签名 | `im_user_profiles`;User Public / Subscription Public 仅保留服务端生成的展示投影 |
| 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 |
| 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 |
| 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 |
| 实名姓名、证件号 | 实名表加密存储,不进入 Public |
| 群昵称 | `im_group_members`,按需投影到 Subscription Private |
### 14.2 隐私设置
隐私设置影响以下入口:
1. 账号/手机号/二维码/群聊添加好友。
2. 个人主页字段展示。
3. 在线状态和最后在线时间展示。
4. 非好友消息和通话发起。
客户端可以隐藏入口,但最终阻断必须在服务端执行。
### 14.3 青少年模式
青少年模式状态保存在服务端,PIN 使用强哈希,不保存明文。
受限能力由租户策略决定,首期建议覆盖:
1. 添加好友。
2. 加群和创建群。
3. 文件传输。
4. 音视频通话。
5. 工作台外部链接。
### 14.4 会话与通知设置
`im_conversation_settings` 以 `(tenant_id, user_id, topic)` 唯一,保存置顶、免打扰、隐藏等用户侧设置。它不改变其他成员的会话状态,也不能替代 Topic ACL。
Push 发送前同时检查租户通知策略、用户全局通知设置、会话免打扰、青少年模式和系统权限登记状态。客户端系统通知权限只用于展示引导,服务端保存的是用户偏好和最近一次上报状态,不把客户端上报当作可靠授权依据。
## 15. 安全、实名、举报与反诈
### 15.1 实名认证
实名认证采用独立状态机:
```text
none -> pending -> approved
none -> pending -> rejected
approved -> appeal_pending -> approved/rejected
```
证件号、姓名等敏感字段加密存储,普通查询只返回脱敏状态。
### 15.2 反诈策略
反诈由“本地规则 + 外部风控插件”共同组成:
1. 本地规则用于稳定阻断和提示,例如陌生人、敏感词、频繁加好友、异常文件。
2. 外部插件用于内容审核、风险评分和策略建议。
3. 插件调用遵守第 8.5 节的三种审核模式、超时和降级规则,不能在 Topic Actor 中执行无界远程等待。
4. 风险提示和二次确认结果写审计。
### 15.3 举报与反馈
举报和反馈都按工单处理。
| 类型 | 特点 |
| --- | --- |
| 举报 | 绑定被举报对象、会话、消息、原因、证据和处理状态 |
| 意见反馈 | 绑定提交人、设备、版本、问题描述、附件和处理状态 |
举报证据应固化消息快照和文件引用,防止原消息删除后无法处理。
## 16. 系统消息与通知设计
需要产生异步副作用的业务状态变化,在同一数据库事务中写入 Outbox,由 Worker 生成系统消息、Push、索引或定时任务。没有异步副作用的更新不要求创建空事件。
### 16.1 系统消息格式
系统消息固定使用以下结构;业务模块只能登记 `event_type` 和参数 Schema,不能自行定义另一套消息信封:
```json
{
"type": "system",
"event_type": "group_join_approved",
"template_version": 1,
"actor_id": "usr...",
"target_ids": ["usr..."],
"params": {
"group_id": "grp...",
"group_name": "项目群"
},
"dedupe_key": "tenant:1:group:grpxxx:join:request123"
}
```
### 16.2 事件类型
首期至少定义:
| 业务 | 事件 |
| --- | --- |
| 账号安全 | 新设备登录、密码修改、实名提醒 |
| 好友 | 新申请、通过、拒绝、删除 |
| 群 | 邀请、申请、审批、公告、禁言、管理员变更、群主转让、解散 |
| 文件 | 群文件上传、文件失效 |
| 通话 | 未接、拒绝、取消、忙线、通话结束 |
| 工作台 | 会议通知、会议变更、日报提醒、月报提醒、信箱处理 |
| 安全 | 反诈提示、举报受理、反馈受理 |
## 17. 权限模型
### 17.1 角色层级
| 层级 | 角色 |
| --- | --- |
| 平台 | `platform_admin` |
| 租户 | `tenant_owner`、`tenant_admin` |
| 组织 | 部门负责人、报表管理员 |
| 群 | 群主、管理员、成员 |
| 关系 | 好友、非好友、黑名单 |
| 账号 | 普通用户、实名用户、青少年模式用户 |
### 17.2 权限判定顺序
```text
租户状态 -> 用户状态 -> 业务角色 -> Topic ACL -> 产品策略 -> 风控策略
```
任何一步失败,写操作不得继续。
## 18. 数据一致性与异步任务
### 18.1 事务边界
必须在同一事务中完成:
1. 好友申请处理和关系状态更新。
2. 群审批通过和 Subscription 写入。
3. 群主转让和权限更新。
4. 消息保存和附件引用建立。
5. 文件记录从 `uploaded` 切换为 `ready`、附件引用和业务索引写入。
6. 举报提交和证据引用。
业务层不能在 Handler 中连续调用多个独立 Store 方法来模拟事务。`imstore` 必须提供按用例划分的事务方法,例如 `AcceptFriendRequest`、`ApproveGroupJoin`、`TransferGroupOwner` 和 `SaveMessageWithAttachments`,方法内部同时更新产品业务表、Tinode 核心表、审计记录和 Outbox。
对象存储不参与 MySQL 事务,文件采用以下状态机:
```text
pending -> uploaded -> ready
pending/uploaded -> failed
uploaded(超时且无引用) -> orphaned -> deleted
```
对象上传成功后,再在 MySQL 事务中把文件切换为 `ready` 并建立业务引用。若数据库事务失败,文件保持 `uploaded`,由带宽受控的租户级垃圾回收任务清理;消息只能引用 `ready` 文件。
### 18.2 Outbox
需要通知、Push、索引或审计的业务写操作,在事务内写入 `im_outbox_events`。异步 Worker 负责:
1. 发送系统消息。
2. 推送离线通知。
3. 更新搜索索引。
4. 调用外部审核/反诈。
5. 发送会议、日报、月报定时提醒。
6. 记录可观测指标。
`im_outbox_events` 至少包含:
```text
id, tenant_id, aggregate_type, aggregate_id, event_type,
payload, dedupe_key, state, available_at, attempts,
locked_by, locked_at, last_error, created_at, processed_at
```
Outbox 处理规则:
1. `dedupe_key` 在租户内唯一,生产事件和消费副作用都必须幂等。
2. Worker 使用 `SELECT ... FOR UPDATE SKIP LOCKED` 分批领取事件,设置最大批量和租户公平调度,避免单个租户占满队列。
3. 失败采用有上限的指数退避;超过上限进入 `dead_letter`,触发告警并支持人工重放。
4. 系统消息通过集群安全的内部发布接口进入目标 Topic,不能直接假设 Outbox Worker 与目标 Topic 位于同一进程。
5. 标记事件成功前必须确认目标副作用已获得稳定幂等结果;进程崩溃后的重复执行不得产生重复消息或 Push。
6. 外部审核事件必须按第 8.5 节推进消息审核状态,不能绕过状态机直接广播。
## 19. API 与错误码规范
### 19.1 成功响应
```json
{
"code": 0,
"data": {},
"request_id": "..."
}
```
### 19.2 错误响应
```json
{
"code": 30001,
"message": "permission denied",
"reason": "GROUP_MUTED",
"request_id": "..."
}
```
错误码按模块分段:
| 范围 | 模块 |
| --- | --- |
| 10000-19999 | 通用、认证、租户 |
| 20000-29999 | 好友、黑名单、隐私 |
| 30000-39999 | 群、公告、群文件 |
| 40000-49999 | 消息、收藏、搜索、转发 |
| 50000-59999 | 文件、媒体、通话 |
| 60000-69999 | 组织、会议、日报、月报 |
| 70000-79999 | 实名、青少年、安全、举报、反馈 |
HTTP 状态码表达协议层结果,业务 `code` 表达稳定失败原因:参数错误使用 400,未认证使用 401,无权限使用 403,不可见资源使用 404,版本或幂等冲突使用 409,限频使用 429,服务端故障使用 5xx。客户端不得只判断 HTTP 状态而忽略业务 `code`。
## 20. 可观测性与审计
### 20.1 日志字段
认证后的请求日志至少包含:
```text
request_id, tenant_id, uid, sid, topic, action, result, latency_ms
```
手机号、证件号、Token、验证码、文件签名和设备 Push Token 不写明文日志。
### 20.2 审计事件
必须审计:
1. 租户、组织和管理员变更。
2. 登录、密码修改、实名审核和二次验证。
3. 好友删除、拉黑、黑名单解除。
4. 群主转让、管理员变更、禁言、解散群。
5. 文件下载、批量转发、收藏导出。
6. 举报处理、反馈处理和反诈二次确认。
7. 平台管理员代操作。
## 21. 开发阶段规划
### 阶段 0:基础底座收口
目标:让后续业务都站在正确地基上。
交付:
1. 多租户核心链路完成并通过双租户隔离测试。
2. 消息 `client_msg_id` 幂等。
3. 文件下载资源级授权。
4. 系统消息结构和 Outbox 基础。
5. 统一错误码和请求日志。
6. 产品版明确只支持 MySQL 8,不支持的 Adapter 在启动时快速失败。
7. `im_tenant_config` 三项配额在注册、建群和入群入口强制生效。
8. 建立专用业务事务 Store,禁止跨多个 Store 调用拼接关键事务。
### 阶段 1:账号、资料、好友和黑名单
交付:
1. 注册、登录、找回密码、二次验证。
2. `auth_version`、登录设备、Token 撤销和安全通知。
3. 个人资料、会话设置和隐私设置。
4. 好友申请、通讯录、主页、删除好友。
5. 独立黑名单和单聊发送/通话策略。
### 阶段 2:群聊产品化
交付:
1. 群列表、创建群、群资料、群头像。
2. 入群审批、邀请、群二维码。
3. 群公告、管理员、群主转让。
4. 全员禁言、成员禁言、群文件。
5. 群业务真源与 Topic/Subscription/ACL 投影一致性检查。
### 阶段 3:消息增强、收藏、搜索和转发
交付:
1. 结构化消息类型。
2. @成员、引用、编辑、撤回、批量删除及消息版本事件。
3. 收藏分类和分享。
4. 聊天记录、文件、图片、视频和链接搜索。
5. 逐条转发和合并转发。
6. `sync_block`、`async_hold` 和 `async_post_audit` 审核状态机。
### 阶段 4:通话和通知体验
交付:
1. `call_id` 和通话记录。
2. 通话状态消息。
3. 忙线、拒绝、未接、超时和异常断开。
4. Push 文案、免打扰、锁屏隐私。
### 阶段 5:企业工作台
交付:
1. 工作台应用配置。
2. 组织架构。
3. 会议通知和参会确认。
4. 日报、月报。
5. 总经理信箱。
### 阶段 6:安全合规
交付:
1. 实名认证。
2. 青少年模式。
3. 反诈策略。
4. 举报和意见反馈。
5. 审计后台和处置流程。
## 22. 测试策略
### 22.1 自动化测试
每个模块至少包含:
1. 单元测试:状态机、权限判断、错误码。
2. Store 测试:租户隔离、唯一约束、分页、软删除。
3. API 测试:参数校验、幂等、越权、并发。
4. WebSocket 测试:消息顺序、回执、离线重连、系统消息。
5. 安全测试:跨租户、跨用户、跨群、文件越权和枚举。
6. 事务测试:在每个数据库写入点注入失败,验证业务真源、通信投影、审计和 Outbox 不出现部分提交。
7. Worker 测试:重复消费、锁超时、进程崩溃、指数退避、死信和人工重放。
8. Schema 测试:MySQL 初始化 DDL、升级 DDL 和 Adapter SQL 保持一致,唯一约束与外键真实生效。
9. 性能测试:大群广播、并发发消息、重连风暴、文件上传下载、中文搜索和 Outbox 积压恢复。
性能测试不能只记录平均值。每次发布前根据部署规模给出 P95/P99 延迟、错误率、吞吐、数据库连接和队列积压报告;未确定正式 SLO 前,以当前稳定版本基线为回归门槛。
### 22.2 重点验收场景
| 场景 | 预期 |
| --- | --- |
| 两个租户使用相同手机号注册 | 均成功,互不影响 |
| A 租户尝试搜索 B 租户用户 | 返回空或通用 NotFound |
| 被拉黑用户发送消息 | 不进入对方会话,返回稳定失败原因 |
| 禁言成员发群消息 | 服务端拒绝,其他成员不可见 |
| 文件 URL 被其他用户重放 | 无权限返回 403/404 |
| 断线重试同一消息 | 不重复入库,返回原消息状态 |
| 同一发送人把相同 `client_msg_id` 投递到不同 Topic | 第二次返回首次投递结果,不写入另一 Topic |
| 并发注册超过 `max_users` | 成功数不超过配额,失败请求返回同一超限错误 |
| 并发审批入群超过 `max_group_members` | 最终有效成员数不超过配额 |
| 群主转让并发提交 | 最终只有一个成功,审计完整 |
| 群主转让在任一写入点失败 | owner、成员角色和 Topic ACL 全部回滚 |
| `async_hold` 消息等待审核 | 审核前其他成员不可见,通过后只广播一次 |
| Outbox 发送成功但标记前进程崩溃 | 重启重放不产生重复系统消息或 Push |
| 用户退群后搜索旧群消息 | 即使索引尚在也不返回结果 |
| 举报消息后原消息被撤回 | 举报证据仍可处理 |
## 23. 开发约束
1. 新业务表一律以 `im_` 前缀命名。
2. 所有租户业务表必须带 `tenant_id`;平台级表和 `im_tenant` 等根表例外,例外必须经过 DDL 评审。
3. 新接口不使用无租户 Store。
4. 不在 Topic Actor 中执行慢查询、外部 HTTP 或复杂报表统计。
5. 不把敏感字段放入 User Public、Topic Public、普通消息 JSON 或日志。
6. 新增消息类型必须先定义 schema,再实现客户端渲染。
7. 所有危险操作必须有二次确认、幂等和审计。
8. 所有批量操作必须限制数量并返回逐项结果。
9. 所有列表必须分页。
10. 所有外部服务调用必须有超时、重试上限和降级策略。
11. 关键业务状态变化必须通过专用事务 Store 完成,不允许 Handler 直接修改 Topic ACL 绕过业务真源。
12. 产品模块只允许在 `TenantBusinessReady()` 为真且数据库版本满足要求时启动。
## 24. 待产品确认事项
以下问题会影响接口和表结构,开发前需要产品或业务方确认:
对应阶段冻结接口和 DDL 前必须确认本节事项。在产品未给出结论时,可以按本文其他章节声明的默认行为开发内部能力,但不得将未确认交互作为正式验收口径。
1. 删除好友后是否同时删除双方会话,还是仅禁止继续发送。
2. 拉黑后是否允许被拉黑人看到历史消息和个人资料。
3. 群聊已读回执是否展示具体已读成员,是否只对管理员开放。
4. 消息撤回时限和管理员撤回范围。
5. 合并转发是否允许包含已撤回、已删除或涉敏消息。
6. 群文件是否等同于群内所有文件消息,还是独立上传目录。
7. 实名认证是否强制,未实名能否继续登录、聊天、加好友、入群。
8. 青少年模式限制范围和默认策略。
9. 总经理信箱是否匿名,以及哪些角色可以查看真实提交人。
10. 日报/月报是否支持草稿、补交、撤回和修改。
## 25. 总结
后续开发应以现有 Tinode 服务端作为实时通信内核,在其外建设清晰的产品业务层。核心原则是:聊天实时链路保持轻,业务状态机走 HTTP 和数据库事务,状态变化通过系统消息和 Push 回到聊天体验中。
多租户、消息幂等、文件授权、系统消息和审计是所有功能之前的基础工程。好友、群治理、收藏搜索、通话、工作台和安全合规应按阶段推进,每个阶段都必须同时完成数据模型、接口、权限、通知和测试闭环。
@@ -0,0 +1,555 @@
# IM 群消息按月分表改造方案
## 1. 背景与目标
当前每个租户库使用单表 `im_group_messages` 保存全部群消息。随着群数量、消息量和保留时间增长,单表会出现索引膨胀、查询抖动、DDL 风险升高和归档困难等问题。
本方案将群消息正文按自然月拆分为物理表:
```text
im_group_messages_202609
im_group_messages_202610
im_group_messages_202611
```
目标:
1. 每个租户库独立分表,消息表不增加 `tenant_id`。
2. Topic 的 `seqid` 继续由 Redis 原子递增,跨月不重置。
3. Kafka 异步落库稳定路由到首次确定的月表。
4. 客户端继续按 `topic + seq` 拉取,不感知月份。
5. 支持跨月分页、补拉、指定消息、搜索、撤回、编辑和删除。
6. 旧单表平滑迁移,不停服、不丢失、不重复。
7. Redis 保留 24 小时热消息,MySQL 月表仍是最终数据源。
## 2. 核心原则
### 2.1 分表粒度
- 一个租户库每月一张群消息表,不按单个群建表。
- 表名固定为 `im_group_messages_YYYYMM`。
- 月份统一使用 UTC,但最终写入月份由 Redis 全局 `active_month` 决定,避免多节点在月初产生交叠。
- Redis Lua 一次性返回 `seqid + shard_month`;生产端把结果固化到 Kafka,重试、补偿不得重算。
### 2.2 Seq 规则
- `seqid` 在 Topic 内全生命周期单调递增,跨月不归零。
- Redis `topic:last_seq` 是唯一发号源。
- 月表只保存已分配的 Seq,不通过 `MAX(seqid)` 发号。
- 客户端游标继续使用 `before/since/seq`。
### 2.3 分层职责
| 层级 | 职责 |
| --- | --- |
| Redis | 原子 Seq、全局活动月份、长期 Topic 月段路由、短期发送幂等、24 小时热消息、在线状态、实时推送 |
| Kafka | 异步落库、削峰、重试、死信、消费幂等 |
| MySQL 月表 | 群消息正文最终存储 |
| MySQL 路由镜像 | 月表目录、Topic 月段路由持久化镜像;Redis 丢失时用于重建,不进入正常查询热路径 |
| `im_dellog` | 用户视角删除范围,每张月表查询时继续过滤 |
## 3. 表结构
### 3.1 月表模板
第一阶段保持当前字段兼容,降低 Store 层改造范围。
```sql
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
message_id BIGINT NOT NULL COMMENT 'Snowflake 全局消息 ID',
topic VARCHAR(64) NOT NULL COMMENT '群 Topic',
group_id BIGINT NOT NULL COMMENT '业务群 ID',
seqid BIGINT NOT NULL COMMENT 'Topic 内递增,跨月不重置',
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NULL,
msg_type VARCHAR(32) NOT NULL DEFAULT 'text',
status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON NULL,
head JSON NULL,
mentioned_user_ids JSON NULL,
mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT NULL,
root_seqid BIGINT NULL,
file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0,
has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass',
version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3) NULL,
recalled_by BIGINT NULL,
deleted_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (message_id),
UNIQUE KEY uk_topic_seq (topic, seqid),
UNIQUE KEY uk_topic_sender_client_mid (topic, from_user, client_msg_id),
KEY idx_group_seq (group_id, seqid),
KEY idx_topic_created (topic, created_at),
KEY idx_topic_from_seq (topic, from_user, seqid),
KEY idx_audit_created (audit_status, created_at),
KEY idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
说明:
1. `message_id` 使用 Snowflake 全局唯一 ID,作为月表物理主键和跨表引用 ID。
2. 聊天协议定位、补拉和已读水位仍统一使用 `topic + seqid`,不向客户端暴露物理主键。
3. 单表唯一索引不能覆盖跨月重试,跨月幂等由 Redis、消费日志和幂等表共同保证。
4. 文件二进制仍在 OSS,月表仅保存消息和文件元数据。
### 3.2 月表目录
```sql
CREATE TABLE IF NOT EXISTS im_group_message_shards (
shard_month CHAR(6) NOT NULL COMMENT 'UTC 月份 YYYYMM',
table_name VARCHAR(64) NOT NULL,
shard_start DATETIME(3) NOT NULL,
shard_end DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'prepared'
COMMENT 'prepared/writable/sealing/sealed/archived',
row_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NULL,
max_created_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (shard_month),
UNIQUE KEY uk_table_name (table_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
表名只允许由服务端根据月份生成,并且必须存在于目录表,禁止使用请求参数直接拼接 SQL。
### 3.3 Redis Topic 月段路由
在线查询使用 Redis 路由,路由 Key 长期不过期:
```text
# 当前可写月份
im:{tenant_id}:groupmsg:active_month
STRING = 202610
# Topic 月份段,score 为该月首条 seq
im:{tenant_id}:groupmsg:route:{topic}
ZSET: 202609 => 1
202610 => 10087
# 月段完整范围,field 为 YYYYMM
im:{tenant_id}:groupmsg:route_meta:{topic}
HASH: 202609 => min_seq=1,max_seq=10086,count=10086
202610 => min_seq=10087,max_seq=12000,count=1914
```
路由规则:
1. `route` 和 `route_meta` 不设置 TTL,只在群物理删除或对应消息保留期结束后清理。
2. 精确 Seq 定位使用 `ZREVRANGEBYSCORE route seq -inf LIMIT 0 1`,找到最大 `min_seq <= seq` 的月份,再校验 Hash 中的 `max_seq`。
3. 倒序分页通过 `ZREVRANGE` 按月段倒序读取,正向补拉通过 `ZRANGE` 读取。
4. 路由 Key 使用 Redis Cluster hash tag,保证 Lua 和 Pipeline 访问同一槽位。
5. Redis 开启 AOF everysec、主从复制和持久化备份;同时保留 MySQL 镜像作为灾备重建来源。
活动月份和 Seq 必须在同一个 Lua 脚本中读取/分配,两个 Key 使用相同的租户 hash tag:
```lua
local shard_month = redis.call('GET', KEYS[1])
if not shard_month or shard_month < ARGV[1] then
redis.call('SET', KEYS[1], ARGV[1])
shard_month = ARGV[1]
end
local seq_id = redis.call('INCR', KEYS[2])
return {seq_id, shard_month}
```
其中 `KEYS[1]` 是租户活动月份,`KEYS[2]` 是 Topic Seq,`ARGV[1]` 来自 Redis `TIME` 转换后的 UTC 月份。业务节点不得根据本机时间计算月份;首次写入可初始化活动月份,之后 Lua 只允许月份单向推进,旧节点不能回拨。
### 3.4 MySQL 路由镜像
```sql
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
topic VARCHAR(64) NOT NULL,
shard_month CHAR(6) NOT NULL,
min_seq BIGINT NOT NULL,
max_seq BIGINT NOT NULL,
message_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NOT NULL,
max_created_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic, shard_month),
KEY idx_topic_max_seq (topic, max_seq),
KEY idx_month_topic (shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
该表不参与正常消息查询,只用于 Kafka 事务持久化、Redis 路由重建、历史迁移、数据核对和归档。Redis 与镜像不一致时,以月表实际数据和 MySQL 镜像修复 Redis。
路由一致性规则:
1. Kafka 消费事务同时提交消息和 MySQL 路由镜像,保证最终数据与镜像一致。
2. MySQL 提交成功后再更新 Redis `route/route_meta`;Redis 更新失败不回滚消息,进入路由修复重试队列。
3. 查询发现 Redis 路由 Key 不存在时,从 MySQL 镜像加载该 Topic 全部有效月段并回填 Redis,然后重试查询。
4. Shard Manager 启动时可按租户增量重建路由,并定时抽样核对 Redis、MySQL 镜像和月表实际 Seq 范围。
5. Redis 路由不设置 TTL;只有群被物理删除,或消息保留策略确认清除对应月份后,才同步删除相应月段。
### 3.5 跨月幂等表
```sql
CREATE TABLE IF NOT EXISTS im_group_message_dedup (
topic VARCHAR(64) NOT NULL,
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL,
shard_month CHAR(6) NOT NULL,
message_id BIGINT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic, from_user, client_msg_id),
KEY idx_expires (expires_at),
KEY idx_topic_seq (topic, seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
- Redis `SET NX` 是入口快速幂等。
- Kafka 消费日志按 `event_id` 防止同一事件重复消费。
- 该表防止月末发送、月初重试时同一 `client_msg_id` 落入两张表。
- 建议保留 7 至 30 天后清理,不作为永久消息索引。
## 4. 建表和月切换
每个租户库运行统一 Shard Manager:
1. 每天巡检上月、当月和未来两个月。
2. 缺表时根据固定模板创建,成功后登记目录。
3. 使用 Redis 分布式锁和 Redis `TIME` 判断月界,确认新月表可写后再原子更新 `active_month`。
4. 当月设置为 `writable`,所有节点通过 Redis 读取同一个活动月份。
5. 上月进入 `sealing`,建议继续接受 7 天延迟 Kafka 消息。
6. 确认没有积压和死信后改为 `sealed`。
7. 建表失败立即告警,不切换 `active_month`,禁止在业务写入事务中临时执行 DDL。
月切换示例:
```text
2026-09-30 23:59:59 UTC seq=10086 -> im_group_messages_202609
2026-10-01 00:00:00 UTC seq=10087 -> im_group_messages_202610
```
Kafka 事件必须固定路由信息:
```json
{
"topic": "grp_xxx",
"seq_id": 10087,
"created_at": "2026-10-01T00:00:00.123Z",
"shard_month": "202610",
"client_msg_id": "client_mid_xxx"
}
```
## 5. 群消息落库
### 5.1 生产端
1. 校验群成员、禁言、群状态和内容策略。
2. 使用 Redis 幂等 Key 抢占 `client_msg_id`。
3. 使用 Redis Lua 原子获取 Topic 下一个 `seqid` 和全局 `active_month`。
4. 生成服务端 UTC `created_at`,并把 Lua 返回的月份作为 `shard_month`。
5. 把 `shard_month` 写入 Kafka;所有重试沿用原值。
6. Kafka 生产成功后按现有策略向客户端返回 Seq。
7. 写入 Redis 热消息并执行实时推送。
### 5.2 Kafka 消费端
消费端按 `tenant + shard_month` 分组批量写入:
1. 校验月份格式和允许范围。
2. 从目录确认目标表已存在。
3. 开启事务。
4. 插入或确认 `im_group_message_dedup`。
5. 插入目标月表。
6. 在同一事务中 UPSERT MySQL 路由镜像的最小/最大 Seq、时间和消息数。
7. 更新消费幂等状态并提交事务。
8. 提交成功后更新 Redis `route/route_meta`,并把 Redis 幂等状态改为 `done`。
路由 UPSERT:
```sql
INSERT INTO im_group_message_topic_shards
(topic, shard_month, min_seq, max_seq, message_count,
min_created_at, max_created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE
min_seq = LEAST(min_seq, VALUES(min_seq)),
max_seq = GREATEST(max_seq, VALUES(max_seq)),
message_count = message_count + 1,
min_created_at = LEAST(min_created_at, VALUES(min_created_at)),
max_created_at = GREATEST(max_created_at, VALUES(max_created_at));
```
### 5.3 异常处理
- 目标月表不存在:消费失败、进入重试并高优告警。
- `sealing` 月表:允许携带原 `shard_month` 的延迟事件写入。
- `sealed` 月表:进入补偿队列,人工确认后临时开放或补写。
- 重复 `event_id`:消费日志直接判定成功。
- 重复 `client_msg_id`:返回原 `seqid/shard_month`,不新增消息。
- 超过最大重试次数:进入死信队列,不能静默丢弃。
- Redis 热缓存失败不回滚 MySQL,客户端按 Seq 回源补拉。
- Redis 路由更新失败:进入独立修复重试;查询侧可从 MySQL 镜像重建,不影响已提交消息。
## 6. 消息查询
### 6.1 总流程
```text
客户端 Topic Seq 请求
-> ACL/成员身份校验
-> Redis 24 小时热消息
-> Redis 是否完整覆盖请求范围
-> Redis Topic 月段路由
-> 月表逐张查询
-> 按 seqid 合并、去重、排序、截断
-> 应用 im_dellog 用户视角过滤
-> 返回客户端
```
Redis 只有完整覆盖整页时才能直接返回;有缺口必须回源 MySQL。
### 6.2 最新消息倒序分页
请求 `topic=grp_xxx&before=10088&limit=50`:
1. 从 Redis Topic 月段路由找出 `min_seq < before` 的月份,按月倒序。
2. 先查最新候选月:
```sql
SELECT ...
FROM im_group_messages_202610 AS m
LEFT JOIN im_dellog AS d
ON d.topic = m.topic
AND m.seqid BETWEEN d.low AND d.hi - 1
AND d.deletedfor = ?
WHERE m.topic = ?
AND m.seqid < ?
AND m.status != 'deleted'
AND d.deletedfor IS NULL
ORDER BY m.seqid DESC
LIMIT ?;
```
3. 数量不足时继续查前一个月。
4. 服务层按 `seqid DESC` 合并并截取 `limit`。
5. 下一页游标仍是本页最小 `seqid`,客户端不感知月份。
禁止对所有历史表执行无上限 `UNION ALL`。
### 6.3 Since 正向补拉
1. 从 Redis Topic 月段路由查找 `max_seq > since_seq` 的月份。
2. 按月升序执行 `seqid > since_seq ORDER BY seqid ASC LIMIT N`。
3. 合并后按 Seq 升序返回。
4. 达到上限后返回最后一条 Seq,客户端继续请求。
### 6.4 指定消息
撤回、回复预览、举报取证按 `topic + seqid` 定位:
```text
ZREVRANGEBYSCORE im:{tenant_id}:groupmsg:route:{topic} {seqid} -inf LIMIT 0 1
HGET im:{tenant_id}:groupmsg:route_meta:{topic} {shard_month}
```
先找到最大 `min_seq <= seqid` 的月份,再校验该月段 `max_seq >= seqid`,随后执行 `WHERE topic=? AND seqid=? LIMIT 1`。Redis 路由 Key 缺失时读取 MySQL 镜像并回填 Redis;镜像也缺失时,仅降级扫描当前月和最近若干月并记录修复任务,不允许扫描全部历史表。
### 6.5 上下文和范围
查询目标消息前后各 20 条时:
1. 定位目标所在月。
2. 在本月分别查询目标前后消息。
3. 任一方向不足时继续查询相邻月份。
4. 合并、去重后按 Seq 正序返回。
### 6.6 搜索和统计
- 短期搜索必须带起止时间,最多覆盖 3 个月,逐月查询后归并。
- 中长期通过 Kafka 建独立搜索索引;结果只返回 `topic + seqid`,服务端回源月表并重新校验 ACL。
- Admin 统计使用按日/月汇总表,不实时扫描所有月表执行 `COUNT(*)`。
## 7. 撤回、编辑和删除
撤回、编辑流程:
1. 根据 `topic + seqid` 定位目标月表。
2. 在目标月表事务中 `SELECT ... FOR UPDATE`。
3. 校验版本、权限和状态。
4. 更新目标月表并写修订记录、业务事件。
5. 提交后更新或删除 Redis 热消息。
修订记录建议增加月份:
```sql
ALTER TABLE im_message_revisions
ADD COLUMN shard_month CHAR(6) NULL AFTER topic;
```
用户侧删除继续使用 `im_dellog` 的 Topic Seq 范围,每张月表查询都应用相同过滤。全局删除或合规处置先定位月表,再更新 `status/deleted_at/content/head`;OSS 附件仍走文件生命周期任务。
## 8. Store 层设计
所有动态表访问统一经过路由组件:
```go
type GroupMessageShardRouter interface {
WriteTable(createdAt time.Time, shardMonth string) (string, error)
CandidateTablesBySeq(topic string, seq int64) ([]string, error)
TablesBefore(topic string, before int64) ([]string, error)
TablesSince(topic string, since int64) ([]string, error)
}
```
改造范围:
- `TenantGroupMessageSave`:按事件 `shard_month` 写入。
- `TenantGroupMessageGetOne`:路由后查询目标月。
- `TenantGroupMessageGetAll`:逐月查询并归并。
- `TenantGroupMessageGetByClientID`:先查 Redis/幂等表,再查询目标月。
- `TenantGroupMessageRecall/Edit`:定位目标月后执行事务。
- `TenantMessageSearch`:按时间选月,或转独立搜索。
- Admin 内部消息查询、举报、收藏、回复和群文件引用同步改造。
动态表名必须满足 `^im_group_messages_[0-9]{6}$` 且月份已登记。SQL 参数不能绑定表名,必须严格白名单。
## 9. 旧表迁移
原 `im_group_messages` 先保留,不删除。
推荐步骤:
1. 上线路由表、幂等表和 Shard Manager。
2. 预建历史月份、当月和未来两个月表。
3. 先发布支持“旧表 + 月表”双读的代码,写入仍走旧表。
4. 记录切换水位 `cutover_seq/cutover_time`。
5. 切换新消息只写月表,Kafka 开始携带 `shard_month`。
6. 按 `created_at` 分批把旧表数据迁入对应月表。
7. 每批使用 `INSERT IGNORE`,校验 `topic + seqid`、数量和内容哈希。
8. 回填 MySQL Topic 月段路由镜像,并由重建任务写入 Redis 路由。
9. 双读期间按 `topic + seqid` 去重。
10. 全量校验通过后停止读取旧表。
11. 旧表改名 `im_group_messages_legacy`,只读保留。
12. 观察至少一个完整保留周期后再单独审批清理。
不建议长期双写旧表和月表,否则两个写目标部分成功会扩大一致性问题。
迁移批次要求:
- 使用主键或 `created_at + id` 游标,禁止 OFFSET 深分页。
- 单批 1,000 至 5,000 条,根据线上延迟调整。
- 每批独立事务,记录开始/结束游标和成功/失败数。
- 支持暂停、续跑和重复执行。
## 10. 归档与保留
状态流转:
```text
prepared -> writable -> sealing -> sealed -> archived
```
- `writable`:当月正常写。
- `sealing`:月末后接受延迟消息,默认 7 天。
- `sealed`:在线 MySQL 只读。
- `archived`:转对象存储或低成本库,在线查询走归档服务。
归档前确认 Kafka 无积压/死信、路由完整、消息数量和 Seq 范围一致,并确认举报、审计、收藏和群文件引用仍可定位。
## 11. 监控与告警
| 指标 | 说明 |
| --- | --- |
| `group_message_persist_total{month,status}` | 各月落库成功/失败 |
| `group_message_persist_latency` | Kafka 到 MySQL 提交耗时 |
| `group_message_shard_missing_total` | 目标月表不存在 |
| `group_message_query_tables` | 单次请求扫描表数 |
| `group_message_query_latency{source}` | Redis、单月、跨月耗时 |
| `group_message_dedup_hit_total` | 幂等命中 |
| `group_message_route_miss_total` | Redis 路由缺失 |
| `group_message_route_rebuild_total{status}` | 从 MySQL 镜像重建 Redis 路由 |
| `group_message_route_repair_backlog` | Redis 路由修复积压 |
| `group_message_dead_letter_total` | 落库死信 |
未来月表未创建、当月不可写、`active_month` 缺失、Kafka 延迟超限、单次扫描超过 3 张表、Redis 路由缺失或修复积压增长时必须告警。
## 12. 验收测试
1. 月末和月初消息落入正确表,Seq 连续。
2. Kafka 次月才消费时仍写事件指定月份。
3. 同一 `client_msg_id` 跨月重试只保留一条。
4. 最新 50 条跨两个月时数量、顺序、游标正确。
5. `since_seq` 跨月补拉无遗漏、无重复。
6. `topic + seq` 可定位历史消息。
7. 回复上下文可跨月读取。
8. 撤回、编辑、举报、收藏、群文件引用可定位旧月。
9. 用户删除范围跨月时过滤正确。
10. Redis 热消息不完整时正确回源。
11. 月表缺失时进入重试/死信并告警。
12. 迁移双读无重复,迁移任务可暂停、续跑。
13. 万人群高并发时 Seq 唯一且落库稳定。
14. 非法月份和非法动态表名被拒绝。
15. 多节点跨月并发时 Lua 返回同一活动月份,Seq 不重号且月段不交叠。
16. 删除 Redis 路由后可从 MySQL 镜像自动重建,查询结果不缺失。
## 13. 实施阶段
### 第一阶段:基础设施
- 建月表模板、目录、MySQL 路由镜像和跨月幂等表。
- 实现 Shard Manager,预建未来两个月。
- 建立长期不过期的 Redis Topic 月段路由和原子 `seqid + active_month` Lua。
- Kafka 群消息事件增加 `shard_month`。
### 第二阶段:写入切换
- 消费者按月落库。
- 完成幂等、重试、死信、MySQL 镜像和 Redis 路由更新。
- Redis 热消息与实时推送保持不变。
### 第三阶段:查询切换
- 完成精确查询、倒序分页、Since 补拉和上下文跨月查询。
- 改造撤回、编辑、删除、举报及 Admin 内部查询。
- 增加跨月查询监控。
### 第四阶段:历史迁移
- 上线旧表/月表双读。
- 回填历史消息与 MySQL 路由镜像,重建 Redis Topic 路由。
- 校验后把旧表改为只读保留。
### 第五阶段:搜索和归档
- 消息搜索切换独立索引或限制时间范围。
- 建立 `sealed/archived` 生命周期和历史恢复机制。
## 14. 结论
群消息按月分表不能只改写入表名,必须一起实现固定 `shard_month`、Redis Topic 月段路由、MySQL 路由镜像、跨月幂等、逐月游标查询、历史消息定位和旧表迁移。
最终链路:
```text
发送:Redis Lua 原子分配 Seq 和 active_month -> Kafka 固定 shard_month -> MySQL 月表
实时:Redis Pub/Sub/Stream -> WebSocket
热读:Redis 24 小时消息
冷读:Redis Topic 月段路由 -> MySQL 月表逐月查询
容灾:MySQL 路由镜像 -> 重建长期 Redis 路由
补偿:客户端按 Topic Seq 拉取
```
客户端协议无需增加月份字段,月表路由完全由服务端处理。
+248
View File
@@ -0,0 +1,248 @@
-- IM group business tables design.
-- Scope: chapter 6 group chat business truth tables.
-- Database: the currently selected tenant database.
-- Important: one tenant owns one database, so these tables intentionally do not contain tenant_id.
-- This SQL is for review first. Do not execute directly in production before confirming indexes and migration order.
-- Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator.
-- 1. Group profile and governance truth.
-- Topic/Subscription remains the communication projection; this table is the product business truth.
CREATE TABLE IF NOT EXISTS im_group_settings (
id BIGINT NOT NULL,
topic VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
description VARCHAR(512) NOT NULL DEFAULT '',
group_status VARCHAR(16) NOT NULL DEFAULT 'active',
join_approval TINYINT NOT NULL DEFAULT 0,
mute_all TINYINT NOT NULL DEFAULT 0,
member_upload_files TINYINT NOT NULL DEFAULT 1,
member_private_chat TINYINT NOT NULL DEFAULT 1,
member_invite TINYINT NOT NULL DEFAULT 1,
max_member_count INT NOT NULL DEFAULT 0 COMMENT '0 means use tenant default or unlimited',
max_admin_count INT NOT NULL DEFAULT 10,
member_count INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_by BIGINT NOT NULL,
dissolved_by BIGINT,
dissolved_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_settings_topic (topic),
INDEX idx_im_group_settings_owner (owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated (group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 2. Group members and role truth.
-- Role changes must update this table and the Tinode Subscription ACL in the same transaction.
CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member',
member_status VARCHAR(16) NOT NULL DEFAULT 'active',
nickname VARCHAR(64) NOT NULL DEFAULT '',
pinned TINYINT NOT NULL DEFAULT 0,
mute_notifications TINYINT NOT NULL DEFAULT 0,
join_source VARCHAR(32) NOT NULL DEFAULT 'manual',
joined_by BIGINT,
joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
left_at DATETIME(3),
removed_by BIGINT,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_members_topic_user (topic, user_id),
INDEX idx_im_group_members_user_status (user_id, member_status, updated_at),
INDEX idx_im_group_members_topic_role (topic, role, member_status, id),
INDEX idx_im_group_members_topic_status (topic, member_status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 3. Direct invitations.
-- If join_approval is off, accepting an invitation can directly create im_group_members + Subscription.
-- If join_approval is on, accepting may create a pending im_group_join_requests record.
CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT,
invitation_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL,
target_user_id BIGINT NOT NULL,
message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending',
result VARCHAR(32) NOT NULL DEFAULT '',
join_request_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_invitations_invitation (invitation_id),
UNIQUE INDEX uk_im_group_invitations_idempotency (inviter_user_id, idempotency_key),
INDEX idx_im_group_invitations_target (target_user_id, status, expires_at),
INDEX idx_im_group_invitations_topic_status (topic, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 4. Join requests.
-- Pending users must not have a readable Subscription before approval.
CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
applicant_user_id BIGINT NOT NULL,
inviter_user_id BIGINT,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
qr_token_hash CHAR(64) NOT NULL DEFAULT '',
message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending',
reject_reason VARCHAR(256) NOT NULL DEFAULT '',
handled_by BIGINT,
handled_at DATETIME(3),
expires_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_join_requests_request (request_id),
UNIQUE INDEX uk_im_group_join_requests_idempotency (applicant_user_id, idempotency_key),
INDEX idx_im_group_join_requests_topic_status (topic, status, id),
INDEX idx_im_group_join_requests_applicant (applicant_user_id, topic, status, id),
INDEX idx_im_group_join_requests_expire (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 5. Group announcements.
CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT,
announcement_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
author_user_id BIGINT NOT NULL,
content TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'current',
version INT NOT NULL DEFAULT 1,
published_at DATETIME(3) NOT NULL,
deleted_by BIGINT,
deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_announcements_announcement (announcement_id),
UNIQUE INDEX uk_im_group_announcements_idempotency (topic, author_user_id, idempotency_key),
INDEX idx_im_group_announcements_topic_status (topic, status, version DESC, id DESC),
INDEX idx_im_group_announcements_topic_time (topic, published_at DESC, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 6. Announcement read receipts.
CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
id BIGINT NOT NULL,
announcement_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
read_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_announcement_reads_announcement_user (announcement_id, user_id),
INDEX idx_im_group_announcement_reads_topic_user (topic, user_id, read_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 7. Current member mute state.
-- Historical changes are stored in im_group_admin_logs.
CREATE TABLE IF NOT EXISTS im_group_mutes (
id BIGINT NOT NULL,
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
muted_by BIGINT NOT NULL,
muted_at DATETIME(3) NOT NULL,
expires_at DATETIME(3),
reason VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
revoked_by BIGINT,
revoked_at DATETIME(3),
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_mutes_topic_user (topic, user_id),
INDEX idx_im_group_mutes_topic_status (topic, status, expires_at),
INDEX idx_im_group_mutes_user_status (user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 8. Group QR codes / invite tokens.
-- qr_token should remain opaque to clients. Store only token hash.
CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT,
token_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
created_by BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL,
revoked_by BIGINT,
revoked_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_qrcodes_token (token_id),
UNIQUE INDEX uk_im_group_qrcodes_hash (token_hash),
INDEX idx_im_group_qrcodes_topic_status (topic, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 9. Group file directory.
-- File binary metadata still lives in the existing file table/storage. This is the group-visible index.
CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT,
group_file_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL,
source_topic VARCHAR(32) NOT NULL DEFAULT '',
source_seq INT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file',
file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
deleted_by BIGINT,
deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_files_group_file (group_file_id),
UNIQUE INDEX uk_im_group_files_topic_file (topic, file_id),
UNIQUE INDEX uk_im_group_files_idempotency (topic, uploader_id, idempotency_key),
INDEX idx_im_group_files_topic_type (topic, status, file_type, id DESC),
INDEX idx_im_group_files_topic_name (topic, status, file_name),
INDEX idx_im_group_files_uploader (uploader_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 10. Group operation audit logs.
-- Store role changes, governance changes, mutes, announcements, owner transfer, dissolve, file delete, etc.
CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
log_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
operator_id BIGINT NOT NULL,
target_user_id BIGINT,
action VARCHAR(64) NOT NULL,
object_type VARCHAR(32) NOT NULL DEFAULT '',
object_id VARCHAR(64) NOT NULL DEFAULT '',
before_data JSON,
after_data JSON,
request_id VARCHAR(128) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_admin_logs_log (log_id),
INDEX idx_im_group_admin_logs_topic_time (topic, id DESC),
INDEX idx_im_group_admin_logs_operator (operator_id, id DESC),
INDEX idx_im_group_admin_logs_object (object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 11. Group operation idempotency is handled by Redis-backed HTTP idempotency.
DROP TABLE IF EXISTS im_group_idempotency;
+122
View File
@@ -0,0 +1,122 @@
# IM「联系我们」移动端接口对接文档
> 版本:v1.0
> 日期:2026-09-21
> 状态:**已实现**,可按本文直接联调
> 适用端:iOS / Android / Web(im_webapp)
## 1. 功能说明
移动端「设置 / 关于 / 支持」页面的**联系我们**入口,展示管理员配置的客服邮箱。
数据链路:
```text
管理后台「系统管理 → 字典管理 → 联系我们」
→ IM 管理端数据库(字典类型 im_contact_us,条目即 label/value)
→ im-server 缓存(内存 TTL 1 分钟兜底 + 管理端变更事件主动失效)
→ 移动端 GET /v1/im/contact-us
```
要点:
- 条目内容由运营在管理后台维护,客户端不需要硬编码任何联系方式。
- 管理后台修改后**近实时生效**(事件失效,秒级);事件通道不可用时最长 1 分钟内收敛(缓存 TTL 兜底)。
- 条目数量不固定,客户端必须按列表渲染,不能假设只有一个条目。
- 接口返回为空列表时,客户端应**隐藏联系我们入口**,不要渲染空区块。
## 2. 接口定义
### 2.1 获取联系我们配置
```text
GET {api_path}v1/im/contact-us
```
**请求头**(公共约定同《IM-前后端对接文档.md》§0.2):
| Header | 必填 | 说明 |
| --- | --- | --- |
| `X-MeetingIM-APIKey` | 是 | 应用级接入标识 |
| `X-IM-Schema` | 是 | 固定 `1` |
| `X-Request-ID` | 是 | 请求追踪 ID |
| `Authorization` | 是 | `Bearer <token>`,登录态必填 |
**请求参数**:无
**成功响应**(HTTP 200):
```json
{
"code": 0,
"data": {
"items": [
{ "label": "客服邮箱", "value": "support@example.com" }
]
},
"request_id": "req-contactus-2",
"server_time": "2026-09-21T04:58:19.521Z"
}
```
`data` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items` | `array` | 联系方式条目列表,按管理后台配置的排序返回;可能为空数组 `[]` |
| `items[].label` | `string` | 条目名称,如「客服邮箱」,客户端原样展示 |
| `items[].value` | `string` | 条目值,邮箱地址 |
**失败响应**:
| HTTP | code | reason | 场景 |
| --- | --- | --- | --- |
| 403 | 30003 | `API_KEY_REQUIRED` | API Key 缺失或无效 |
| 401 | 30043 | `AUTH_REQUIRED` | 未登录 / token 过期,引导重新登录 |
| 405 | 30002 | `METHOD_NOT_ALLOWED` | 非 GET 请求 |
| 500 | 90000 | `INTERNAL_ERROR` | 服务端内部错误,可重试 |
## 3. 客户端渲染规则
按条目值的形态渲染(客户端本地判断,与服务端无关):
| value 形态 | 判断 | 交互 |
| --- | --- | --- |
| 邮箱 | 包含 `@` 且形如 `x@y.z` | `mailto:{value}`,图标用 `email` |
| 其他 | 不满足邮箱格式 | 纯文本展示,不可点击 |
补充约定:
- 每个条目的展示文案使用 `label`,不要自行翻译或改写。
- 接口失败(网络/500)时静默降级:若客户端保留了旧版本静态联系方式可继续展示,否则隐藏入口,不弹 toast。
- 不需要本地缓存该接口结果;进入页面时请求即可,服务端已有缓存兜底。
## 4. 本地联调示例
```bash
curl -s "http://{host}/{api_path}v1/im/contact-us" \
-H "X-MeetingIM-APIKey: <api_key>" \
-H "X-IM-Schema: 1" \
-H "X-Request-ID: req-contactus-2" \
-H "Authorization: Bearer <token>"
```
实测返回:
```json
{"code":0,"data":{"items":[{"label":"客服邮箱","value":"support@example.com"}]},"request_id":"req-contactus-2","server_time":"2026-09-21T04:58:19.521Z"}
```
## 5. 配置维护(运营侧)
- 入口:管理后台「系统管理 → 字典管理」→ 字典类型 `联系我们`(type=`im_contact_us`)。
- 新增/修改/删除条目即配置生效,`状态=停用` 的条目不会返回。
- 测试环境初始化 SQL:`im_manager/docs/im/IM-联系我们字典配置.sql`(im_manager 仓库)。
- 首次部署后如接口返回空列表,先确认管理后台该字典类型下存在 `状态=启用` 的条目。
## 6. 参考实现
Web 端(im_webapp)已完成对接,可直接参考:
- API 封装:`src/lib/im-api.js` 中 `getContactUs()`
- 页面渲染:`src/views/acc-support-view.jsx`(邮箱 `mailto:`、空列表回退静态链接)
+438
View File
@@ -0,0 +1,438 @@
# IM 产品需求实现情况审核报告
> 审核日期:2026-08-17
> 审核对象:当前仓库代码、数据库脚本及运行配置
> 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md`
> 多租户依据:`IM-独立库多租户改造方案.md`
> 明确排除:已废弃的 `IM-服务端多租户改造方案.md`
> 数据库更新:2026-08-18 已完成无独立 `id` 表的雪花主键改造和本地库迁移。
## 1. 审核说明
本报告以代码和数据库脚本的实际状态为准,不直接沿用前后端对接文档中的“现状/目标”状态。对接文档中的状态是设计阶段快照,部分“新增”接口目前已经实现,部分已注册接口仍缺少业务闭环。
判定口径:
| 状态 | 含义 |
| --- | --- |
| 已实现 | 主流程、存储和必要权限校验已落地,可进入联调 |
| 部分实现 | 已有接口或数据模型,但缺少关键策略、通知、推送、事务或异常闭环 |
| 阻断缺陷 | 代码已存在,但当前实现会导致主流程失败、权限绕过或数据不一致 |
| 未实现 | 未找到对应路由、业务逻辑或持久化实现 |
本次为代码审核并辅以 Go 单元测试和真实 MySQL 适配器集成测试,不包含多节点 WebSocket、移动端、短信通道和真实 FCM 凭据的端到端联调。
## 2. 总体结论
当前版本已经不是初始 Tinode 的简单 topic 模型。好友关系、群业务扩展表、认证安全、收藏搜索、工作台、会议、日报月报、反馈举报等模块均已出现较完整的 HTTP 路由和存储接口,产品功能骨架已经形成。
首轮审核发现的 P0 和本报告第 4 节的 6 项 P1 已于 2026-08-17 完成代码修复。项目仍不应按“全部完成”交付,以下部署和 P2 闭环仍需处理:
1. FCM 已接通产品 Token 表,但真实环境凭据和移动端联调仍待部署完成。
2. 真实短信、多节点 WebSocket、通话竞态和后台处置工作流仍未闭环。
3. 青少年模式、文件安全、群文件和会话设备管理等 P2 能力仍待开发。
### 2.1 分章完成度
| 章节 | 模块 | 当前判断 | 摘要 |
| --- | --- | --- | --- |
| 0 | 公共协议与基础约定 | 已实现主流程 | 统一响应、公共头、消息协议、Bearer 认证和全局幂等已落地 |
| 1 | 租户、认证与安全 | 部分实现 | 租户解析、验证码、密码重置、实名提交、安全挑战已实现;真实短信、会话管理等缺失 |
| 2 | 首页、会话与消息基础 | 部分实现 | 会话、回执、撤回、编辑、能力开关、实时事件和系统通知已落地;稳定游标等仍待增强 |
| 3 | 单聊与好友关系 | 部分实现 | 好友、黑名单、隐私策略、关系事件和通知已落地;历史文件/通话组合规则仍待验收 |
| 4 | 消息能力 | 部分实现 | @、媒体、转发、收藏及协议类型兼容已有;@通知和贴纸等缺失 |
| 5 | 联系人与关系管理 | 部分实现 | 基础功能、全局幂等和实时事件已落地;审计和二维码风控仍需增强 |
| 6 | 群聊 | P0 已修复,仍部分实现 | 初始化、邀请、申请、禁言、事务和 Outbox 已修复;群文件、解散等仍缺失 |
| 7 | 收藏、搜索与文件 | 部分实现 | 收藏、上下文、媒体搜索、文件 ACL/Range/ETag 已有;群文件、安全扫描等缺失 |
| 8 | 音视频通话 | 部分实现 | Tinode 通话事件和记录已存在;离线呼叫、跨设备竞态、群通话等未闭环 |
| 9 | 工作台与办公协同 | 部分实现 | 工作台、公开租户编码、组织查询、会议通知与报表已实现;权限仍需增强 |
| 10 | 设置与设备 | 部分实现 | 资料、隐私及主要策略执行、青少年模式、版本、Push Token 已有;设备会话与青少年统一策略仍待增强 |
| 11 | 安全、举报与反馈 | 部分实现 | URL 检查、挑战、举报和反馈提交已有;风险令牌消费、处置工作流和安全通知缺失 |
## 3. 发布阻断问题
> 修复状态更新(2026-08-17):本节列出的 7 项 P0 代码问题均已处理。FCM 代码链路已经接通,部署环境仍必须提供真实 Firebase 服务账号凭据。
### P0-01 消息头协议与对接文档不一致(已修复)
对接文档约定消息头使用:
```json
{
"x-im-schema": 1,
"x-im-type": "text"
}
```
修复前的校验在 `server/store/store.go` 中存在两处不兼容:
- `x-im-schema` 被要求为字符串,文档示例中的数字 `1` 会被判为 malformed。
- 消息类型主要校验 `head["type"]`,而不是协议约定的 `head["x-im-type"]`。
- 服务端允许的 `type` 值与文档的 `voice`、`contact_card`、`forward_bundle`、`system_event` 等枚举不一致。
- `server/topic.go:1050` 仅通过 `head["type"] == "system"` 防止客户端伪造系统消息,无法覆盖 `x-im-type=system_event`。
修复结果:现已接受数字或字符串形式的 schema 1,以 `x-im-type` 为产品协议字段并兼容旧 `type`;增加类型别名兼容、双字段冲突校验和 `system_event` 防伪测试。
### P0-02 群业务表未按租户业务库自动初始化(已修复)
修复前群表脚本存在以下问题:
- SQL 在第 7 行硬编码 `CREATE DATABASE tinode`,所有表也写死在 `tinode.im_group_*`。
- `scripts/check_group_business_tables.go:44` 写死 `TABLE_SCHEMA='tinode'`。
- `server/db/mysql/adapter.go` 的业务库创建流程没有创建这些群业务表。
修复结果:12 张群表已进入 MySQL 统一初始化;服务启动和首次打开独立租户库时会执行幂等建表;SQL 与检查脚本改为使用 DSN 当前选中的租户库,不再硬编码 `tinode`。
### P0-03 群邀请会把非成员误判为已入群(已修复)
原逻辑只判断 `store.Subs.Get(...)` 的 `err == nil`,因此会把 `(nil, nil)` 的非成员判为已入群。
修复结果:邀请改为 MySQL 原子操作,在事务内判断有效 Subscription、恢复删除订阅、写邀请和成员记录、更新人数并写 Outbox;已补充非成员邀请回归测试。
### P0-04 入群申请主流程和状态一致性有问题(已修复)
原入群申请入口复用了 `requireIMGroupMember`,群外用户无法提交申请;审批状态和成员写入也不在同一事务。
修复结果:POST 入群申请只校验登录用户、群状态及当前非成员状态;审批在一个事务内锁定申请,先完成 Subscription/成员写入,再更新申请状态和 Outbox,任何一步失败都会回滚。
### P0-05 群禁言未接入消息发布权限(已修复)
原 WebSocket 发布路径没有查询成员禁言和全员禁言状态。
修复结果:消息持久化前调用群发言策略,覆盖成员禁言、全员禁言、禁言过期、管理员豁免和 Root 系统消息;已补充被禁言成员发布返回 403 的测试。
### P0-06 产品 Push Token 与 FCM 数据源断开(代码已修复)
- 产品 Push Token 接口写入 `im_device_push_tokens`,原 FCM 只读取旧 `im_devices`。
- 仓库默认配置没有内置 Firebase 凭据。
修复结果:FCM 现优先读取产品 Token 表,并保留旧设备表回退;支持通过 `IM_FCM_CREDENTIALS_FILE` 或 `IM_FCM_CREDENTIALS_JSON` 注入凭据并自动启用。真实 Android/iOS 推送仍需部署方提供有效凭据完成联调。
### P0-07 群写操作缺少事务和可靠事件机制(已修复)
原建群、成员变更、审批、退群、角色变更和群主转让分别修改 Topic、Subscription 与群扩展表,并存在忽略错误的情况。
修复结果:上述关键写操作已收口到租户 MySQL 事务,角色 ACL 与业务角色同步更新;同一事务写入 `im_group_events_outbox`,提交后通过 Hub 向在线 Topic 派发,派发失败保留 pending 状态并在后续群请求重试。其余双写路径不再忽略错误,并提供回滚补偿。
## 4. 跨模块公共问题
> 修复状态更新(2026-08-17):本节 6 项 P1 代码问题均已处理。
### P1-01 HTTP 公共请求头未统一执行(已修复)
对接文档要求产品 HTTP 请求携带 `X-Tinode-APIKey`、`X-IM-Schema` 和 `X-Request-ID`。修复前各 Handler 主要在 `authenticateIMHTTPBearer` 中校验 Bearer Token:
- `/v1/im` 认证接口未统一校验 API Key。
- 未找到统一的 HTTP `X-IM-Schema` 版本校验。
- `X-Request-ID` 仅回显,缺失时不会拒绝或生成服务端 Request ID。
修复结果:`/v1/im/` 已统一经过协议中间件,强制校验 Header 中的 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;`X-Request-ID` 缺失时服务端生成并在响应中回传。
### P1-02 幂等只检查 Key 是否存在,没有实现幂等语义(已修复)
修复前 `requireIMIdempotencyKey` 主要检查请求头非空,没有统一持久化以下信息:
- `tenant + uid + method + route + idempotency_key`
- 请求体哈希
- 首次响应状态码和响应体
修复结果:新增租户库 `im_http_idempotency` 表和统一中间件,按用户、方法、路由和 Key 建立唯一作用域,持久化 Query/Body 哈希及首次状态码/响应体;支持完整响应重放、同 Key 不同请求冲突和处理中冲突。
### P1-03 HTTP 写操作缺少在线事件与 Outbox(已修复)
修复前,消息编辑/撤回、好友申请/通过/删除/拉黑、群资料与成员变更等 HTTP 操作大多只修改数据库并返回 HTTP 响应,没有统一广播到在线会话,也没有可靠 Outbox 供重试。
修复结果:新增 `im_business_events_outbox`,通过 Hub 向 Topic 或用户其他设备发布 `im` 事件,队列满或 Hub 不可用时保留 pending 以便重试。已覆盖消息编辑/撤回、好友关系、群业务、会议和安全事件;消息、好友申请/审批、群与会议的关键写路径在业务事务内写 Outbox,其他可重复关系写由全局幂等机制保证失败后可补写。
### P1-04 系统通知只有查询能力,缺少业务生产者(已修复)
修复前,系统通知表、列表和已读接口已经存在,但好友、安全、群聊、会议等模块没有完整生产链路。
修复结果:好友申请/处理/删除、群成员与群设置、会议变更/提醒/回应和安全确认均已生产用户系统通知,通知 `event_id` 可重复执行且不会重复入箱。
### P1-05 隐私配置已存储但未全面执行(已修复)
修复前,资料可见性、加好友方式、最后在线时间、已读回执等配置已经有表和接口,但主要读写路径没有执行这些策略。
修复结果:搜索与好友申请已执行 `add_me_methods`,用户资料执行 `profile_visibility`,HTTP 回执列表执行 `read_receipts`,Tinode P2P `get.desc/get.sub` 输出执行 `last_seen_visibility`;非法隐私枚举也会在存储入口被拒绝。
### P1-06 返回了内部租户 ID(已修复)
修复前工作台数据把数字 `TenantID` 格式化后放入 `tenant_code` 返回。对接约定要求客户端只使用公开租户编码,不感知内部 `tenant_id`。
修复结果:工作台会根据当前 `TenantID` 查询租户注册表,`tenant_code` 仅返回公开 `code`,不再暴露内部数字 ID。
## 5. 按需求章节审核
### 第 1 章:租户、认证与安全
**已实现**
- 租户解析、状态检查和 WebSocket `hi` 阶段租户绑定。
- Basic/Token 登录和 Token 中的租户、`auth_version` 校验。
- 注册验证码创建/校验、注册前验证消费、密码重置。
- 独立密码设置与验证、安全挑战、实名提交与状态查询。
- 用户安全状态变化后的在线 Session 驱逐能力。
**问题与缺失**
- 验证码目前主要完成生成、哈希和记录,未接入可验证的真实短信发送通道。
- 限流以租户、场景、手机号和重发窗口为主,缺少 IP、设备、账号等组合风控。
- 未找到完整的登录失败次数、账号临时锁定和解锁闭环。
- 实名认证只有用户提交和状态查询,缺少审核/第三方认证结果回写流程。
- 缺少 Refresh Token、登录设备列表、单设备下线、全部设备下线等会话管理 API。
- 缺少 API Key 轮换、吊销和灰度兼容机制。
### 第 2 章:首页、会话与消息基础
**已实现**
- Tinode `me/sub/pres` 会话基础能力。
- Bootstrap、会话搜索、未读/已读回执、消息撤回和编辑接口。
- 系统通知列表、未读数和已读接口。
- 撤回/编辑能力开关、事务内 Outbox 和在线事件已开启。
- 好友、群、会议和安全模块可向系统通知箱生产通知。
**问题与缺失**
- 回执游标依赖当前成员集合和整数偏移,成员变化后不属于稳定游标。
### 第 3 章:单聊与好友关系
**已实现**
- 好友申请、申请列表、同意、拒绝、好友列表和删除。
- 好友备注、黑名单、关系上下文和用户资料查询。
- WebSocket P2P 发消息前调用 `CanSendP2P`,可阻止非好友或拉黑关系发送。
- 单聊消息、回执、文件上传和历史消息基础能力。
- 好友申请、处理、删除、拉黑和备注会生成可重试关系事件,需要提醒对端的操作同时生成系统通知。
- 搜索、好友申请、资料、已读回执和最后在线时间已执行隐私设置。
**问题与缺失**
- 删除好友或拉黑后的历史消息、文件下载、通话权限缺少统一规则和验收测试。
### 第 4 章:消息能力
**已实现**
- @成员和 `@all` 解析,`@all` 有管理员权限限制。
- 文本、媒体、文件等基础消息与附件上传。
- 消息转发、收藏和历史上下文查询。
- 通话消息会写入 `x-im-type=call`。
**问题与缺失**
- @消息没有形成专门通知、Push 优先级和频控策略。
- 未找到贴纸包/表情商店相关数据模型与接口。
- 媒体上传进度、失败恢复主要依赖客户端,服务端缺少分片/断点上传状态机。
- 消息 `client_mid` 的幂等写入和 topic seq 增长不是完整事务,并发重复请求可能产生序号空洞或冲突错误。
### 第 5 章:联系人和关系管理
**已实现**
- 用户搜索、好友、申请、黑名单、关系上下文等核心接口已经注册。
- 关系表和 P2P 发送权限已纳入 MySQL 租户业务存储。
- 关系写操作已纳入全局幂等、系统通知和实时事件链路。
**问题与缺失**
- 手机号/通讯录匹配的授权、脱敏和隐私规则未形成完整闭环。
- 关系独立审计记录仍需补充。
- 二维码关系入口主要依赖签名令牌,缺少服务端可吊销记录、使用次数和风险控制。
### 第 6 章:群聊
**已实现**
- 群创建、资料、成员、成员资料、邀请、入群申请、公告、角色、转让群主、退群、禁言、二维码等路由已增加。
- 已持久化群设置、群成员、邀请、入群申请、公告、公告已读和禁言数据。
- `docs/IM-群聊业务表设计.sql` 设计了 12 张群业务扩展表。
**已修复的阻断项与剩余缺失**
- 群业务表自动初始化已修复,见 P0-02。
- 群邀请误判非成员已修复,见 P0-03。
- 群外申请和审批一致性已修复,见 P0-04。
- WebSocket 禁言校验已修复,见 P0-05。
- 建群、成员、角色和群主变更事务及 Outbox 已落地,见 P0-07。
- `im_group_qrcodes`、`im_group_files`、`im_group_admin_logs` 目前仍主要是表设计;`im_group_idempotency` 和 `im_group_events_outbox` 已有生产代码。
- 未找到群解散接口和完整状态清理流程。
- 未找到群文件列表、分类、搜索和文件消息自动索引能力。
- 群二维码主要使用 HMAC Token,没有持久化,无法单码吊销、限制使用次数或查看使用记录。
- 群公告、成员、角色、禁言等变更已接入 Outbox 与系统通知,真实多端与离线 Push 仍需联调验收。
### 第 7 章:收藏、搜索与文件
**已实现**
- 收藏新增、列表、详情、删除和转发。
- 消息搜索、上下文定位和媒体消息筛选。
- 文件资源访问会校验用户权限,响应支持 ETag;本地存储通过 `ServeContent` 支持 Range,S3 支持预签名 URL。
**问题与缺失**
- 未接入病毒扫描、恶意文件识别、图片/视频内容安全和隔离状态。
- 群文件索引表没有写入及查询实现。
- 搜索和列表游标的稳定性、权限快照和数据删除后的结果一致性需要专项测试。
- 文件下载审计、敏感文件水印和生命周期清理未实现。
### 第 8 章:音视频通话
**已实现**
- Tinode 通话邀请、接听、挂断等实时事件基础能力。
- 通话记录创建、接听、结束和列表接口。
- P2P 消息入口的好友关系校验可覆盖在线通话邀请的基础关系限制。
**问题与缺失**
- FCM 代码已使用产品 Token 数据源,但离线来电仍需真实凭据和移动端联调验收。
- 多设备同时接听缺少服务端唯一接听者仲裁和状态序列控制。
- 通话状态写入、实时事件和 Push 之间没有幂等/Outbox 保证。
- 未实现通话质量指标、失败原因标准化和质量上报。
- 未找到群语音/群视频通话的完整参与者状态模型。
- 好友、黑名单、青少年模式等策略没有通过统一服务覆盖所有通话入口。
### 第 9 章:工作台与办公协同
**已实现**
- 工作台聚合接口、组织树和成员搜索。
- 会议创建、修改、取消、提醒、参会响应和查询。
- 会议变更与参会响应在同一事务内写入 Outbox 和系统通知。
- 工作台 `tenant_code` 返回注册表的公开租户编码。
- 日报/月报草稿、提交、列表、统计和提醒。
- 邮箱配置查询和问题提交。
**问题与缺失**
- 工作台功能项与权限没有完全由服务端动态下发和裁剪。
- 组织、会议和报表的数据范围/角色权限需要更严格的统一校验和跨租户测试。
- 会议提醒已有实时事件和系统通知;报表审批/提交结果、角标和真实 Push 仍需闭环。
- 邮箱提交更接近收件记录,缺少分派、处理、回复、关闭等工作流。
### 第 10 章:设置、隐私与设备
**已实现**
- 个人资料读写、隐私设置读写。
- 加好友方式、资料可见性、最后在线时间和已读回执已在主要业务入口执行。
- 青少年模式启用、关闭和查询。
- 最新版本查询和设备 Push Token 注册接口。
**问题与缺失**
- `GET /me` 的实名状态和青少年模式字段存在硬编码默认值,与独立查询接口的数据可能不一致。
- 缺少登录设备/会话列表、设备改名、单设备退出、全端退出等完整设备管理。
- 青少年模式目前以设置存储为主,夜间限制、内容过滤、通话/加好友限制没有统一策略执行层。
- Push Token 注册与 FCM 发送数据源已统一,真实 Firebase 凭据和移动端仍待联调。
- 版本接口缺少灰度发布、渠道、强更分组及回滚策略。
### 第 11 章:安全、举报与反馈
**已实现**
- URL 安全检查和基础规则判定。
- 风险挑战继续/取消流程及风险动作令牌签发。
- 举报原因列表、举报提交、反馈配置和反馈提交。
**问题与缺失**
- `risk_action_token` 签发后,未找到原始高风险业务操作对该令牌进行统一消费和一次性校验的链路。
- URL 检查主要是基础规则,缺少信誉库、重定向链分析和异步复检。
- 举报和反馈以接收为主,缺少审核、分派、处置、申诉、回复和 SLA 状态流转。
- 风险确认已写入用户可见的系统安全通知,其他安全操作仍需按场景补齐。
- 举报证据文件没有明确接入专门 ACL、安全扫描和保留/删除策略。
## 6. 数据库与迁移审核
### 已具备
- 核心 Tinode 表已统一使用 `im_` 前缀。
- 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。
- 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。
- `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。
- MySQL 业务表已统一具备独立 `id` 主键;原先使用自然键或复合主键的 11 张表已改用服务端雪花 `BIGINT id`,原业务键保留唯一索引。
- 应用启动会在当前租户库幂等补列、回填存量雪花 ID 并切换主键;迁移使用数据库级 advisory lock,独立租户库与残留 `tenant_id` 旧结构均可保留原业务唯一性。
- 用户身份字段已结构化:手机号迁入 `im_users.phone`,邮箱迁入 `im_user_profiles.email`;用户 `tags/im_usertags` 在回填后删除,Topic 标签体系继续保留。
### 需要修正
1. 继续把其他历史建表/升级逻辑收敛到唯一 schema 版本和迁移入口;本次雪花主键改造已具备统一自动迁移入口。
2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。
3. 新租户建库和存量租户升级必须执行同一套 migration。
4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。
5. 对已设计但未使用的群表明确去留,避免表结构被误认为功能已经完成。
6. 补充索引、唯一键和外键/逻辑引用的一致性检查,尤其是邀请、申请、成员和幂等键。
## 7. 测试与验证结果
### 本次执行
```bash
go test -tags mysql ./server/db/mysql ./server/store ./server/push/fcm ./server -count=1
```
结果:全部通过。覆盖 HTTP 协议头、幂等响应重放/冲突、消息协议、隐私策略、群业务、FCM Token 数据源和 MySQL 建表语句。
```bash
go test -tags mysql ./server/db/mysql/tests -count=1
```
结果:使用 `root/root@127.0.0.1:3306` 和临时数据库完整通过;包含租户库创建、群表、P1 基础设施表、无 `tenant_id` 约束和多租户隔离测试。测试后已恢复 `test.conf` 并删除临时数据库。
### 测试缺口
- 缺少 HTTP 写入后 WebSocket 多端同步的集成测试。
- 缺少真实 FCM 凭据和移动端设备的离线推送测试。
- 隐私策略的基础分支已有测试,但黑名单、青少年模式、文件和通话的组合权限仍缺专项矩阵。
- 幂等重放和同 Key 不同 Body 已有单测,多进程高并发压测仍未执行。
## 8. 文档一致性问题
1. `IM-服务端多租户改造方案.md` 已明确废弃,本报告未把它作为实现依据。
2. `IM-系统设计文档.md` 中仍有共享业务库加 `tenant_id` 的描述,与当前独立租户业务库方向不一致,需要更新或标记历史版本。
3. `IM-前后端对接文档.md` 的接口状态是设计阶段快照,已有多项“新增”路由落地,应按本报告和实际联调结果刷新。
4. `IM-未实现后续增强项.md` 已于 2026-08-17 按最新 P0/P1 修复状态同步:已完成项已移入各章基线,待办表仅保留未实现或待环境联调的增强项。
5. 群表设计文档与独立库多租户方案冲突,脚本中的 `tinode` 库名必须移除。
## 9. 建议开发顺序
### 第一阶段:解除主流程阻断(已完成)
1. 统一消息协议字段、类型枚举和兼容策略。
2. 把群表纳入租户库 migration,并完成存量租户升级。
3. 修复群邀请、群外申请、审批一致性和禁言绕过。
4. 为建群、成员变更、群主转让建立事务和明确错误处理。
5. 统一 Push Token 数据源并启用 FCM 测试环境。
### 第二阶段:完成生产闭环(P1 代码已完成)
1. 建立统一 HTTP Middleware:API Key、Schema、Request ID、Bearer、限流。
2. 建立统一幂等存储和响应重放机制。
3. 落地 Outbox,补齐关系、群、消息编辑/撤回、会议等实时事件。
4. 建立系统通知生产服务,并与 Push、角标联动。
5. 建立统一隐私/关系/青少年模式 Policy Service。
### 第三阶段:补齐增强能力
1. 群文件、群解散、二维码管理、管理员日志。
2. 多设备会话管理、Refresh Token、API Key 生命周期。
3. 通话多设备仲裁、质量指标及群通话。
4. 举报、反馈、实名和邮箱的后台处置工作流。
5. 文件安全扫描、版本灰度和运营配置能力。
## 10. 建议验收门槛
在声明产品需求“已完成”前,至少满足:
- 新建租户无需手工 SQL 即可完成全部模块初始化。
- 文档示例消息可直接发送、存储、搜索、转发并被旧客户端兼容处理。
- 所有写接口具备可验证的幂等语义。
- HTTP 状态变更能可靠同步到 WebSocket 在线端,并能通过 Outbox 重试。
- FCM 使用产品注册的 Token 完成真实离线推送。
- 群邀请、申请、禁言、退群、转让和解散通过真实 MySQL 集成测试。
- 好友、黑名单、隐私、青少年模式在消息、文件、资料、通话入口保持一致。
- `go test ./...` 能完整编译并通过;关键链路另有端到端测试。

Some files were not shown because too many files have changed in this diff Show More