Compare commits

...
201 Commits
Author SHA1 Message Date
richjohnrambo bc101b3278 fix: query tenant-sharded private messages
Deploy im-server production / deploy (push) Successful in 7m7s
2026-10-21 12:40:40 +08:00
richjohnrambo 39b0392545 feat: add trusted customer service friendships
Deploy im-server production / deploy (push) Successful in 11m38s
2026-10-20 22:24:34 +08:00
Your Name 93a6ff0d03 fix(ci): keep im deploy scoped to service
Deploy im-server production / deploy (push) Successful in 6m19s
2026-10-20 17:56:31 +08:00
richjohnrambo 9c10862a6d fix: recreate Caddy after IM CORS update
Deploy im-server production / deploy (push) Successful in 7m58s
2026-10-19 14:34:13 +08:00
richjohnrambo 5197960e58 fix: remove conflicting production IM CORS header
Deploy im-server production / deploy (push) Successful in 7m23s
2026-10-19 14:24:57 +08:00
richjohnrambo fe5f870b6b ci: print production IM Caddy route
Deploy im-server production / deploy (push) Successful in 7m25s
2026-10-19 14:16:11 +08:00
richjohnrambo 5b270860e7 ci: inspect production IM CORS routing
Deploy im-server production / deploy (push) Successful in 8m9s
2026-10-19 14:06:20 +08:00
Your Name 209f105b12 fix: honor admin internal api toggle
Deploy im-server production / deploy (push) Successful in 4m25s
2026-10-18 20:24:39 +08:00
Your Name 5d49008960 ci: deploy pro with docker compose
Deploy im-server production / deploy (push) Failing after 8m46s
2026-10-18 20:11:07 +08:00
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
264 changed files with 33683 additions and 13311 deletions
+53 -22
View File
@@ -1,38 +1,69 @@
name: Deploy im-server (k3s test cluster) name: Deploy im-server production
on: on:
push:
branches:
- pro
workflow_dispatch: workflow_dispatch:
inputs:
image_tag: concurrency:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)' group: production-im-server
required: false cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/im_server
SERVICE: im-server
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs: jobs:
deploy: deploy:
runs-on: deploy-host runs-on: deploy-host
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 确定镜像tag - name: Log in to Gitea Container Registry
id: vars env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="${{ github.event.inputs.image_tag }}" set -euo pipefail
[ -z "$TAG" ] && TAG=test printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 登录镜像仓库 - name: Build and push image
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin env:
GITHUB_SHA: ${{ github.sha }}
- name: 构建并推送镜像(从本仓库源码编译,不是官方预编译包)
run: | run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -f docker/tinode/Dockerfile.build \ docker build -f docker/tinode/Dockerfile.build \
-t git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} . -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: 部署到k3s测试集群 - name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
mkdir -p ~/.kube set -euo pipefail
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config install -d -m 700 "$HOME/.ssh"
chmod 600 ~/.kube/config KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
kubectl rollout restart deployment/im-server -n im-server-system cleanup() {
kubectl rollout status deployment/im-server -n im-server-system --timeout=120s shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"cd /opt/olns && docker compose pull im-server && docker compose up -d --no-deps --wait im-server"
+2 -2
View File
@@ -80,8 +80,8 @@ See [instructions](./docker/README.md)
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. 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 ```js
"mysql": { "mysql": {
"dsn": "root@tcp(localhost)/tinode?parseTime=true", "dsn": "root@tcp(localhost)/meeting_im?parseTime=true",
"database": "tinode" "database": "meeting_im"
}, },
``` ```
+11 -1
View File
@@ -143,7 +143,7 @@ tinode 移除
全局禁止加好友 比如下班时间到了 不能加 从群成员去看 不能显示个人信息 个人名片也不能看 二维码也不能加 全局禁止加好友 比如下班时间到了 不能加 从群成员去看 不能显示个人信息 个人名片也不能看 二维码也不能加
阿里云的oss放一个域名清单 要加密 阿里云的oss放一个域名清单 要加密 apikey动态更换
设备类型 ip 全部采集入库 设备类型 ip 全部采集入库
离线时间 和上线时间的采集 离线时间 和上线时间的采集
检查一下多处存同一个信息的。尤其是用户的 检查一下多处存同一个信息的。尤其是用户的
@@ -201,4 +201,14 @@ Bundle ID:com.ysl.IM
KEY ID:J4ZUVHW59G KEY ID:J4ZUVHW59G
Team ID:2DN6KF37CH Team ID:2DN6KF37CH
单号登录 多设备顶号 已经实现
多标签登录的消息到达率
建一个千人大群
telegram报警功能
加上ai翻译的功能 ai笔记
后端要做一下 redis 的 sentinel适配, 不然重新选举之后可能会写错节点报错
https://api.118im.com 简单弄一个官网,https://api.118im.com/legal/privacy-policy需要把名字改对,TestFlight的beta版需要审核,这两个可能会是卡点  
+11 -10
View File
@@ -196,7 +196,7 @@ func (a *audit) run() {
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("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": "联调群" + suffix, "description": "自动化测试群", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string { 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.groupTopic = asString(r.Data["topic"])
a.groupVer = asInt(r.Data["version"]) a.groupVer = asInt(r.Data["version"])
return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer) return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer)
@@ -379,7 +379,7 @@ func (a *audit) run() {
func (a *audit) register(chapter, id, username, phone, password string, primary bool) { func (a *audit) register(chapter, id, username, phone, password string, primary bool) {
var challenge, verification string var challenge, verification string
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) 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"]) challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge) return "challenge_id=" + mask(challenge)
}) })
@@ -404,7 +404,7 @@ func (a *audit) register(chapter, id, username, phone, password string, primary
func (a *audit) registerThird(chapter, id, username, phone, password string) { func (a *audit) registerThird(chapter, id, username, phone, password string) {
var challenge, verification string var challenge, verification string
a.testHTTP(chapter, id+"-code", "入群用户注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) 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"]) challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge) return "challenge_id=" + mask(challenge)
}) })
@@ -446,13 +446,14 @@ func (a *audit) wsRegister(username, phone, password, challenge, verification st
accID := "acc-" + username accID := "acc-" + username
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
err = conn.WriteJSON(map[string]any{"acc": map[string]any{ err = conn.WriteJSON(map[string]any{"acc": map[string]any{
"id": accID, "id": accID,
"user": "new", "user": "new",
"scheme": "basic", "scheme": "basic",
"username": username, "username": username,
"phone": phone, "phone": phone,
"secret": secret, "registration_type": 1,
"login": true, "secret": secret,
"login": true,
"cred": []map[string]any{{ "cred": []map[string]any{{
"meth": "tel", "meth": "tel",
"val": phone, "val": phone,
+19 -17
View File
@@ -364,7 +364,7 @@ func (a *audit) run() {
if alice == nil || bob == nil || a.groupTopic == "" { if alice == nil || bob == nil || a.groupTopic == "" {
return "FAIL: websocket client or group missing" return "FAIL: websocket client or group missing"
} }
text := "WS 群消息 " + suffix text := "group audit message " + suffix
seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text) seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text)
if err != nil { if err != nil {
return "FAIL: " + err.Error() return "FAIL: " + err.Error()
@@ -377,11 +377,11 @@ func (a *audit) run() {
return "FAIL: websocket client missing" return "FAIL: websocket client missing"
} }
head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix} head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix}
first, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix) first, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix)
if err != nil { if err != nil {
return "FAIL: first pub: " + err.Error() return "FAIL: first pub: " + err.Error()
} }
second, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix) second, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix)
if err != nil { if err != nil {
return "FAIL: duplicate pub: " + err.Error() return "FAIL: duplicate pub: " + err.Error()
} }
@@ -406,7 +406,7 @@ func (a *audit) run() {
return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count) return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count)
}) })
a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string { a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string {
status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("WS 群消息")+"&type=text", nil, a.tokenB) status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("group audit message")+"&type=text", nil, a.tokenB)
if err != nil { if err != nil {
return "FAIL: " + err.Error() return "FAIL: " + err.Error()
} }
@@ -637,13 +637,14 @@ func (a *audit) register(username, phone, password string) (string, string, erro
id := "acc-" + username id := "acc-" + username
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
if err = c.write(map[string]any{"acc": map[string]any{ if err = c.write(map[string]any{"acc": map[string]any{
"id": id, "id": id,
"user": "new", "user": "new",
"scheme": "basic", "scheme": "basic",
"username": username, "username": username,
"phone": phone, "phone": phone,
"secret": secret, "registration_type": 1,
"login": true, "secret": secret,
"login": true,
"cred": []map[string]any{{ "cred": []map[string]any{{
"meth": "tel", "meth": "tel",
"val": phone, "val": phone,
@@ -667,7 +668,7 @@ func (a *audit) register(username, phone, password string) (string, string, erro
} }
func (a *audit) verificationCode(phone string) (string, error) { func (a *audit) verificationCode(phone string) (string, error) {
status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "") status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "")
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -724,8 +725,8 @@ func (a *audit) createFriendship() (string, error) {
func (a *audit) createGroup(suffix string) (string, error) { func (a *audit) createGroup(suffix string) (string, error) {
status, r, err := a.call("POST", "/v1/im/groups", map[string]any{ status, r, err := a.call("POST", "/v1/im/groups", map[string]any{
"name": "WS协议群" + suffix, "name": "auditgroup" + suffix,
"description": "WS audit group", "description": "audit group",
"member_user_ids": []string{a.userB}, "member_user_ids": []string{a.userB},
"avatar_file_id": "", "avatar_file_id": "",
}, a.tokenA) }, a.tokenA)
@@ -827,9 +828,10 @@ func (a *audit) openBasicLoggedIn(name, username, password string) (*wsClient, e
loginID := "login-" + strings.ToLower(name) loginID := "login-" + strings.ToLower(name)
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
if err = c.write(map[string]any{"login": map[string]any{ if err = c.write(map[string]any{"login": map[string]any{
"id": loginID, "id": loginID,
"scheme": "basic", "scheme": "basic",
"secret": secret, "login_type": 1,
"secret": secret,
}}); err != nil { }}); err != nil {
_ = c.conn.Close() _ = c.conn.Close()
return nil, err return nil, err
+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
+11 -3
View File
@@ -188,6 +188,14 @@ You can specify the following environment variables when issuing `docker run` co
| `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_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. | | `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_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). | | `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. | | `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_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]. |
@@ -203,9 +211,9 @@ You can specify the following environment variables when issuing `docker run` co
| `FS_CORS_ORIGINS`[^2] | string | `["*"]` | Cors origins when media is served from the file system. See `AWS_CORS_ORIGINS` for details. | | `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. | | `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. | | `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)/tinode?&#8203;parseTime=true&#8203;&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). | | `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. | | `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/tinode?&#8203;sslmode=disable&#8203;&connect_timeout=10'</code> | PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). | | `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. | | `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. | | `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_AUTH_MECHANISM`[^2] | string | `"plain"` | SMTP authentication mechanism to use; one of "login", "cram-md5", "plain". |
@@ -224,7 +232,7 @@ You can specify the following environment variables when issuing `docker run` co
| `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. | | `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_AUTH_TOKEN` | string | | Tinode Push Gateway authentication token. |
| `TNPG_ORG`[^2] | string | | Tinode Push Gateway organization name as registered at https://console.tinode.co | | `TNPG_ORG`[^2] | string | | Tinode Push Gateway organization name as registered at https://console.tinode.co |
| `UID_ENCRYPTION_KEY`[^2] | string | `la6YsO+bNX/+XIkOqc5Svw==` | base64-encoded 16 random bytes used as an encryption key for user IDs. | | `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. | | `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. | | `WAIT_FOR` | string | | If non-empty, waits for the specified database `host:port` to be available before starting the server. |
+13 -4
View File
@@ -51,8 +51,10 @@ ENV SAMPLE_DATA=$SAMPLE_DATA
ENV DEFAULT_COUNTRY_CODE=US ENV DEFAULT_COUNTRY_CODE=US
# The MySQL DSN connection. # The MySQL DSN connection.
ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci' ENV MYSQL_DSN='root@tcp(mysql)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci'
ENV REDIS_ADDR= ENV REDIS_ADDR=
ENV REDIS_SENTINEL_MASTER_NAME=
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
ENV REDIS_USERNAME= ENV REDIS_USERNAME=
ENV REDIS_PASSWORD= ENV REDIS_PASSWORD=
ENV REDIS_DB=0 ENV REDIS_DB=0
@@ -85,6 +87,9 @@ ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD= ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=3 ENV KAFKA_MAX_RETRY_COUNT=3
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]' 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_DEAD_LETTER_RETENTION_SECONDS=7776000
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
@@ -97,10 +102,12 @@ ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=false ENV INTERNAL_API_ENABLED=true
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300 ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET= 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_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL= ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
@@ -108,7 +115,7 @@ ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000 ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# The PostgreSQL DSN connection. # The PostgreSQL DSN connection.
ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10' ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/meeting_im?sslmode=disable&connect_timeout=10'
# Disable chatbot plugin by default. # Disable chatbot plugin by default.
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
@@ -153,7 +160,7 @@ ENV API_KEY_SALT=T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=
ENV AUTH_TOKEN_KEY=wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc= ENV AUTH_TOKEN_KEY=wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc=
# Key to initialize UID generator # Key to initialize UID generator
ENV UID_ENCRYPTION_KEY=la6YsO+bNX/+XIkOqc5Svw== ENV UID_ENCRYPTION_KEY=dkXxWLwqUNRfYmdXRzOkZw==
# Disable TLS by default. # Disable TLS by default.
ENV TLS_ENABLED=false ENV TLS_ENABLED=false
@@ -187,6 +194,8 @@ ENV TNPG_ORG=
ENV JPUSH_PUSH_ENABLED=false ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push 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_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60 ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false ENV JPUSH_APNS_PRODUCTION=false
+12 -3
View File
@@ -14,7 +14,7 @@ COPY . .
RUN CGO_ENABLED=0 go build -tags mysql -o /out/tinode ./server 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 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 # scripts/apply_hotgo_tenant_db.go is `//go:build ignore`-tagged (a standalone
# migration script, not part of any package) - build it by exact file path. # 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 RUN CGO_ENABLED=0 go build -o /out/apply-hotgo-tenant-db ./scripts/apply_hotgo_tenant_db.go
# ---- Stage 2: runtime image ---- # ---- Stage 2: runtime image ----
@@ -35,6 +35,8 @@ ENV DEFAULT_COUNTRY_CODE=US
ENV STORE_USE_ADAPTER=mysql ENV STORE_USE_ADAPTER=mysql
ENV REDIS_ADDR= ENV REDIS_ADDR=
ENV REDIS_SENTINEL_MASTER_NAME=
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
ENV REDIS_USERNAME= ENV REDIS_USERNAME=
ENV REDIS_DB=0 ENV REDIS_DB=0
ENV REDIS_DIAL_TIMEOUT_MS=2000 ENV REDIS_DIAL_TIMEOUT_MS=2000
@@ -65,6 +67,9 @@ ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD= ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=3 ENV KAFKA_MAX_RETRY_COUNT=3
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]' 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_DEAD_LETTER_RETENTION_SECONDS=7776000
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
@@ -77,10 +82,12 @@ ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=false ENV INTERNAL_API_ENABLED=true
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300 ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET= 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_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL= ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
@@ -88,7 +95,7 @@ ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000 ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# Tenant registry lives in the hotgo database (shared with im_manager); tenant # Tenant registry lives in the hotgo database (shared with im_manager); tenant
# DB routing resolves to the same tinode DB for the seeded "default" tenant. # business data lives in the shared meeting_im DB and is scoped by tenant_id.
ENV TENANT_REGISTRY_DB=hotgo ENV TENANT_REGISTRY_DB=hotgo
ENV MEDIA_HANDLER=sys_config ENV MEDIA_HANDLER=sys_config
@@ -103,6 +110,8 @@ ENV TNPG_PUSH_ENABLED=false
ENV JPUSH_PUSH_ENABLED=false ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push 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_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60 ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false ENV JPUSH_APNS_PRODUCTION=false
+50 -34
View File
@@ -5,6 +5,8 @@
"static_mount": "/", "static_mount": "/",
"grpc_listen": ":16060", "grpc_listen": ":16060",
"grpc_keepalive_enabled": true, "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", "api_key_salt": "$API_KEY_SALT",
"max_message_size": 4194304, "max_message_size": 4194304,
"max_subscriber_count": 128, "max_subscriber_count": 128,
@@ -77,59 +79,63 @@
"use_adapter": "$STORE_USE_ADAPTER", "use_adapter": "$STORE_USE_ADAPTER",
"adapters": { "adapters": {
"mysql": { "mysql": {
"database": "tinode", "database": "meeting_im",
"dsn": "$MYSQL_DSN", "dsn": "$MYSQL_DSN",
"max_open_conns": 64, "max_open_conns": $MYSQL_MAX_OPEN_CONNS,
"max_idle_conns": 64, "max_idle_conns": $MYSQL_MAX_IDLE_CONNS,
"conn_max_lifetime": 60, "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, "sql_timeout": 10,
"tenant_registry_db": "$TENANT_REGISTRY_DB", "tenant_registry_db": "$TENANT_REGISTRY_DB",
"tenant_db_routing": true "tenant_db_routing": true
}, },
"postgres": { "postgres": {
"database": "tinode", "database": "meeting_im",
"dsn": "$POSTGRES_DSN" "dsn": "$POSTGRES_DSN"
}, },
"rethinkdb": { "rethinkdb": {
"database": "tinode", "database": "meeting_im",
"addresses": "rethinkdb" "addresses": "rethinkdb"
}, },
"mongodb": { "mongodb": {
"database": "tinode", "database": "meeting_im",
"addresses": "mongodb", "addresses": "mongodb",
"replica_set": "rs0" "replica_set": "rs0"
} }
}, },
"redis": { "redis": {
"addr": "redis.118im.com:30637", "addr": "$REDIS_ADDR",
"username": "", "sentinel_master_name": "$REDIS_SENTINEL_MASTER_NAME",
"password": "HMTJzkm.h>Y+E9ep", "sentinel_addrs": $REDIS_SENTINEL_ADDRS_JSON,
"db": 0, "username": "$REDIS_USERNAME",
"key_prefix": "im", "password": "$REDIS_PASSWORD",
"dial_timeout_ms": 2000, "db": $REDIS_DB,
"read_timeout_ms": 2000, "key_prefix": "$REDIS_KEY_PREFIX",
"write_timeout_ms": 2000, "dial_timeout_ms": $REDIS_DIAL_TIMEOUT_MS,
"pending_ttl_seconds": 300, "read_timeout_ms": $REDIS_READ_TIMEOUT_MS,
"done_ttl_seconds": 86400, "write_timeout_ms": $REDIS_WRITE_TIMEOUT_MS,
"failed_ttl_seconds": 600, "pending_ttl_seconds": $REDIS_PENDING_TTL_SECONDS,
"hot_message_ttl_seconds": 86400, "done_ttl_seconds": $REDIS_DONE_TTL_SECONDS,
"presence_ttl_seconds": 120, "failed_ttl_seconds": $REDIS_FAILED_TTL_SECONDS,
"cache_ttl_seconds": 300, "hot_message_ttl_seconds": $REDIS_HOT_MESSAGE_TTL_SECONDS,
"short_state_ttl_seconds": 600, "presence_ttl_seconds": $REDIS_PRESENCE_TTL_SECONDS,
"group_fanout_enabled": true "cache_ttl_seconds": $REDIS_CACHE_TTL_SECONDS,
"short_state_ttl_seconds": $REDIS_SHORT_STATE_TTL_SECONDS,
"group_fanout_enabled": $REDIS_GROUP_FANOUT_ENABLED
} }
}, },
"kafka": { "kafka": {
"enabled": true, "enabled": true,
"brokers": ["kafka.118im.com:30835"], "brokers": $KAFKA_BROKERS_JSON,
"client_id": "im-server", "client_id": "im-server",
"consumer_group": "im-server", "consumer_group": "im-server",
"dial_timeout_ms": 3000, "dial_timeout_ms": 3000,
"write_timeout_ms": 3000, "write_timeout_ms": 3000,
"read_timeout_ms": 3000, "read_timeout_ms": 3000,
"batch_timeout_ms": 10, "batch_timeout_ms": 10,
"required_acks": 1, "required_acks": -1,
"allow_auto_topic_creation": false, "allow_auto_topic_creation": false,
"tls_enabled": false, "tls_enabled": false,
"sasl_enabled": false, "sasl_enabled": false,
@@ -138,6 +144,9 @@
"sasl_password": "", "sasl_password": "",
"max_retry_count": 3, "max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000], "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, "dead_letter_retention_seconds": 7776000,
"topics": { "topics": {
"admin_command": "im.admin.command", "admin_command": "im.admin.command",
@@ -154,17 +163,21 @@
} }
}, },
"internal_api": { "internal_api": {
"enabled": true, "enabled": $INTERNAL_API_ENABLED,
"time_window_seconds": 300, "time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
"services": { "services": {
"hotgo-admin": { "$INTERNAL_API_HOTGO_SERVICE_NAME": {
"secret": "$INTERNAL_API_HOTGO_SECRET", "secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"] "allow": ["admin_query"]
},
"$INTERNAL_API_CULTIVATE_SERVICE_NAME": {
"secret": "$INTERNAL_API_CULTIVATE_SECRET",
"allow": ["cultivate"]
} }
} }
}, },
"admin_internal_api": { "admin_internal_api": {
"enabled": true, "enabled": $ADMIN_INTERNAL_API_ENABLED,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL", "base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME", "service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET", "secret": "$ADMIN_INTERNAL_API_SECRET",
@@ -253,6 +266,8 @@
"enabled": $JPUSH_PUSH_ENABLED, "enabled": $JPUSH_PUSH_ENABLED,
"api_domain": "$JPUSH_API_DOMAIN", "api_domain": "$JPUSH_API_DOMAIN",
"push_path": "$JPUSH_PUSH_PATH", "push_path": "$JPUSH_PUSH_PATH",
"workers": $JPUSH_WORKERS,
"device_cache_ttl_seconds": $JPUSH_DEVICE_CACHE_TTL_SECONDS,
"default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS, "default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS,
"call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS, "call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS,
"apns_production": $JPUSH_APNS_PRODUCTION, "apns_production": $JPUSH_APNS_PRODUCTION,
@@ -271,11 +286,12 @@
"cluster_config": { "cluster_config": {
"self": "", "self": "",
"nodes": [ "nodes": $CLUSTER_NODES_JSON,
{"name": "tinode-0", "addr": "tinode-0:12000"}, "proxy_to_master_buffer": $CLUSTER_PROXY_TO_MASTER_BUFFER,
{"name": "tinode-1", "addr": "tinode-1:12001"}, "proxy_to_master_workers": $CLUSTER_PROXY_TO_MASTER_WORKERS,
{"name": "tinode-2", "addr": "tinode-2:12002"} "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": { "failover": {
"enabled": true, "enabled": true,
"heartbeat": 100, "heartbeat": 100,
+26 -10
View File
@@ -13,6 +13,21 @@ else
CONFIG=working.config CONFIG=working.config
: "${REDIS_KEY_PREFIX:=im}" : "${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. # Remove the old config.
rm -f working.config rm -f working.config
@@ -161,8 +176,8 @@ else
exit 1 exit 1
fi fi
# Create/upgrade the hotgo tenant registry and the workbench/org/meetings/ # Validate that startup is pointed at the shared meeting_im database. Schema
# reports/mailbox/etc. tables that init-db above does not manage. # changes are applied separately and are never performed during pod startup.
run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "./apply-hotgo-tenant-db failed. Quitting." echo "./apply-hotgo-tenant-db failed. Quitting."
@@ -185,11 +200,12 @@ fi
args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL") args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL")
# Run the tinode server. Mirror the captured stderr to container logs on exit so # Run the tinode server, mirroring output to both stdout (so kubectl logs/Promtail's
# CrashLoopBackOff pods expose the actual startup failure via kubectl logs. # standard container-log collection actually sees it -- confirmed live that the old
./tinode "${args[@]}" 2>> /var/log/tinode.log # file-only redirect meant Loki never received a single real tinode log line, only
status=$? # this script's own earlier init-db output) and the log file (kept for exec-based
if [ "$status" -ne 0 ] && [ -s /var/log/tinode.log ]; then # inspection, e.g. `kubectl exec ... -- cat /var/log/tinode.log`). PIPESTATUS[0] is
tail -200 /var/log/tinode.log >&2 # required here: with output piped through tee, a bare $? would report tee's exit
fi # status (almost always 0) instead of tinode's.
exit "$status" ./tinode "${args[@]}" 2>&1 | tee -a /var/log/tinode.log
exit "${PIPESTATUS[0]}"
@@ -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。
@@ -2,11 +2,12 @@
## 1. 适用范围 ## 1. 适用范围
本文档用于 iOS、Android 客户端检查新版本,以及执行推荐更新和强制更新。 本文档用于 iOS、Android、Web 客户端检查新版本,以及执行推荐更新和强制更新。
- 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。 - 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。
- iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。 - iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。
- Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。 - Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。
- Web 端没有安装包时,`store_url` 和 `package_url` 可以为空,使用 `update_type`、`min_supported_version` 和 `release_notes` 控制强刷、兼容提醒或维护提示。
- 客户端不直接访问数据库,也不自行拼接 OSS 地址。 - 客户端不直接访问数据库,也不自行拼接 OSS 地址。
## 2. 检查版本接口 ## 2. 检查版本接口
@@ -22,14 +23,14 @@ GET https://api.118im.com/v1/im/app/releases/latest
| Header | 必填 | 示例 | 说明 | | Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 | | `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 |
| `X-Client-Platform` | 建议 | `ios` / `android` | 客户端平台 | | `X-Client-Platform` | 建议 | `ios` / `android` / `web` | 客户端平台 |
### 2.2 Query 参数 ### 2.2 Query 参数
| 参数 | 必填 | 示例 | 说明 | | 参数 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `apikey` | 是 | `AQAAAA...` | 应用 API Key | | `apikey` | 是 | `AQAAAA...` | 应用 API Key |
| `platform` | 是 | `android` | 仅支持 `ios`、`android` | | `platform` | 是 | `android` | 仅支持 `ios`、`android`、`web` |
| `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 | | `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 |
| `channel` | 否 | `stable` | 发布渠道,默认 `stable` | | `channel` | 否 | `stable` | 发布渠道,默认 `stable` |
@@ -190,7 +191,7 @@ shasum -a 256 meetingim-1.3.0.apk
| --- | --- | | --- | --- |
| 400 | 缺少 `X-IM-Schema: 1` | | 400 | 缺少 `X-IM-Schema: 1` |
| 400 | 缺少或错误的 `current_version` | | 400 | 缺少或错误的 `current_version` |
| 400 | `platform` 不是 `ios` 或 `android` | | 400 | `platform` 不是 `ios`、`android` 或 `web` |
| 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 | | 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 |
## 9. 当前测试环境 ## 9. 当前测试环境
@@ -203,4 +204,4 @@ API Key: AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy
默认渠道: stable 默认渠道: stable
``` ```
当前测试库已配置 iOS、Android `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。 当前测试库已配置 iOS、Android、Web `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。
+12 -4
View File
@@ -724,11 +724,19 @@ IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终
1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。 1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。
2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。 2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。 3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
4. 消费者按 `message_kind` 区分私聊与群聊: 4. 私聊和群聊使用独立 Topic、独立消费组和独立物理表:私聊批量写入租户分表 `im_%03d_messages`,群聊按租户和月份批量写入 `im_%03d_group_messages_YYYYMM`。
- `p2p` 写入 `im_messages`。
- `group` 写入 `im_group_messages`。
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。 5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
6. 失败按统一退避重试 3 次,超过上限先投递 `im.dead.letter`,再由 dead letter consumer 写入 `im_kafka_dead_letters`。 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 已接入的缓存失效消费 ### 11.10 IM Server 已接入的缓存失效消费
+15 -12
View File
@@ -545,27 +545,27 @@ Payload:
1. 用户 A 与用户 B 建立私聊。 1. 用户 A 与用户 B 建立私聊。
2. A 发送带 `x-im-client-mid` 的私聊消息。 2. A 发送带 `x-im-client-mid` 的私聊消息。
3. 查询 Kafka `im.message.persist`。 3. 查询 Kafka `im.message.persist`。
4. 查询租户库 `im_messages`。 4. 查询租户私聊分表 `im_%03d_messages`。
5. A 使用同一 `x-im-client-mid` 重复发送。 5. A 使用同一 `x-im-client-mid` 重复发送。
预期: 预期:
1. A 立即收到服务端 ack 和 `seq_id`。 1. A 立即收到服务端 ack 和 `seq_id`。
2. `seq_id` 为 BIGINT/int64。 2. `seq_id` 为 BIGINT/int64。
3. 消费成功后 `im_messages` 有一条记录。 3. 消费成功后对应租户私聊分表有一条记录。
4. 重复发送不重复落库。 4. 重复发送不重复落库。
5. 消费日志 succeeded。 5. 消费日志 succeeded。
### TC-KAFKA-014 群聊消息异步落库 ### TC-KAFKA-014 群聊消息异步落库
目标:验证群消息写入 `im_group_messages`,不混入私聊表。 目标:验证群消息写入租户月度群消息分表,不混入私聊表。
步骤: 步骤:
1. 创建群并加入至少两个成员。 1. 创建群并加入至少两个成员。
2. 成员发送群消息。 2. 成员发送群消息。
3. 查询 `im_group_messages`。 3. 查询 `im_%03d_group_messages_YYYYMM`。
4. 查询 `im_messages`。 4. 查询 `im_%03d_messages`。
5. 重连客户端按 Topic seq 拉取消息。 5. 重连客户端按 Topic seq 拉取消息。
预期: 预期:
@@ -574,24 +574,27 @@ Payload:
2. `seq_id` 单调增长,类型为 BIGINT/int64。 2. `seq_id` 单调增长,类型为 BIGINT/int64。
3. 客户端补偿拉取成功。 3. 客户端补偿拉取成功。
4. 24 小时热消息可进入 Redis 热缓存。 4. 24 小时热消息可进入 Redis 热缓存。
5. 私聊与群聊分别由 `im.message.persist`、`im.group.message.persist` 消费。
### TC-KAFKA-015 消息消费失败重试 ### TC-KAFKA-015 消息消费失败重试
目标:验证 `im.message.persist` 消费失败可重试并最终成功。 目标:验证私聊和群聊批量落库失败后均按“逐条降级、重试 3 次、死信后提交 offset”处理。
步骤: 步骤:
1. 临时制造租户库写入失败,如锁表或停止库连接。 1. 临时制造租户库写入失败,如锁表或停止库连接。
2. 发送消息。 2. 在批次窗口内连续发送多条私聊和群聊消息。
3. 恢复数据库。 3. 恢复数据库,验证逐条重试可成功落库。
4. 等待重试。 4. 再次持续制造单条失败直至超过重试次数。
5. 查询 `im_kafka_consume_log`、`im_kafka_dead_letters` 和 `im.dead.letter`。
预期: 预期:
1. `im_kafka_consume_log.retry_count` 增加。 1. `im_kafka_consume_log.retry_count` 增加。
2. 未超过重试上限时不进入死信。 2. 批量失败后降级为单条写入,每条最多执行 4 次(首次加 3 次重试)。
3. 数据库恢复后消息最终落库。 3. 未超过重试上限时不进入死信,数据库恢复后消息最终落库并标记 succeeded。
4. 状态变为 succeeded。 4. 持续失败的单条消息写入死信表并投递死信 Topic。
5. 只有批内全部消息达到 succeeded/dead 终态后才提交 offset,不能跳过失败消息。
### TC-KAFKA-016 文件上传完成事件 ### TC-KAFKA-016 文件上传完成事件
@@ -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`,方便排查误入库问题。
+144 -65
View File
@@ -154,14 +154,20 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。
资源不存在类错误统一语义(2026-09-29 修订):
- 好友申请 `accept/reject` 在申请不存在时统一返回 `404/20004 RESOURCE_NOT_FOUND`,两个入口语义一致。
- 群类接口(`DELETE /v1/im/groups/{topic}`、`POST /v1/im/groups/{topic}/leave`、`POST /v1/im/groups/{topic}/qrcodes`、`PUT /v1/im/groups/{topic}/members/me/profile`、`GET/PATCH /v1/im/groups/{topic}/settings` 等经群成员校验的路径)在群不存在时返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散时返回 `410/30004 GROUP_DISSOLVED`;群存在但当前用户非成员仍是 `403/30005 PERMISSION_DENIED`。
- 服务端对不存在的资源不再返回 5xx 或网关错误。
### 0.5 WebSocket 建连、登录和恢复顺序 ### 0.5 WebSocket 建连、登录和恢复顺序
#### 0.5.1 现有服务端可直接执行的冷启动流程 #### 0.5.1 现有服务端可直接执行的冷启动流程
```text ```text
读取客户端预置的 host 和普通 API Key 读取客户端预置的 host 和普通 API Key
-> 用户输入/客户端读取 tenant_code -> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 -> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
-> 带 API Key 完成 WebSocket Upgrade -> 带 API Key 完成 WebSocket Upgrade
-> hi(tenant_code) -> hi(tenant_code)
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 -> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
@@ -173,7 +179,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
-> 进入可发送状态 -> 进入可发送状态
``` ```
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 当前服务端已提供 `/v1/im/tenants/resolve`,用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不把它作为必选前置校验。企业是否存在、是否可用必须在 `{hi}`、登录和注册链路中最终校验。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
`hi` 请求: `hi` 请求:
@@ -236,6 +242,8 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} {"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}
``` ```
**向上翻页(加载更早历史)最终参数约定(后端已确认,2026-09-29)**:复用同一 `get data`,最终参数为 `before`(排他上界,返回 `seq < before`),**没有 REST cursor 语义**。聊天页滚动到顶部时发 `data:{"before":<当前已加载最小 seq>,"limit":50}`(`since` 可省略,默认 0)。服务端返回该锚点之前**最接近 `before` 的 `limit` 条**,`{data}` 按 `seq` 从大到小下发(首条即锚点前最新一条);收到 `ctrl 204 no content`(`params.what=="data"`)表示已到会话顶部。`data` 参数全集:`since`(含下界)、`before`(排他上界)、`limit`、`ranges`。P2P 与群聊(含热缓存 fallback)语义一致。REST `GET /v1/im/conversations/{topic}/messages` 当前为空占位(见 4.21.1),不得用于历史分页。
订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。
##### 0.5.1.1 WebSocket 心跳与在线状态 ##### 0.5.1.1 WebSocket 心跳与在线状态
@@ -322,11 +330,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | | 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` |
| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | | Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | | 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | | 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
**注册 `username` 规则(服务端强校验,2026-10-14 确认)**:3~10 个字符,仅允许小写字母 `a-z` 和数字 `0-9`;不允许大写、下划线、点、汉字及任何符号。不传 `username` 只传 `phone` 时服务端自动生成随机用户名(此时 `secret` 使用 `base64(手机号:密码)`)。违规表现:过短返回 `422 policy violation, params.what=auth`(本次联调实测,用户名 `zz` 即此情形);超长或含非法字符返回 `422, params.what=username, reason=USERNAME_INVALID_FORMAT`。最小长度来自部署配置 `basic` 认证的 `min_login_length`(当前部署模板为 3),服务端代码上限为 10。
##### 0.5.4.2 启动订阅、会话列表和增量恢复 ##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 | | 场景 | 客户端发送 | 服务端返回示例 |
@@ -343,6 +353,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 类型 | 客户端发送 | 服务端返回/下行示例 | | 类型 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- | | --- | --- | --- |
| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | | 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` |
| 引用回复文本 | `{"pub":{"id":"pub-reply-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_reply_1","x-im-reply":{"seq":128,"type":"text","text":"今天晚上一起吃饭吗?","from":"usr..."}},"content":{"text":"好啊"}}}` | `x-im-reply` 必须保留在 `head` 中;服务端不解析业务语义,入库、实时下行和历史读取均原样返回 |
| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | | 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` |
| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | | 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` |
| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | | 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` |
@@ -361,7 +372,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 | | 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 | | `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 |
| `system_action` | 群邀请确认说明、审批入口、群设置入口、可点击人名或群名提示 | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_invite_confirm_enabled","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_invite_confirm_enabled","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[{"type":"open_group_invite_settings","label":"群聊邀请确认","target_id":"grp..."}]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染 | | `system_action` | 审批入口、群设置入口、可点击人名或群名提示;仅在当前消息对所有接收者都可操作时下发 `actions` | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_member_joined","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_member_joined","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染。若 `actions` 为空,不得将普通文案渲染为可点击入口 |
`content` 字段约定: `content` 字段约定:
@@ -371,7 +382,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` | | `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` |
| `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 | | `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 |
| `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 | | `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 |
| `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作 | | `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作。群邀请确认开关、全员禁言开关、成员私信权限开关等设置变更公告面向全员广播,服务端不下发仅管理员可操作的 `actions`;客户端不得仅凭模板名高亮入口。若历史消息或兼容字段携带 `min_role`,客户端必须按当前成员角色过滤,角色不足时忽略该 action |
| `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 | | `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 |
`actors[].click` 和 `actions[]` 的 `type` 首版白名单: `actors[].click` 和 `actions[]` 的 `type` 首版白名单:
@@ -380,7 +391,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `user_profile` | `target_id` | 打开用户资料页 | | `user_profile` | `target_id` | 打开用户资料页 |
| `group_profile` | `target_id` | 打开群资料页 | | `group_profile` | `target_id` | 打开群资料页 |
| `open_group_invite_settings` | `target_id` | 打开群邀请确认设置 | | `open_group_invite_settings` | `target_id` | 打开群邀请确认设置;仅用于明确发给管理员/群主或全员都可执行的入口,不用于全员广播的开关结果公告 |
| `open_group_join_requests` | `target_id` | 打开群入群审批列表 | | `open_group_join_requests` | `target_id` | 打开群入群审批列表 |
| `open_message` | `topic,seq` | 定位到指定消息 | | `open_message` | `topic,seq` | 定位到指定消息 |
@@ -397,6 +408,14 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。 `kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。
**单聊已读/已收回执协议约定(服务端已验证,2026-09-29):**
1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。
2. **离线补发形态**:接收方离线时收到 `{"info":{"topic":"me","src":"p2p...","from":"usr...","what":"recv","seq":12}}`,即 `topic` 固定为 `me`、`src` 才是话题名。客户端必须同时兼容上述两种形态。
3. **序号单调递增**:服务端丢弃 `seq` 不大于当前水位的回执(`read`、`recv` 各自独立判断),`read` 回执会同步抬升 `recv`,恒有 `read ≥ recv`。客户端渲染一律取服务端最大游标,禁止用本地计数覆盖服务端值。
4. **断线重连恢复**:`sub`/`get desc` 响应的 `desc` 中包含 `seq`、`read`、`recv`、`clear` 四个游标;`get {"what":"sub"}` 也返回各成员的 `read/recv`。重连后以这些游标恢复本地状态,不要依赖进程内存。
5. **换设备登录**:回执水位按「用户 × 话题」在服务端持久化,与设备无关;新设备订阅后 `desc` 同时返回自己与对端的最新 `read/recv`,可完整恢复对方"已读/已收"状态。发送者自身的 `read/recv` 由服务端自动推进到其最新发出的 `seq`。
##### 0.5.4.5 Topic 资料、个人群设置和标签 ##### 0.5.4.5 Topic 资料、个人群设置和标签
| 场景 | 客户端发送 | 服务端返回/下行示例 | | 场景 | 客户端发送 | 服务端返回/下行示例 |
@@ -438,7 +457,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"event": "group.member.joined", "event": "group.member.joined",
"payload": { "payload": {
"topic": "grp_xxx", "topic": "grp_xxx",
"user_id": "usr_xxx" "user_id": "usr_xxx",
"member_sync": "single",
"visibility": "all_members"
}, },
"ts": "2026-08-25T10:00:00Z" "ts": "2026-08-25T10:00:00Z"
} }
@@ -456,7 +477,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 | | 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 | | 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
| 单聊消息变更脏标记 | P2P 双方在线会话 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"message.mutation","payload":{"topic":"p2p...","seq":12,"version":2,"event":"message.recalled"}}}` | 当前未订阅该 P2P 也要标记会话脏,并调用 `message-mutations` 做补偿同步 | | 单聊消息变更脏标记 | P2P 双方在线会话 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"message.mutation","payload":{"topic":"p2p...","seq":12,"version":2,"event":"message.recalled"}}}` | 当前未订阅该 P2P 也要标记会话脏,并调用 `message-mutations` 做补偿同步 |
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 | | 成员加入 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"user_id":"usr_xxx","member_sync":"single","visibility":"all_members"}}}` | 全体成员可见;调用 `GET /v1/im/groups/{topic}/members/{user_id}` 刷新单个成员。加入事件同时写入群消息,离线后可按 Topic seq 补拉 |
| 成员退出/移除、角色或禁言变化 | 群主、管理员和被操作人 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.left","payload":{"user_id":"usr_xxx","member_sync":"remove","visibility":"admins_and_affected"}}}` | 普通成员不可见;`single` 拉取单成员,`remove` 本地删除,`full` 重新分页拉取全部成员 |
| 其他群业务变化 | 按具体事件授权范围 | `info.what="group"` | 刷新群详情、入群申请、公告或治理权限 |
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 | | 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 | | 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
@@ -526,7 +549,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 | | `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 | | `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 |
| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 | | `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id,caller_display,expires_at` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 | | `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | | `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | | `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
@@ -580,6 +603,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
}, },
"sound": "default", "sound": "default",
"badge": "+1", "badge": "+1",
"category": "im_call",
"content-available": 1,
"extras": { "extras": {
"type": "call_invite", "type": "call_invite",
"tenant_code": "default", "tenant_code": "default",
@@ -587,18 +612,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"seq": "30", "seq": "30",
"call_id": "call_01K...", "call_id": "call_01K...",
"call_type": "audio", "call_type": "audio",
"caller_id": "usr_caller" "caller_id": "usr_caller",
"caller_display": "张三",
"expires_at": "2026-09-18T10:00:30Z"
} }
} }
}, },
"options": { "options": {
"time_to_live": 60, "time_to_live": 30,
"apns_production": false, "apns_production": false,
"classification": 1 "classification": 1
} }
} }
``` ```
来电推送必须走普通 APNs/极光 alert 通知,不使用 PushKit VoIP topic;iOS `notification.ios.category` 固定为 `im_call`,用于客户端通知按钮,同时带 `content-available: 1` 唤醒后台 App 拉取 `topic/seq` 并启动 Live Activity。`time_to_live` 和 `expires_at` 以 30 秒左右短有效期为准,过期后服务端不再投递。
#### 0.5.5 Long Polling 与文件基础示例 #### 0.5.5 Long Polling 与文件基础示例
##### 0.5.5.1 WebSocket 与文件基础接口 ##### 0.5.5.1 WebSocket 与文件基础接口
@@ -607,10 +636,17 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` | | `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` |
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | | WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | | `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | | `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;聊天附件必须带 `topic=<消息所在会话 topic>`(`p2p.../grp...`),可选 `purpose=chat_file`、`id=<幂等ID>` | `{"ctrl":{"code":200,"params":{"expires":"2026-10-13T06:21:20Z","file_id":"s6yhMyiOfaU","url":"/v0/file/s/s6yhMyiOfaU.txt"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 | | `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-`;URL 可带文件扩展名(服务端自动剥离) | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 |
**文件上传/下载鉴权与回收规则(已实现,客户端必须遵守):**
1. 上传聊天附件(图片/视频/语音/文件)必须携带 `topic=<消息将要发送到的会话 topic>`。服务端校验上传者是该 `p2p`/`grp` 会话的订阅成员(`modewant`/`modegiven` 含读权限),不是成员返回 `403 permission denied`;校验通过则在服务端建立「文件-会话」引用,该会话全体成员凭有效 Token 即可下载。
2. 不带 `topic` 的上传只有上传者本人可下载。任何文件若一直未被任何会话/消息引用,服务端在创建 1 小时后由后台任务物理回收;上传后必须及时发送消息或携带 `topic`,否则对端将拿到 404。用户头像/资料类文件不受此限:客户端设置头像(PATCH `avatar_file_id`)时服务端自动写入文件引用,该文件不会被回收。
3. 下载放行范围(2026-10-14 更新):上传者本人、文件所属会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、**用户头像/资料文件(对任何登录用户放行,2026-10-14 修复:此前仅上传者可读,群成员拉取他人头像 403)**。非授权返回 `403 permission denied`(即 `FILE_FORBIDDEN`),客户端不自动重试。
4. `avatar_url` 统一回填规则(2026-10-14 起,所有返回用户/群摘要的接口一致):`avatar_file_id` 为完整 URL 时 `avatar_url` 原样返回;为裸 file_id 时返回 `"/v0/file/s/{file_id}"`(可直接下载,无需客户端拼接);头像为空时回落默认头像 URL(男/女按性别,`avatar_file_id` 与 `avatar_url` 同值)。涉及接口:`/v1/im/groups/{topic}/members`、`/members/{userId}/profile`、`/v1/im/users/{id}/profile`、`/v1/im/me/profile`、转发目标列表等。
### 0.6 Topic 与产品消息 ### 0.6 Topic 与产品消息
@@ -637,6 +673,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 `x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
引用回复使用 `head.x-im-reply` 承载客户端生成的轻量快照,字段为 `seq:number`、`type:string`、`text:string`,可选 `from:string`。该对象必须放在 `head`,不能放入 `content`;服务端只负责把未知 `head` 字段随消息存储、实时转发、历史拉取和 HTTP 查询一起原样返回,不对被引用消息做二次查询或语义校验。客户端兼容历史服务端将 `x-im-reply` 作为 JSON 字符串返回的情况,但新版本客户端发送时必须使用 JSON 对象。
消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。 消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。
### 0.7 HTTP 幂等、重试和安全 ### 0.7 HTTP 幂等、重试和安全
@@ -653,22 +691,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/企业码登录.png" alt="企业码登录" width="360"> <img src="images/企业码登录.png" alt="企业码登录" width="360">
**状态:企业解析接口与 WebSocket 租户握手均已实现。** **状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
**当前系统的执行方式:** **当前系统的执行方式:**
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 - 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 - 客户端从构建/环境配置读取 `host` 和普通 API Key,直接使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket;当前登录/注册主流程不要求先调用 `POST /v1/im/tenants/resolve`。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 - Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"登录/注册页确定的 tenant_code"}}`。
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 - 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 - 当前失败表现:企业码超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。登录/注册页统一展示“企业不存在或不可用”。
**企业解析接口:** **企业解析接口:**
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](IM-个人无企业码注册前后端对接说明.md)。
成功响应示例: 成功响应示例:
@@ -800,13 +839,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** **状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 - 前端动作:企业码、账号、密码和协议勾选在同一个登录页完成;个人用户可不填企业码,企业用户填写企业码。账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 - 协议:点击登录后先按页面企业码确定 `tenant_code` 并发送 WebSocket `{hi}`;`hi` 成功后发送 `{login scheme:"basic"}`,`secret=base64(username:password)`。Base64 不是加密,必须通过 WSS 传输。同一次登录不得先登录再切换企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 - 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 - 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 - Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 - 错误:企业不存在、停用或不允许登录统一显示“企业不存在或不可用”;`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并留在登录页重新选择个人/企业身份;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
登录成功示例: 登录成功示例:
@@ -846,6 +885,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。** **状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 - 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 - 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。 - 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。 - 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
@@ -864,19 +904,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
用户名方式: 用户名方式:
```json ```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}} {"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}}
``` ```
手机号验证方式: 手机号验证方式:
```json ```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} {"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
``` ```
仅手机号注册方式: 仅手机号注册方式:
```json ```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} {"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","registration_type":1,"secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
``` ```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 - 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 - 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
@@ -912,7 +952,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 - 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`;注销账号使用 `{"scene":"delete_account","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 - 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 - 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
@@ -938,7 +978,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 - 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。
- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 - 前端只读展示,不缓存原始证件信息,不提供直接修改接口。
- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 - 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,Body `{"reason":"...","attachments":[]}`(`reason` 必填、≤500 字;`attachments` 最多 9 个 file_id),Bearer 即可,当前无需二次身份验证;不复用本页保存按钮。
### 1.10 实名认证(安卓).png ### 1.10 实名认证(安卓).png
@@ -976,7 +1016,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` | | `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` | | `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` | | `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` | | `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
@@ -988,7 +1028,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | | `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | | `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` | | `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
| `GET /v1/im/app/releases/latest?platform=ios\|android&current_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"ios","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"https://apps.apple.com/app/id...","release_notes":"修复已知问题。","remind_after_seconds":86400}` | | `GET /v1/im/app/releases/latest?platform=ios\|android\|web&current_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"web","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"","release_notes":"修复已知问题。","remind_after_seconds":86400}` |
## 2. 消息首页、全局导航与系统通知 ## 2. 消息首页、全局导航与系统通知
@@ -1042,8 +1082,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:需改造。** **状态:需改造。**
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 - `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
- `summary` 为服务端生成的可直接展示文案(如「对方已同意你的好友申请」「对方已婉拒你的好友申请」),前端直接渲染,无需再做状态码到文案的映射;`action` 读路径统一归一为 `{type,target_id,payload,...原始键}`。
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 - 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;重复已读返回首次持久化的 `read_at`,不回显本次提交值。跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例 ### 2.6 接口示例
@@ -1062,7 +1103,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | | `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","target_id":"usr...","payload":{"request_id":"frq_1"},"request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
## 3. 单聊会话、会话建立与消息状态 ## 3. 单聊会话、会话建立与消息状态
@@ -1121,11 +1162,14 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/双方沟通状态.png" alt="双方沟通状态" width="360"> <img src="images/双方沟通状态.png" alt="双方沟通状态" width="360">
**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** **状态:上传带 `topic` 与下载按会话授权已在服务端实现;客户端按本节接入。**
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 - 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传(**必须带 `topic=<该会话 topic>`**,服务端校验成员资格并自动建立文件-会话引用),得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 - `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 - 服务端在上传阶段即建立文件与会话的引用(带 `topic`);消息发布成功后按消息附件引用建立消息级 ACL;文件下载校验会话成员身份与消息可见性。
- 媒体文件(缩略图、原图、视频、语音)一律走同一次上传流程并带同一个 `topic`;缩略图也必须挂到会话,否则对端只能看到占位图。
- 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
### 3.7 个人对话—拉黑状态/已读状态.png ### 3.7 个人对话—拉黑状态/已读状态.png
@@ -1279,7 +1323,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:客户端任务管理新增;服务端上传需改造。** **状态:客户端任务管理新增;服务端上传需改造。**
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。 - 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;`upload_id` 仅在单次上传任务内有效(进度上报与本地去重用)。
- 重试即新建上传任务:`progress` 归零、生成新 `upload_id`,**整文件重传** `POST /v0/file/u/`,不做分片/断点续传;重传成功后消息体 `file_id` 等字段全部取最新一次上传响应。
- 自动重试采用指数退避且不超过 3 次,之后转人工点按重试;App 冷启动扫描本地 `failed` 任务队列重新上传。
- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。 - 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。
- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。 - 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。
@@ -1395,10 +1441,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` | | `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` |
| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | | `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | | `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | | `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | | `GET /v1/im/conversations/{topic}/messages` | **服务端当前为空占位**:GET 恒返回空列表,勿用 `?since=&limit=` 调它;历史消息(首屏与向上翻页)一律走 WS `get data` 的 `since`/`before`/`limit`(向上翻页见 0.5.1 约定) | `{"topic":"p2p...","items":[],"next_cursor":"","has_more":false}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` | | `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
@@ -1652,6 +1698,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `mutation_version` 是会话级水位,不是单条消息的 `version`。 - `mutation_version` 是会话级水位,不是单条消息的 `version`。
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。 - 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
- 补偿接口返回的是最新 `limit` 条(默认 50、上限 100)变更的**状态快照**,非严格增量;变更条数超过 `limit` 时更早的变更不再返回。撤回窗口 120 秒、编辑窗口 300 秒,超窗变更无需客户端处理。
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。 - 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。 - 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。 - 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
@@ -1664,7 +1711,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增产品通讯录接口。** **状态:新增产品通讯录接口。**
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online,online_visibility`;好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。 - 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,avatar_url,initial,online,online_visibility`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。
- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 - 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。
- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 - 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1678,6 +1725,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 申请不存在时 `accept/reject` 统一返回 `404/20004 RESOURCE_NOT_FOUND`;通用语义见 0.4。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 - 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png ### 5.3 添加好友.png
@@ -1726,7 +1774,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。 - `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,avatar_url,gender,region,public_account,signature,relationship,capabilities`(`avatar_url` 为可直接下载的头像地址,统一回填规则见 0.5.5.1 第 4 条);`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 - 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1795,9 +1843,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/黑名单提示.png" alt="黑名单提示" width="360"> <img src="images/黑名单提示.png" alt="黑名单提示" width="360">
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** **状态:图片文件名与画面语义不一致;按“清除聊天记录”实施。服务端已实现并实测(2026-10-09),剩余前端改造。**
- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 - 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 服务端现状(2026-10-09 测试环境实测通过):REST 接口仅写 `im_dellog` 本人区间并递增 `deleted_id`,不改动消息行、对方视角不受影响,本人其他设备经 `deleted_id` 同步感知清空;WS `{del}` 语义相同,两条路径二选一即可。对非成员或不存在会话返回 403 FORBIDDEN。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1823,7 +1872,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | | `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | | `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | | `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | | `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
@@ -1932,6 +1981,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 - `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。
- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 - 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。
- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 - 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.11 群资料.png ### 6.11 群资料.png
@@ -1961,6 +2011,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 - `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 - 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。
- 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。 - 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。
@@ -2029,6 +2080,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 - `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群主/管理员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
@@ -2041,6 +2093,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 - 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。
- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 - 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。
- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 - 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;已解散重复解散返回 `410/30004 GROUP_DISSOLVED`;非群主返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.18 设置群管理员.png ### 6.18 设置群管理员.png
@@ -2147,7 +2200,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。 - 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。
- 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 - 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例 ### 6.29 接口示例
@@ -2173,8 +2226,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | | `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | | `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | | `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` |
| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | | `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | | `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | | `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
@@ -2286,10 +2341,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/文件-下载.png" alt="文件-下载" width="360"> <img src="images/文件-下载.png" alt="文件-下载" width="360">
**状态:底层下载已实现租户校验;资源 ACL 需改造。** **状态:下载授权已实现(按上传者/会话成员/消息引用/群成员/好友/收藏/头像放行)。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 - 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 服务端按文件 ACL 放行:上传者本人、文件所属 `p2p`/`grp` 会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、用户头像/资料文件(对任何登录用户放行,2026-10-14 更新);非授权返回 `403 permission denied`(`FILE_FORBIDDEN`)。对象存储模式下鉴权通过返回 302 短期签名 URL。
- 头像排障口径:群成员/资料接口返回的 `avatar_url` 可直接下载;若对端头像显示占位图且下载 `404`,说明该头像文件从未被引用或已被回收,让对方重新上传并设置头像即可;`403` 为非登录态(Token 失效),重新登录。
- 前提:发送方上传附件时带了 `topic`(见 0.5.5.1)。若对端下载一直 404,说明该文件从未关联会话/消息且已被 1 小时回收任务删除,应让发送方重新上传并携带 `topic`。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
### 7.10 文件-下载中.png ### 7.10 文件-下载中.png
@@ -2320,6 +2377,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 - `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 - 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 - 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
- 搜索匹配字段包括文本正文、`content.text/txt/content/title/summary/name`、文件名、链接标题/摘要、卡片/转发快照等可展示文本;排序按 `seq desc`,`next_cursor` 继续向更早消息翻页。
### 7.13 查找聊天记录-文件.png ### 7.13 查找聊天记录-文件.png
@@ -2400,11 +2458,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/对方状态.png" alt="对方状态" width="360"> <img src="images/对方状态.png" alt="对方状态" width="360">
**状态:在线邀请已实现;Push 与产品鉴权需改造。** **状态:在线邀请、短有效期普通 Push 与产品鉴权已实现。**
- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 - 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。
- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 - 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。
- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 - 后台 Push 只携带 `callId,topic,seq,caller_id,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;iOS 来电 Push 使用普通 alert,`category=im_call`,`content-available=1`,短 TTL 约 30 秒,不使用 PushKit VoIP。
- 被叫在未发送 `ringing/accept` 前也可以直接发送 `hang-up`,服务端按 `topic+seq+callId` 校验后立即结束主叫侧振铃,并把原通话消息替换为 `declined`;主叫主动取消则替换为 `missed`。
### 8.3 语音通话-对方待接受状态.png ### 8.3 语音通话-对方待接受状态.png
@@ -2912,8 +2971,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`;`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,avatar_url,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`(乐观锁必填,`version` 取自最近一次 `GET /v1/im/me/profile` 的返回值);缺失或 `<=0` 返回 `400/20000 MALFORMED_REQUEST`,与当前版本不一致返回 `409/20009 VERSION_CONFLICT`(重新 GET 取最新 `version` 后重试即可);服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。 - 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
@@ -2925,7 +2984,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 - `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 - 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突(`409/20009`)先拉最新资料再由用户确认。
### 10.4 出生日期.png ### 10.4 出生日期.png
@@ -2990,7 +3049,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。 完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
- `GET /v1/im/app/releases/latest?platform=ios|android&current_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。 - `GET /v1/im/app/releases/latest?platform=ios|android|web&current_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。 - 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。 - Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。 - 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
@@ -3032,7 +3091,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。 - `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,二者返回 `enforced=true`;`payment_guard` 仍为预留项,返回 `enforced=false`。
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。 - 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。 - 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 - 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
@@ -3053,7 +3112,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 - 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。 - 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,当前已覆盖夜间音视频呼叫发起;前端隐藏只是体验优化,不能作为安全控制。
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 - 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
### 10.17 关闭青少年模式(安卓).png ### 10.17 关闭青少年模式(安卓).png
@@ -3118,14 +3177,14 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | | `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | | `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` | | `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | | `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` | | `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
@@ -3134,9 +3193,29 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/security/realname/appeals` | `{"reason":"证件变更需要更正实名信息","attachments":["file_1"]}` | `{"appeal_id":"rna_1","reason":"...","attachments":["file_1"],"status":"pending"}`;当前无需二次身份验证(如后续启用,将复用 1.7 的 action_token 机制并在此处更新) |
| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | | `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` |
| `POST /v1/im/me/password` | `{"old_password":"OldPass123","new_password":"NewPass123"}` | `{"status":"ok"}`;旧密码错误返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`;新密码不满足策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}` |
| `POST /v1/im/security/challenges` 注销账号发码 | Header `Idempotency-Key`;Body `{"scene":"delete_account","channel":"sms"}` | `{"challenge_id":"sec_chg_9","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges/{id}/verify` 注销账号验证 | `{"code":"123456"}` | `{"action_token":"del_token","action":"delete_account","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/me/deletion` | Header `X-Action-Token: del_token`;Body `{}` | `{"status":"ok"}` |
#### 10.22.2 账号安全:修改密码与注销账号
**修改密码 `POST /v1/im/me/password`(已实现):**
- 已登录(Bearer)用户提交 `old_password`、`new_password`;两者必填,缺一返回 `400 MALFORMED_REQUEST`。
- 服务端先用旧密码走 `basic` 认证校验,失败返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`,不泄露账号状态。
- 新密码不满足密码策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}`。
- 成功后服务端递增 `auth_version` 并撤销该用户全部在线 Session(广播 `password_changed`);包括当前请求在内的所有旧 Token 全部失效,客户端收到成功响应后必须清理本租户凭证并跳转登录页。
**注销账号 `POST /v1/im/me/deletion`(已实现):**
- 两步挑战复用 1.7 敏感操作基建:`POST /v1/im/security/challenges`(Body `{"scene":"delete_account","channel":"sms"}`,发码到账号绑定手机号)→ `POST /v1/im/security/challenges/{id}/verify`(返回一次性 `action_token`)→ `POST /v1/im/me/deletion`(Header `X-Action-Token` 携带)。
- 服务端配置 `permanent_accounts=true` 时拒绝注销:HTTP `403` `{"code":30040,"reason":"FORBIDDEN","message":"account deletion is disabled"}`。
- 执行成功为**软删除**:撤销全部认证记录(含登录密码)、踢出所有在线 Session(`reason=user_deleted`)、退出并停用其拥有的群与 P2P 会话、向关注该用户的好友下发 `gone` 在线状态;账号不再可登录。客户端收到成功响应后清理本地数据并返回企业入口。
- 挑战阶段限流与错误码同 1.7(`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`);action token 缺失/无效返回 HTTP `403` `{"code":30042,"reason":"ACTION_TOKEN_INVALID"}`。
## 11. 反诈安全、举报与意见反馈 ## 11. 反诈安全、举报与意见反馈
+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 已按职责边界关闭或降级。
@@ -2,6 +2,9 @@
日期:2026-09-13 日期:2026-09-13
> 006 商户、2000 账号、两台压测机的可执行场景与统计口径见
> [IM-006商户双机消息压力测试方案](./IM-006商户双机消息压力测试方案.md)。本文保留为通用阶梯与指标说明。
## 1. 目标 ## 1. 目标
验证 IM Server 使用 Redis realtime fanout 后,在多 Pod、多端登录、群消息和群业务事件场景下的实时投递能力。 验证 IM Server 使用 Redis realtime fanout 后,在多 Pod、多端登录、群消息和群业务事件场景下的实时投递能力。
+36 -33
View File
@@ -110,22 +110,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications (
INDEX idx_im_realname_id_hash (id_number_hash) INDEX idx_im_realname_id_hash (id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_action_tokens ( DROP TABLE IF EXISTS tinode.im_action_tokens;
id BIGINT NOT NULL AUTO_INCREMENT,
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 (token_id),
INDEX idx_im_action_tokens_user_action (user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings ( CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -164,23 +149,9 @@ CREATE TABLE IF NOT EXISTS tinode.im_system_notifications (
INDEX idx_im_system_notifications_inbox (user_id, id DESC) INDEX idx_im_system_notifications_inbox (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_http_idempotency ( -- HTTP write idempotency is Redis-backed in the latest code.
id BIGINT NOT NULL AUTO_INCREMENT, DROP TABLE IF EXISTS meeting_im.im_http_idempotency;
user_id BIGINT NOT NULL, DROP TABLE IF EXISTS tinode.im_http_idempotency;
method VARCHAR(8) NOT NULL,
route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing',
response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB,
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 (id),
UNIQUE INDEX uk_im_http_idempotency_scope (user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions ( CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1031,5 +1002,37 @@ INSERT INTO tinode.im_feedback_config(id, categories, max_length, attachment_pol
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at = updated_at; ON DUPLICATE KEY UPDATE updated_at = updated_at;
-- ============================================================
-- Shared database runtime fixes for meeting_im.
-- These are required after moving default/system tenants into the shared DB.
-- ============================================================
CREATE TABLE IF NOT EXISTS meeting_im.im_user_account_sequences (
tenant_id BIGINT NOT NULL,
id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
ALTER TABLE meeting_im.im_user_account_sequences
DROP PRIMARY KEY,
ADD PRIMARY KEY (tenant_id, id);
INSERT INTO meeting_im.im_user_account_sequences(tenant_id, id, next_value)
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM meeting_im.im_users
WHERE tenant_id > 0
GROUP BY tenant_id
ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value));
INSERT IGNORE INTO meeting_im.im_user_account_sequences(tenant_id, id, next_value)
SELECT id, 1, 999
FROM hotgo.im_tenant
WHERE state = 1;
DROP TABLE IF EXISTS meeting_im.im_login_lockouts;
-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go. -- Current MySQL adapter schema version expected by server/db/mysql/adapter.go.
UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version'; UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version';
+10 -8
View File
@@ -121,19 +121,21 @@
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 创建群 | 群成员 | `info.what="group"`, `event="group.created"` | `topic`、群基础信息 | 除操作人外写入 | | 创建群 | 群成员 | `info.what="group"`, `event="group.created"` | `topic`、群基础信息 | 除操作人外写入 |
| 更新群设置 | 群成员 | `event="group.settings.updated"` | `topic` | 除操作人外写入 | | 更新群设置 | 群成员 | `event="group.settings.updated"` | `topic` | 除操作人外写入 |
| 成员资料/权限更新 | 群成员 | `event="group.member.updated"` | `user_id` | 除操作人外写入 | | 成员资料/权限更新 | 群主、管理员、本人 | `event="group.member.updated"` | `user_id`、`member_sync=single` | 不写群系统消息 |
| 成员加入 | 群成员 | `event="group.member.joined"` | `user_id`、`topic` | 除操作人外写入 | | 成员加入 | 群成员 | `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.created"` | `request_id`、`applicant_user_id`、`topic` | 普通成员不可见 |
| 入群申请处理 | 群成员 | `event="group.join_request.accepted/rejected"` | `request_id`、`status`、`topic` | 除操作人外写入 | | 入群申请处理 | 群主、管理员、申请人 | `event="group.join_request.accepted/rejected"` | `request_id`、`status`、`topic` | 普通成员不可见;接受时另发全群加入消息 |
| 群公告更新 | 群成员 | `event="group.announcement.updated"` | `topic`、公告信息 | 除操作人外写入 | | 群公告更新 | 群成员 | `event="group.announcement.updated"` | `topic`、公告信息 | 除操作人外写入 |
| 群禁言更新 | 群成员 | `event="group.mute.updated"` | `topic`、禁言信息 | 除操作人外写入 | | 群禁言更新 | 群主、管理员、被操作人 | `event="group.mute.updated"` | `topic`、禁言信息、`member_sync` | 普通成员不可见 |
| 成员离群 | 群成员 | `event="group.member.left"` | `user_id` | 除操作人外写入 | | 成员离群/移除 | 群主、管理员、离群成员 | `event="group.member.left"` | `user_id`、`member_sync=remove` | 普通成员不可见 |
| 群角色更新 | 群成员 | `event="group.role.updated"` | `user_id`、角色信息 | 除操作人外写入 | | 群角色更新 | 群主、管理员、被操作人 | `event="group.role.updated"` | `user_id`、角色信息、`member_sync` | 普通成员不可见 |
| 群主转让 | 群成员 | `event="group.owner.transferred"` | `previous_owner`、`new_owner` | 除操作人外写入 | | 群主转让 | 群成员 | `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 待处理数。 - 入群申请变化:管理员刷新入群申请列表和群 summary 待处理数。
- 群公告变化:刷新公告区。 - 群公告变化:刷新公告区。
- 成员加入/离开:刷新群成员数、成员列表和会话系统提示。 - 成员加入/离开:刷新群成员数、成员列表和会话系统提示。
+1 -1
View File
@@ -123,7 +123,7 @@ HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \
| 探针 | 路径 | 初始延迟 | 周期 | 超时 | 失败次数 | | 探针 | 路径 | 初始延迟 | 周期 | 超时 | 失败次数 |
|---|---|---:|---:|---:|---:| |---|---|---:|---:|---:|---:|
| im-server readinessProbe | `/healthz` | 15s | 10s | 3s | 3 | | im-server readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-server livenessProbe | `/healthz` | 30s | 30s | 3s | 3 | | im-server livenessProbe | `/livez` | 15s | 20s | 3s | 3 |
| im-manager readinessProbe | `/healthz` | 15s | 10s | 3s | 3 | | im-manager readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-manager livenessProbe | `/healthz` | 30s | 30s | 3s | 3 | | im-manager livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
+2 -17
View File
@@ -244,20 +244,5 @@ CREATE TABLE IF NOT EXISTS im_group_admin_logs (
INDEX idx_im_group_admin_logs_object (object_type, object_id) INDEX idx_im_group_admin_logs_object (object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 11. Group operation idempotency. -- 11. Group operation idempotency is handled by Redis-backed HTTP idempotency.
-- This can later be replaced by a global HTTP idempotency table. DROP TABLE IF EXISTS im_group_idempotency;
CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0,
result_body JSON,
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_idempotency_request (user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+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:`、空列表回退静态链接)
@@ -23,7 +23,7 @@ Idempotency-Key: <uuid>
测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。 测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。
当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;业务接口层的深夜消息免打扰、内容过滤、支付拦截尚未实际强制执行,响应中的 `restriction_items[].enforced=false` 用于明确表达这一点。后续某条规则真正接入后台策略和业务拦截后,应把对应项改为 `true`。 当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,响应中的这两项 `restriction_items[].enforced=true`。`payment_guard` 当前仍为预留项,`enforced=false`。
## 2. 开启青少年模式 ## 2. 开启青少年模式
@@ -47,13 +47,13 @@ GET /v1/im/settings/youth-mode
"code": "night_quiet", "code": "night_quiet",
"title": "深夜免打扰", "title": "深夜免打扰",
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
"enforced": false "enforced": true
}, },
{ {
"code": "content_filter", "code": "content_filter",
"title": "内容过滤", "title": "内容过滤",
"description": "对不适合青少年的内容进行识别和限制展示。", "description": "对不适合青少年的内容进行识别和限制展示。",
"enforced": false "enforced": true
}, },
{ {
"code": "payment_guard", "code": "payment_guard",
@@ -202,13 +202,13 @@ POST /v1/im/settings/youth-mode/enable
"code": "night_quiet", "code": "night_quiet",
"title": "深夜免打扰", "title": "深夜免打扰",
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
"enforced": false "enforced": true
}, },
{ {
"code": "content_filter", "code": "content_filter",
"title": "内容过滤", "title": "内容过滤",
"description": "对不适合青少年的内容进行识别和限制展示。", "description": "对不适合青少年的内容进行识别和限制展示。",
"enforced": false "enforced": true
}, },
{ {
"code": "payment_guard", "code": "payment_guard",
@@ -0,0 +1,226 @@
# IM R11 Traefik 入口故障运维排查手册
## 1. 目标与已知事实
需要定位 2026-10-04 北京时间 `10:39:30–10:42:30`,即 UTC `02:39:30–02:42:30` 的入口整体中断。
已知现象:
- `api.118im.com/metrics` 先后出现超时、HTTP 521 和 HTTP 503。
- 三个源 IP 的 443 在约 10:40:16 同时 `connection refused`。
- 2000 个 WebSocket 几乎同时断开,A/B 两台压测机均受影响。
- Tinode cluster 采样仍为 2/2 live peers。
- 故障后三个源 IP 返回同一张 `TRAEFIK DEFAULT CERT`,证书 `notBefore` 为 `2026-10-04 10:41:32 +08:00`,与入口恢复窗口重合。
- 运维已确认 `216.236.31.189:6443` 按安全策略不对公网开放,只允许集群网络访问;外部调试机 TCP 超时属于预期行为。
排查过程中不要先重启 Traefik、节点或 k3s,也不要清理容器和日志。先完成只读取证。
## 2. 运维进入方式
优先在控制面节点直接执行 `kubectl`。如果控制面节点没有配置 kubeconfig,k3s 通常可使用:
```bash
sudo k3s kubectl get nodes -o wide
```
普通 Kubernetes 可使用:
```bash
kubectl get nodes -o wide
kubectl cluster-info
```
不建议把 6443 直接向公网完全开放。长期方案应采用 VPN/堡垒机或仅允许固定运维出口 IP。短期也可以让运维直接在控制面节点采集下面的结果。
## 3. 第一优先级:确认 Traefik 是否重启
先找到 Traefik 所在 namespace、工作负载和 Pod:
```bash
kubectl get deploy,ds,sts,pod -A -o wide | grep -i traefik
kubectl get pods -A -l app.kubernetes.io/name=traefik -o wide
kubectl get pods -A -l app=traefik -o wide
```
对每个 Traefik Pod 执行:
```bash
kubectl -n kube-system get pod TRAEFIK_POD -o jsonpath='{.metadata.name}{"\nstarted="}{.status.startTime}{"\nrestarts="}{.status.containerStatuses[*].restartCount}{"\nlastReason="}{.status.containerStatuses[*].lastState.terminated.reason}{"\nlastExit="}{.status.containerStatuses[*].lastState.terminated.exitCode}{"\nlastFinished="}{.status.containerStatuses[*].lastState.terminated.finishedAt}{"\n"}'
kubectl -n kube-system describe pod TRAEFIK_POD
kubectl -n kube-system logs TRAEFIK_POD --all-containers --timestamps --since-time=2026-10-04T02:39:30Z
kubectl -n kube-system logs TRAEFIK_POD --all-containers --previous --timestamps --since-time=2026-10-04T02:39:30Z
```
重点检查:
- `OOMKilled`、exit code 137、liveness/readiness failure。
- `Killing`、`Evicted`、`NodeNotReady`、`Preempted`。
- `Configuration loaded`、provider reconnect、TLS store/default certificate 重建。
- 10:41:32 附近是否有容器启动日志。
如果 Traefik 不在 `kube-system`,将命令中的 namespace 替换为实际值。
## 4. 第二优先级:查滚动发布和配置变更
```bash
kubectl -n kube-system get deploy,ds,rs -l app.kubernetes.io/name=traefik -o wide
kubectl -n kube-system rollout history deploy/traefik
kubectl -n kube-system get deploy traefik -o yaml
kubectl -n kube-system get cm,secret | grep -i traefik
```
若由 Helm 管理:
```bash
helm list -A | grep -i traefik
helm history traefik -n kube-system
helm get values traefik -n kube-system -a
```
若由 ArgoCD 管理:
```bash
kubectl get applications.argoproj.io -A
kubectl get applications.argoproj.io -A -o yaml | grep -n -i -C 5 traefik
kubectl get pods -n argocd -o wide
kubectl logs -n argocd -l app.kubernetes.io/name=argocd-application-controller --all-containers --timestamps --since-time=2026-10-04T02:39:30Z
```
同时检查 CI/CD 平台在北京时间 10:39–10:42 是否执行过 Traefik、Ingress、Service、证书或节点部署。需要记录操作者、commit、同步 revision 和开始/结束时间。
## 5. 第三优先级:节点是否重启或资源耗尽
```bash
kubectl get nodes -o wide
kubectl describe nodes
kubectl get events -A --sort-by=.metadata.creationTimestamp
kubectl get events -A --field-selector type=Warning --sort-by=.metadata.creationTimestamp
```
在承载 Traefik 的节点执行:
```bash
uptime -s
last -x | head -30
sudo journalctl --list-boots
sudo journalctl --since '2026-10-04 10:39:30' --until '2026-10-04 10:42:30' -u k3s -u k3s-agent -u kubelet -u containerd --no-pager
sudo journalctl -k --since '2026-10-04 10:39:30' --until '2026-10-04 10:42:30' --no-pager
sudo dmesg -T | grep -Ei 'oom|killed process|memory cgroup|conntrack|nf_conntrack|link.*down|reset|I/O error'
```
重点判断:
- 节点是否在 10:41 左右重启。
- 内核是否 OOM kill Traefik、containerd 或 k3s。
- 磁盘是否满、只读或发生 I/O error。
- 网卡是否 link down、驱动 reset。
- kubelet 是否因 liveness probe 杀掉 Traefik。
## 6. 第四优先级:Service 与 Endpoint 是否短暂清空
```bash
kubectl get svc,endpoints,endpointslices -A | grep -Ei 'traefik|im-server'
kubectl -n kube-system describe svc traefik
kubectl -n kube-system get endpoints traefik -o yaml
kubectl -n kube-system get endpointslices -l kubernetes.io/service-name=traefik -o yaml
kubectl -n im-server-system get pod,svc,endpoints,endpointslices -o wide
kubectl -n im-server-system describe svc im-server
kubectl -n im-server-system get endpoints im-server -o yaml
kubectl -n im-server-system get endpointslices -l kubernetes.io/service-name=im-server -o yaml
```
检查 10:40–10:41 是否发生:
- Traefik Service 没有可用 endpoint。
- `im-server` 三个 Pod 同时 NotReady,导致上游 503。
- selector、port、targetPort 或 Ingress backend 被 ArgoCD/Helm 临时改写。
- StatefulSet 滚动时 readiness 配置错误,所有 endpoint 被同时摘除。
## 7. 第五优先级:连接数和 conntrack
在入口节点执行:
```bash
sudo ss -lntp | grep -E ':443|:6443'
sudo ss -s
sudo sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
sudo conntrack -S 2>/dev/null || true
sudo nstat -az | grep -Ei 'ListenDrops|ListenOverflows|RetransSegs|InErrors|OutDiscards'
sudo ip -s link
```
如果有节点监控,导出 10:35–10:45 的:
- CPU usage、load、CPU throttling。
- 内存、OOM、working set。
- conntrack 使用率。
- TCP listen overflow、reset、retransmit。
- 网卡吞吐、drop、error。
- Traefik active connection、open connection、5xx、upstream duration。
conntrack 达到 `nf_conntrack_max` 通常更偏向 timeout/drop;本轮观察到短时间 `connection refused`,因此还应优先确认 443 监听进程是否消失或入口规则是否被重建。
## 8. im-server 同期状态
```bash
kubectl -n im-server-system get pods -o wide
kubectl -n im-server-system get sts im-server -o yaml
kubectl -n im-server-system describe sts im-server
for pod in $(kubectl -n im-server-system get pods -l app=im-server -o name); do
kubectl -n im-server-system logs "$pod" --all-containers --timestamps --since-time=2026-10-04T02:39:30Z > "${pod##*/}.log"
kubectl -n im-server-system logs "$pod" --all-containers --previous --timestamps --since-time=2026-10-04T02:39:30Z > "${pod##*/}.previous.log" 2>&1 || true
done
```
如果 `app=im-server` 标签不匹配,先执行 `kubectl -n im-server-system get pods --show-labels`,再替换 selector。
需要确认三个 im-server Pod 的:
- `startTime` 和 `restartCount`。
- readiness/liveness failure。
- 10:40 左右是否仍持续处理 Kafka、cluster RPC 和 WS。
- 是否有 `too many open files`、OOM、accept error、DNS error、MySQL/Kafka timeout。
## 9. Kubernetes API 访问方式
从当前调试机访问三个源 IP 的 443 均正常。运维已确认 `216.236.31.189:6443` 只允许集群网络访问,因此外部连接超时符合既定安全策略,也与 VPN/TUN 无关。
运维应检查控制面节点:
```bash
sudo ss -lntp | grep 6443
sudo nft list ruleset | grep -n -C 3 6443
sudo iptables -S | grep 6443
sudo iptables -t nat -S | grep 6443
```
后续排查采用以下方式之一:
1. 让排查命令直接在控制面节点运行并回传结果包。
2. 如确需远程调试,为固定运维出口 IP 临时增加 6443 allowlist,完成后撤销。
3. 通过堡垒机/VPN 进入控制面网络。
不要为了调试把 6443 对 `0.0.0.0/0` 永久开放。
## 10. 运维需要回传的结果
至少回传以下内容:
1. Traefik Pod YAML、describe、当前日志和 previous 日志。
2. Traefik Deployment/DaemonSet、ReplicaSet 和 rollout history。
3. 10:39:30–10:42:30 的 k3s/kubelet/containerd/kernel journal。
4. 全集群 Warning Event、节点 describe。
5. Traefik 和 im-server 的 Service、EndpointSlice、Ingress YAML。
6. ArgoCD/Helm/CI 在故障窗口的发布历史。
7. 入口节点 CPU、内存、conntrack、TCP overflow/retransmit 和网卡 drop。
拿到这些材料后,按照以下优先级定责:
1. Traefik 容器或节点在 10:41:32 重启。
2. ArgoCD/Helm/CI 触发 Traefik 滚动或配置重建。
3. Traefik/im-server endpoint 短暂清空。
4. OOM、liveness、conntrack、监听队列或节点网络故障。
5. 其他上游负载均衡或云网络事件。
+495
View File
@@ -0,0 +1,495 @@
# IM 后端问题清单
_2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的实测,能读代码的读代码,均标注核实方式),并把此前散落在各处(`docs/IM-后端问题清单-2026-09-04.md`、历次前端重写工程记录)但从未收录进本清单的问题一并归档,同时确认了几条此前记录、后来查明其实是前端自己的bug(已在下方"已排除"列出,不再作为后端问题)。这次复核期间发现后端正在把数据库从单机MySQL迁移到新k3s集群的Percona XtraDB Cluster(PXC)——`server/meetingim.conf`/`tinode.conf`的MySQL/Redis/Kafka地址已经从旧VM改成新集群地址,代码里也新增了专门处理PXC兼容性问题(命名锁、自增ID越界)的分支——这是今天新发现的几个MySQL相关问题(尤其是周期性的驱动层连接损坏)很可能与之相关的重要背景,供后续排查参考。_
## 目录
**P0 · 安全漏洞 / 核心功能完全不可用**
暂无。
**P1 · 数据完整性 / 权限失效 / 主要功能缺陷**
| 编号 | 接口 | 一句话 |
|---|---|---|
| [问题36](#问题36) | `POST /messages/forward` | 并发转发下响应说"失败"其实已经真实写入——2026-09-14用干净的12并发复测,仍有2/3的"失败"响应对应真实落库的消息,最近一次代码改动(`926b4bb`)改善了部分逻辑但未解决根本问题 |
| [新增80](#新增80) | Bearer Token鉴权(影响所有需要登录态的接口) | 同一个token并发请求,20-45%概率随机返回401 AUTH_REQUIRED,鉴权层把"token真无效"和"Redis/DB瞬时报错"统一处理成同一个错误码且完全不打日志,无法从现有日志区分具体原因 |
| [新增81](#新增81) | `GET /v0/file/s/{id}`(媒体文件服务) | 查询数据库里不存在的列`mtg.recorder_user_id`,25分钟观测窗口内命中这条SQL的请求243次全部失败 |
**P2 · 数据一致性 / 体验缺口 / 校验缺失**
| 编号 | 接口 | 一句话 |
|---|---|---|
| [问题25](#问题25) | `POST /messages/forward`、`/favorites/forward` | 带附言转发时 `head` 声明类型与 `content` 实际结构对不上 |
| [问题27](#问题27) | `GET /reports/daily` | `status` 参数不做取值校验,传任何值都返回200+空列表 |
| [问题33](#问题33) | `GET /reports/daily` | `permissions` 四个字段恒为同一组硬编码值,与用户/参数无关 |
| [问题35](#问题35) | `GET /users/{id}/profile` | 设了备注之后拿不到对方的原昵称(`GET /friends`一侧已修复) |
| [新增3](#新增3) | `PATCH /conversations/{topic}/settings` | 单聊置顶没有批量查询接口,会话列表永远看不出置顶效果 |
| [新增8](#新增8) | `GET /friends` | 完全不感知拉黑关系,与另外两个接口表现不一致 |
| [新增15](#新增15) | `POST /meetings/{id}/cancel` | 完全不校验 `version`,乐观锁摆设 |
| [新增24](#新增24) | `GET /groups/{topic}`、`/settings` | 群成员数存在两套互相独立、可永久失配的计数器 |
| [新增25](#新增25) | 原生WS `{pub}` | link类型消息协议层可构造,但没有真实用户可从UI触达的发送路径 |
| [新增30](#新增30) | `GET /settings/youth-mode` | 开启后不对任何功能做实际限制,`enforced` 恒为硬编码false(前端已移除此功能,仅保留记录完整性) |
| [新增31](#新增31) | `POST /reports/abuse` | 不校验 `requires_description`,可对必填原因提交空描述 |
| [新增34](#新增34) | `POST /tenant-applications` | 统一社会信用代码无去重机制,可无限重复提交 |
| [新增41](#新增41) | `POST /groups/{topic}/mutes` | 无角色层级保护,管理员可禁言群主、任何角色可自我禁言 |
| [新增47](#新增47) | `POST /v0/file/u/` | 真实文件上传上限是32MB而非声明的100MB,且从未用于前端预校验 |
| [新增49](#新增49) | `POST /reports/daily`、`/monthly` | 创建接口对日期只校验格式不校验范围,年份0000/2099均可创建 |
| [新增51](#新增51) | `POST /meetings` | 不校验 `attendee_user_ids` 是否真实存在,可创建任意规模"幽灵参会人" |
| [新增54](#新增54) | 日报/月报SUBMIT | 不校验必填内容,可提交完全空白报告 |
| [新增56](#新增56) | `POST /settings/youth-mode/enable` | `policy_version` 不做版本校验,传任意值即被无条件接受(前端已移除此功能) |
| [新增76](#新增76) | `GET /app/releases/latest?platform=web` | `platform` 只接受ios/android,web端完全不支持检查更新 |
| [新增77](#新增77) | `GET /groups/{topic}/announcements/latest` | 响应没有已读人数字段,"已读N/M"这个设计要求的功能无法实现 |
| [新增78](#新增78) | `GET /topics/{topic}/messages/search`、收藏/通话记录的相关字段 | 发送者/对方user_id被裁掉`usr`前缀,或返回内部hash而非可寻址id |
| [新增82](#新增82) | `cache group settings`(内部缓存,影响群相关读接口的性能与正确性) | 群设置缓存反序列化失败,高频出现(242次/25分钟),覆盖大量不同群 |
| [新增83](#新增83) | `media serve`(文件服务) | 死锁(1213/40001)没有捕获重试,命中的请求直接失败 |
| [新增84](#新增84) | 内部后台任务"media gc" | 周期性(约每50秒一次)触发MySQL驱动层连接损坏("busy buffer"/协议序号错乱),与PXC迁移的时间点吻合,怀疑相关 |
| [新增85](#新增85) | `GET /groups/{topic}/members?role=admin` 等按查询参数区分的缓存 | 写操作(如提升管理员)后,特定查询参数组合的结果有90秒以上的缓存滞后 |
| [新增86](#新增86) | `GET /calls` | 响应字段结构与协议文档完全不同(无`peer`对象、无`incoming`布尔值、时间戳拆成4个字段、`topic`是内部hash) |
| [新增87](#新增87) | 群二维码 `POST /groups/{topic}/qrcodes` vs `POST /qrcodes/resolve` | 铸造接口用`group_snapshot`字段名,解析接口对同样的数据用`group`字段名,两个接口不一致;解析接口另有一个协议文档完全没提过的`status`字段 |
| [新增88](#新增88) | `GET /topics/{topic}/messages/search` | 部分明显应该命中的关键词搜索返回空结果(搜索召回不稳定,2026-09-14复测比更早的记录有改善但未完全解决) |
| [新增89](#新增89) | `im_file_review_tasks` / 养成动态图片审核 | IM 仅创建文件审核任务,没有执行 Worker,所有带图动态都会永久停在 `reviewing/pending` |
| (无编号,功能缺口非bug) | 无 | 整份协议文档没有"移出/踢出群成员"这个操作,`03-group.dc.html`里"移出"按钮因此没有对应接口可用——确认是协议本身没设计这个功能,不是遗漏 |
| (无编号,可能是设计而非缺口) | 无 | 无手机号查询/换绑接口、无登录设备列表接口;`POST /security/challenges`这个"敏感操作二次验证"机制目前只服务青少年模式两个场景,没有覆盖改密码等场景 |
---
## 本次复核确认已修复(不再是问题,供归档参考)
以下条目在2026-09-14用真实账号实测或直接读当前代码确认已经修复,从清单中移除:
- **问题22**(无登出/Token撤销接口):**已修复**。`POST /v1/im/auth/logout`现在是真实可用的接口(之前误判404的路径是没有走对,正确路径是这个)。实测:新token连续3次200 → 调用logout返回200 → 同一token之后5次全部401,确认真实吊销,不是假成功。
- **问题28**(`purpose=user_avatar`被拒):**已修复**。实测提交真实PNG文件,200成功。
- **新增16**(总经理信箱超长内容返回500):**已修复**。代码确认`serveIMExecutiveMailboxSubmissions`现在显式捕获`ErrPolicy`并返回400 VALIDATION_FAILED,不再落到default的500分支。
- **新增17**(`writeIMRelationError`把`ErrNotFound`错误映射成`MALFORMED_REQUEST`):**已修复**。代码确认现在`ErrNotFound`正确映射到404+`RESOURCE_NOT_FOUND`,reason与message一致。
- **新增27**(工作台`unread_count`恒为0):**已修复**(2026-09-10记录"只接入bootstrap,workbench本身还没接",现在确认`serveIMWorkbench`内部也调用了`populateWorkbenchModuleUnreadCounts`)。注:目前只有"会议"模块会计算真实值,其余模块代码上仍是原始SQL的0值,但这正好是原始bug报告实测覆盖的场景,已修复。
- **新增42**(群公告按id查询恒定返回最新公告):**已修复**。代码确认现在正确解析路径里的`announcement_id`并调用`GetAnnouncement`按id查询,不再无视这个参数。
- **新增43**(群文件登记接口无长度/存在性校验):**已修复**。代码确认现在对`file_name`(255)/`mime_type`(128)做长度校验,对`file_size`做负数校验,且会调用`store.Files.Get`验证`file_id`真实存在。
- **新增45**(收藏/搜索/宫格分页off-by-one永久丢失边界记录):**已修复**。实测:24条真实收藏,用limit=10分3页拉取(10+10+4),合并后24条一条不少、无重复。
- **问题30**(系统通知`summary`/`action`结构不一致 + 会议响应事件双写):**已修复/已失效**(2026-10-09复核)。①会议响应双写随上游`0e2f4e7`(2026-09-17把整个workbench/会议模块从im_server移除)消失,现仅剩Kafka单路径写入且按固定event_id幂等upsert,不再适用;②`action`由读路径`normalizeSystemNotificationAction`统一归一为`{type,target_id,payload,...}`;③空`summary`读路径回退`title`;④机器码`summary`(`accepted`/`rejected`/`continue`/`cancel`)已在4处写入点改为人类可读文案(`serveIMFriendRequestAction`/`serveIMRiskChallengeConfirm`/`TenantFriendRequestAccept`/`TenantFriendRequestResolve`)。实测:A对B发好友申请、B拒绝后,A的通知列表返回`summary:"对方已婉拒你的好友申请"`、`action`为归一化结构。
- **新增32**(系统通知重复已读,响应回显请求值但存储不变):**已修复**(2026-10-09复核)。存储层`TenantSystemNotificationMarkRead`在`COALESCE`更新后回读真实行返回,handler用返回值构造响应(单测`TestServeIMSystemNotificationsListAndRead`覆盖)。实测:同一通知先以`read_at=T1`标记已读、再以`T2`重复标记,两次响应均返回持久化的`T1`,不再回显本次提交值。
## 本次复核确认仍是历史误判(从未是真正的后端bug,不应再作为后端问题提出)
以下条目此前曾被记录为"疑似后端bug",后来(多为9月上中旬的复核)证实其实是前端自己调用错了接口/传错了参数,backend行为一直是对的——**不要把这几条再当成后端问题反馈**:
- `GET .../groups/{topic}/members/{user_id}/profile` 查自己恒定400——真相:有专门的`members/me/profile`接口可以正确查询,前端读操作一直在用错误的通用by-id接口,写操作反而一直用对了。已切换到正确接口,问题消失。
- `POST .../groups/{topic}/mutes/batch` 恒定405——真相:真实的批量禁言接口是`/mutes`(不带`/batch`后缀),前端一直在调一个不存在的路径。已改用正确路径。
- `GET /users/{id}/profile` 对好友`usrW9ijv236hHA`恒定403——真相:响应体里其实带着`message:"profile is private"`,是对方自己设置了资料保护,属于设计内行为,不是bug。
- `PATCH .../groups/{topic}/member-settings`(置顶/免打扰)恒定400——真相:这个接口的请求体结构里根本没有`version`字段,后端解码器对未声明字段严格拒绝;前端一直在照抄别的接口的乐观锁写法多传了一个`version`字段。已去掉,问题消失。
- 群设置`governance`字段"被后端写死成默认值"——这是一次静态代码审查的猜测,当场用真实接口交叉验证后证明是错的:`/settings`和`/governance`返回一致,写入后能正确反映在读接口上。从未真正成立过。
---
<a id="问题36"></a>
## 问题 36(P1,2026-09-14复测:仍未修复,最近一次代码改动效果有限):并发转发到同一个会话时,响应说"失败"的条目里相当一部分其实已经成功写入,重试会造成真实内容重复
**证据强度**:✅ 实测确认(多轮独立复现,含2026-09-14最新一轮干净复测)+代码确认
**2026-09-14复测方法与结果**:`git pull`确认`server/kafka_message_persist.go`在9月11日有过一次专门的修复提交(`926b4bb fix im forwarding and message list regressions`),代码上看这次改动新增了`Pending`状态(区分"确定成功"/"确定失败"/"不确定,可能已经发出"三种情况,而不是原来的非成功即失败)。但不能只信代码改动就下结论,实际用真实群(`grpDclMebv3MuI`)、真实消息(seq 135)做了一次干净的12并发转发测试(每个请求用独立的幂等键,模拟"用户看到失败提示后重新点击"的真实场景):
- 12个请求里7个报告"sent"成功(seq 136/137/138/139/140/142/143),3个报告"failed"(`INTERNAL_ERROR`),2个命中已知的401鉴权问题(新增80,未到达转发逻辑)。
- 测试前群里搜索"测试"能查到的最大seq是135;测试后变成130-144连续9个新seq(136-144)。
- **9个真实新消息 vs 7个确认成功——多出来的136-144里的141和144,对应的正是那3条报"failed"里的2条,它们实际上真的写入成功了,只是响应告诉客户端"失败"。**
**结论**:本质问题没有解决——响应仍然不可信,"failed"不能被客户端安全地当作"真的没发生"来处理,重试依然会造成真实重复。9月11日的那次改动可能改善了部分场景(比如更细粒度的pending状态跟踪),但没有堵住这个具体的漏洞。
**根因**(此前已逐行核对,本次未见改变):开启Kafka时,`saveMessageMaybeKafka`先原子预留seq,再异步把消息发布到Kafka——如果produce调用本身报错(例如含糊的ack超时),HTTP handler判定失败并返回给客户端,但已预留的seq和可能已经在网络层提交成功、只是没收到ack的Kafka消息都回滚不了,只要Kafka消费者最终异步写入,"失败"响应背后就会真实落库。
**接口**:`POST /v1/im/messages/forward`
**前端现状**:批量转发的失败提示照原样展示后端返回的`status`/`reason`,无法感知也无法规避"响应可能撒谎"这个问题,需要后端修正响应准确性(例如:明确区分"确定失败可安全重试"和"结果不确定,建议先检查再决定是否重试"两种状态,而不是都归为同一种"failed")。
---
<a id="新增80"></a>
## 新增 80(P1,2026-09-14新发现):Bearer Token鉴权在并发请求下随机出现401,且无法从日志定位具体原因
**证据强度**:✅ 实测确认(自己独立复现,两个不同接口各自独立测试)+代码确认(完整调用链,含具体文件行号)
**现象**:用同一个有效token对`GET /v1/im/groups/{topic}`或`GET /v1/im/me`连续发起20个并发请求,大约15%-65%会随机返回`401 {"code":30043,"reason":"AUTH_REQUIRED"}`,比例不稳定(同一token连续测两轮,一轮5/20失败,几秒后另一轮13/20失败)。对照组:同样并发打`POST /v1/im/tenants/resolve`(只需要API Key,不需要bearer token)20/20全部成功,证明问题只出在bearer token鉴权这一层,不是网络或网关层面的问题。
**排除的可能性**:进入当前所有正在运行的3个`im-server` Pod内部,对`AUTH_TOKEN_KEY`/`MYSQL_DSN`/`REDIS_ADDR`/`REDIS_PASSWORD`/`TENANT_REGISTRY_DB`/`API_KEY_SALT`/`UID_ENCRYPTION_KEY`共7个关键配置项,在每个Pod自己进程内部单独计算哈希值做比对(不在外部解密Secret),3个Pod完全一致——排除"多实例配置不一致"这个最初怀疑的方向;3个Pod是同一批次创建、同一个ReplicaSet,没有游离的旧实例。
**根因(读当前`im_be`源码确认,文件行号)**:`GET /v1/im/groups/{topic}`等接口的鉴权链条是 `authenticateIMHTTPBearer`(`server/im_http.go:7093`) → `resolveIMHTTPBearerToken`(`server/im_http.go:7111`) → `(*authenticator).Authenticate`(`server/auth/token/auth_token.go:103`)。这个函数内部,token签名错误、过期、`serial_num`不匹配、Redis撤销状态查询报错(`IsLoginTokenRevoked`)、按租户路由的DB鉴权版本号查询报错(`GetAuthVersion`/`TenantAdapterFor`)——**所有这些情况的错误全部被统一转换成同一个`types.ErrPermissionDenied`**,最终一律报401,且**这条失败路径上完全没有任何日志输出**,导致即使线上真实发生了这类失败,事后也无法从日志区分具体是哪一种原因触发的。
其中`GetAuthVersion`背后的`TenantAdapterFor`(`server/db/mysql/adapter.go:469`)使用**每个进程自己独立维护的懒加载租户连接缓存**,某个租户如果还没被当前进程缓存过,一波并发请求会各自独立地去开一条新数据库连接并`Ping()`,这种"并发抢着建连接"的场景比"某个pod配置固定错误"更能解释"20个并发里随机几个失败、且失败比例还会波动"这个现象。
**支持证据**:查了同一时间窗口的k8s事件,3个pod中有2个在过去1小时内真实触发过健康检查失败(`Unhealthy: context deadline exceeded`,探测超时只给了1秒);同时机器上2/3节点内存占用在69%-81%(这是个多个服务共享的测试集群)。另外,MySQL的`aborted_connects`计数器非零(13),跟"存在瞬时连接失败"这个方向一致,但不是决定性证据。
**重要背景**:这次复核时发现后端仓库刚经历一次数据库基础设施迁移——`server/meetingim.conf`/`tinode.conf`里的MySQL/Redis/Kafka地址从旧VM换成了新k3s集群的PXC(Percona XtraDB Cluster)地址,且代码里新增了`isPXCStrictModeNamedLockError`/`isIDOutOfRange`两个专门处理PXC兼容性问题的错误分支,说明这个迁移目前还在持续排查兼容性问题的阶段。这个401问题的时间点与这次迁移吻合,虽然鉴权代码本身没有直接调用命名锁,但不排除是同一次基础设施变动带来的连锁不稳定,建议后端一并排查。
**接口**:影响所有需要bearer token鉴权的接口(穷举测试了`GET /v1/im/groups/{topic}`、`GET /v1/im/me`两个,机制上应该影响全部同类接口)
**给后端的建议**:(1)在这条失败路径上补充日志,区分"token确实无效"和"内部依赖(Redis/DB)报错"两种情况,不要两者都报同一个401;(2)审视`TenantAdapterFor`懒加载连接的并发安全性,避免同一租户的并发请求各自重复建连接。
---
<a id="新增81"></a>
## 新增 81(P1,2026-09-14新发现):文件服务查询数据库里不存在的列,几乎每次命中都失败
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计243次)
**现象**:`media serve`(文件服务,`/v0/file/s/{id}`)报错`Error 1054 (42S22): Unknown column 'mtg.recorder_user_id' in 'where clause'`,25分钟的观测窗口里3个pod合计出现243次,是这次复核发现的所有问题里频率最高的一个——几乎所有命中这条SQL路径的真实请求都会失败。
**推测**:列名前缀`mtg`大概率是"会议(meeting)"相关表,可能是代码引用了一个数据库里还不存在的列(缺一次迁移),也可能是列名本身写错了。不确定具体原因,需要后端自己确认。
**接口**:`GET /v0/file/s/{id}`(原生Tinode大文件下载,命中会议关联文件时)
**基础设施侧的态度**:这类"代码查询数据库结构对不上"的问题,本身可能是缺一次迁移,但我们不建议自己去手动改数据库列(不清楚正确的类型/默认值/是否有对应的后端代码逻辑依赖这个列的具体语义),必须由后端确认。
---
<a id="问题25"></a>
## 问题 25(P2,最常见路径已修故降级):带附言转发/收藏转发时,`head` 声明的类型与 `content` 的实际结构对不上
**证据强度**:✅ 实测确认(四条路径全部真实构造验证)
**现象**:逐条转发带附言、收藏转发带附言两条路径,`head`仍声明原消息类型(如image/text),但`content`换成`{forwarded,comment,snapshot}`等包装对象,客户端按head类型解析content会得到空白/无效内容。逐条转发不带附言这条最常见路径已经修好(content原样透传)。
**期望**:附言作为独立的一条文本消息发送(对接文档本来就是这么描述的),转发的那条消息仍然原样透传原content。
**接口**:`POST /v1/im/messages/forward`;`POST /v1/im/favorites/forward`
---
<a id="问题27"></a>
## 问题 27(P2):`status` 参数不做取值校验,传任何值都返回200+空列表
**证据强度**:✅ 实测确认(2026-09-14重测,传`status=totally_bogus_value_xyz`依然200+`items:[]`,未变)
**现象**:传完全不存在的status值也返回200,客户端无法区分"确实没数据"和"根本不支持"。
**接口**:`GET /v1/im/reports/daily?status=`(月报同构)
---
<a id="问题33"></a>
## 问题 33(P2):`permissions` 的四个字段在不同参数下恒为同一组值
**证据强度**:✅ 代码确认(2026-09-14重读源码,确认仍是硬编码map字面量,行号已随文件改动漂移到2614-2619行左右)
**现象**:`can_create`/`can_remind_unsubmitted`/`can_view_managed`/`makeup_allowed_days`换任何用户、scope、日期区间组合都恒为同一组值,因为响应体里这部分是Go代码写死的map字面量,完全不引用`principal`/`reports`/任何请求参数。
**接口**:`GET /v1/im/reports/daily`
---
<a id="问题35"></a>
## 问题 35(P2,`GET /friends`一侧已修复,范围收窄到`GET /users/{id}/profile`):设了备注之后,好友主页详情接口拿不到对方的原昵称
**证据强度**:✅ 实测确认(2026-09-14再次复核,问题依然存在)
**现象**:`GET /v1/im/friends`响应里带的嵌套`peer`对象(`peer.display_name`)能正确带对方的真实原始昵称,这一侧的诉求已经满足。但`GET /v1/im/users/{id}/profile`依然没有修:既没有remark字段,也没有独立的原昵称字段——保存备注后本地乐观更新正确显示新备注,但一旦触发这个详情接口的背景刷新重新拉取,会因为接口不带remark字段而把显示"冲"回"未设置",数据没有真的丢,只是这个详情页的显示会"看起来复原了"。
**接口**:`GET /v1/im/users/{id}/profile`(仍缺);对照已修复的`GET /v1/im/friends`
**期望**:`GET /users/{id}/profile`比照`GET /friends`已经落地的`peer`对象模式,补上对方的真实原始昵称字段。
---
<a id="新增3"></a>
## 新增 3(P2):单聊的"置顶聊天"没有任何办法让会话列表知道,因为没有批量查询会话设置的接口
**证据强度**:✅ 实测确认(对照群聊置顶排除法)
**现象**:群聊置顶写入订阅原生private字段能随meta同步下发;单聊置顶写入独立表且不写private字段,只有单条查询接口无批量接口,前端无法为几十上百个会话逐一查询,设置生效但用户看不出效果。
**接口**:`PATCH /v1/im/conversations/{topic}/settings`(写入);缺失批量查询接口
---
<a id="新增8"></a>
## 新增 8(P2):`GET /friends` 完全不感知拉黑关系,与另外两个接口表现不一致
**证据强度**:✅ 实测确认+代码确认
**现象**:拉黑后该好友仍显示`relationship:"friend"`且权限位全开,与`GET /conversations/{topic}/context`(正确显示`blocked_by_me`)、`GET /users/{id}/profile`(裸403)三者互不一致。
**根因**:`friendListItemPayload`及数据源`types.FriendListItem`从结构体到SQL全程未涉及黑名单表。
**接口**:`GET /v1/im/friends`;对照`GET /v1/im/users/{id}/profile`、`GET /v1/im/conversations/{topic}/context`
---
<a id="新增15"></a>
## 新增 15(P2):`POST /meetings/{id}/cancel` 完全不校验 `version`
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码,`serveIMMeetingCancel`依然只解析`Version int`但从未在调用`store.Meetings.Cancel`时传入这个字段)
**现象**:故意传`version:999`也直接成功,与同资源my-response接口(版本校验正常409)形成对照——乐观锁形同虚设。
**接口**:`POST /v1/im/meetings/{id}/cancel`
---
<a id="新增24"></a>
## 新增 24(P2):群成员数存在两套互相独立、可永久失配的计数器
**证据强度**:✅ 实测确认(3个真实群交叉对照)+代码确认(2026-09-14重读源码确认未变)
**现象**:同一群同一时刻,`GET /groups/{topic}`与`/settings`显示的`member_count`,跟真实成员列表、qrcodes的`group_snapshot`可能不一致。
**根因**:业务层`im_group_settings.member_count`(只看`im_group_members`)与Tinode原生层`im_topics.subcnt`(只看`im_subscriptions`)是两套独立计数器,`mergeGroupSettingsPayload`(`server/im_http.go:8012`)无条件用业务层值覆盖了本来准确的subcnt值。
**接口**:`GET /v1/im/groups/{topic}`;`/settings`;`POST /groups/{topic}/qrcodes`;`GET /groups/{topic}/members`
---
<a id="新增25"></a>
## 新增 25(P2,产品需求缺口):link类型消息——协议层面可构造,但没有任何真实用户可从webapp UI触达的路径
**证据强度**:✅ 实测确认(正反两方向均构造真实数据验证)
**现象**:WS`{pub}`可直接构造出`content:{url:...}`扁平结构被判定为link类型(可收藏可搜索),但`im_webapp`所有正常发送路径产出的content都不是这个形状,"我的收藏-链接"等tab对真实用户永远是空的。
**接口**:WS `{pub}`(消息发送),无特定REST路径
---
<a id="新增30"></a>
## 新增 30(P2):青少年模式开启后不对任何功能做实际限制,`enforced` 恒为硬编码false
**证据强度**:✅ 实测确认(2026-09-14重新实测,未变)+代码确认
**现象**:restriction_items三项(深夜免打扰/内容过滤/支付守护)的`enforced`恒为硬编码false,全仓库穷举确认没有任何消息发送/好友添加/群聊/支付相关接口会查询用户的青少年模式状态。
**接口**:`GET /v1/im/settings/youth-mode`
**⚠️ 注**:`im_webapp`已明确移除青少年模式这个前端功能(用户产品决策,非技术限制),这条对当前`im_webapp`已无实际影响,仅为完整性保留在清单里。
---
<a id="新增31"></a>
## 新增 31(P2):`POST /reports/abuse` 不校验 `requires_description`,可以对必填原因提交空描述
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:某原因码(如illegal/other)的`requires_description`为true,但提交该reason_code、description为空依然200成功——代码里只校验了`description`长度上限(500),从未检查对应`reason_code`是否要求必填。
**接口**:`POST /v1/im/reports/abuse`
---
<a id="新增34"></a>
## 新增 34(P2):企业注册申请接口对同一个统一社会信用代码没有任何去重/防重复提交机制
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:同一信用代码用三个不同幂等键连续提交三次,全部独立成功建单,与需求文档"应防止重复提交"矛盾。代码上`serveIMTenantApplications`直接调用`CreateTenantApplication`,没有先查询该信用代码是否已有pending申请。
**接口**:`POST /v1/im/tenant-applications`
---
<a id="新增41"></a>
## 新增 41(P2):群禁言接口没有任何角色层级保护——管理员可以禁言群主、任何角色都能自我禁言
**证据强度**:✅ 实测确认(代码逐行核对根因,2026-09-14重读源码确认未变)
**现象**:GET成员列表明确标注群主`eligible:false`/`ineligible_reason:"owner"`,但`POST /groups/{topic}/mutes`对群主本人自我禁言、以及管理员禁言群主均返回200成功——代码只检查了操作者本人是否是管理员,从未检查目标成员的角色。
**接口**:`POST /groups/{topic}/mutes`;对照`GET /groups/{topic}/members?mute_eligible=true`
---
<a id="新增47"></a>
## 新增 47(P2):真实文件上传大小上限是32MB,不是仓库配置声明的100MB,且这个真实上限从未被前端用来做任何提前校验
**证据强度**:✅ 实测确认(2026-09-14重测`GET /bootstrap`,`max_file_size`依然是33554432即32MB,与之前记录一致)
**现象**:`GET /v1/im/bootstrap`声明的`data.limits.max_file_size`是33554432(32MB),但代码仓库`tinode.conf`/`meetingim.conf`里配置的是104857600(100MB)——声明值和真实生效值不一致,且这个字段从未被前端读取用于上传前的预校验。
**接口**:`POST /v0/file/u/`(Tinode原生大文件上传);`GET /v1/im/bootstrap`(`data.limits.max_file_size`)
**⚠️ 说明**:此前记录曾提到仓库配置文件已经改成一致的104857600,但线上实测一直是32MB——这次复核确认线上依然是32MB,怀疑部署环境的实际配置和仓库里能看到的配置文件不是同一份,或者中间有额外的网关/环境变量覆盖,建议后端确认线上部署实际生效的配置来源。
---
<a id="新增49"></a>
## 新增 49(P2):日报/月报创建接口对日期只校验格式不校验范围,年份0000、2099均可创建并成功提交
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:`report_date:"2099-12-31"`、`"0000-01-01"`均能创建、保存草稿、提交成功走完整流程。`parseIMDate`只用`time.ParseInLocation("2006-01-02",...)`校验格式,没有任何年份范围校验。
**接口**:`POST /v1/im/reports/daily`(report_date)、`POST /v1/im/reports/monthly`(month)及对应的`/submit`接口
---
<a id="新增51"></a>
## 新增 51(P2):会议创建接口不校验 `attendee_user_ids` 对应的用户是否真实存在,可创建任意规模的"幽灵参会人"
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:用50个语法合法但真实不存在的user_id+1个真实好友,一次性创建52人会议成功。`parseIMUserIDList`只做格式校验(`types.ParseUserId`)和去重,从未查询数据库确认这些用户真实存在。
**接口**:`POST /v1/im/meetings`
---
<a id="新增54"></a>
## 新增 54(P2):日报/月报SUBMIT接口不校验必填内容,可提交完全空白的报告,与响应自身声明的 `validation.*_required:true` 矛盾
**证据强度**:✅ 实测确认(现象)+代码确认(2026-09-14找到确切根因:`TenantDailyReportSubmit`的UPDATE语句只匹配`report_id`/`author_id`/`version`,完全不检查`work_summary`/`next_plan`等内容字段)
**现象**:`work_summary`/`next_plan`均为空字符串的月报草稿直接调用submit,返回200且`status:"submitted"`,同时响应体自己声明这两个字段`validation.*_required:true`。
**接口**:`POST /v1/im/reports/daily/{id}/submit`、`POST /v1/im/reports/monthly/{id}/submit`
---
<a id="新增56"></a>
## 新增 56(P2):青少年模式启用接口 `policy_version` 不做版本校验,传任意正整数即被无条件接受并永久覆盖
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:代码只检查`body.PolicyVersion <= 0`(必须是正数),从未跟服务端已知的当前真实policy_version做比对,传任意大的正整数都会被接受并覆盖存储值。
**接口**:`POST /v1/im/settings/youth-mode/enable`
**⚠️ 注**:同新增30,`im_webapp`已移除此功能,仅为完整性保留记录。
---
<a id="新增76"></a>
## 新增 76(P2):`GET /app/releases/latest` 的 `platform` 参数只接受 `ios`/`android`,web端完全不支持检查更新
**证据强度**:✅ 代码确认(2026-09-14重读当前HEAD确认未变)
**现象**:`GET /v1/im/app/releases/latest?platform=web`恒定`400 MALFORMED_REQUEST {"details":{"field":"platform"}}`。
**接口**:`GET /v1/im/app/releases/latest`
**前端现状**:`AboutPage.tsx`捕获这个错误后显示"暂不支持检查更新",不会一直转圈或显示假的"已是最新版本"。
---
<a id="新增77"></a>
## 新增 77(P2):群公告接口响应里没有已读人数字段,设计要求的"已读N/M"功能无法实现
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读`groupAnnouncementPayload`/`groupAnnouncementPayloadWithPermissions`确认未变)
**现象**:`GET /groups/{topic}/announcements/latest`真实响应确认没有任何"已读数"/"已读人数"相关字段,设计稿明确要求的"已读18/28"这类展示前端拿不到数据来源。
**接口**:`GET /v1/im/groups/{topic}/announcements/latest`
**前端现状**:不展示已读人数(没有编造假数据)。
---
<a id="新增78"></a>
## 新增 78(P2):消息搜索/收藏/通话记录里,涉及用户/话题id的字段有不一致的裁剪或哈希化处理
**证据强度**:✅ 实测确认(三处独立复现)
**现象**:
- `GET /topics/{topic}/messages/search`的`items[].from`、收藏`type:"text"`的`snapshot.from`:真实user_id被裁掉`usr`前缀(如`usrH5dvZ3ckWxo`变成`H5dvZ3ckWxo`),直接拿这个值和当前用户完整id比较会永远为false。
- 收藏的`source.topic`(P2P来源)、通话记录`GET /calls`的`topic`字段:用的是服务端内部`p2p...`哈希形式,不是SDK寻址用的`usr...`形式,客户端没有办法把这个哈希反解回对方user_id。
**接口**:`GET /v1/im/topics/{topic}/messages/search`;收藏接口的`snapshot.from`/`source.topic`;`GET /v1/im/calls`的`topic`
**前端现状**:都补了对应的兜底逻辑(`normalizeSender()`补回`usr`前缀;`p2p`前缀的topic降级显示"单聊消息"通用文案),不会崩溃,只是丢失了直接寻址能力。
---
<a id="新增82"></a>
## 新增 82(P2,2026-09-14新发现):群设置缓存反序列化失败,高频出现,覆盖大量不同群
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计242次,覆盖十几个不同真实群)
**现象**:`cache group settings[{topic}]: read failed: Uid.UnmarshalJSON: invalid length`,几乎是常态出现,不是个别数据问题——说明这条缓存读取逻辑本身有系统性bug,大概率是并发访问时缓存里的数据被写坏/读到了不完整的中间状态。其中至少一次复现(`grpDclMebv3MuI`)是被我们自己的并发请求触发的,其余多次是某个后台任务在循环访问一批固定的群时触发的。
**接口**:内部缓存逻辑,不直接对应某个REST接口,但会影响所有需要读取群设置的接口的正确性
---
<a id="新增83"></a>
## 新增 83(P2,2026-09-14新发现):`media serve` 死锁没有捕获重试
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计82次)
**现象**:`Error 1213 (40001): Deadlock found when trying to get lock; try restarting transaction`——MySQL自己在错误信息里就写明了"try restarting transaction",这是标准的死锁处理方式,但代码里没有捕获这个特定错误码后自动重试,命中死锁的请求直接失败。
**接口**:`GET /v0/file/s/{id}`(文件服务)
---
<a id="新增84"></a>
## 新增 84(P2,2026-09-14新发现):内部周期性后台任务"media gc"约每50秒触发一次MySQL驱动层连接损坏
**证据强度**:✅ 实测确认(真实运行日志,从09:42到10:03连续观察,间隔稳定在46-67秒之间,与是否有外部请求流量无关)
**现象**:`[mysql] connection.go:215 busy buffer`紧接着`packets.go:68 [warn] unexpected sequence nr: expected 0, got 5`,然后`media gc: N driver: bad connection`——这是MySQL驱动协议层面的连接状态错乱,是同一个进程内部对同一条MySQL连接被并发误用导致的典型症状,不是网络问题。
**与PXC迁移的关系**:这个问题的出现时间与本次复核发现的数据库迁移(旧VM单机MySQL → 新k3s集群PXC)时间点吻合,怀疑与迁移相关,但没有直接证据(比如没有看到这个后台任务代码里调用了新增的PXC兼容分支),仅供后端排查参考。
**接口**:内部后台任务,不直接对应外部接口,但可能影响同一时间段内其它数据库操作的稳定性
---
<a id="新增85"></a>
## 新增 85(P2,历史遗留,曾用编号"新增7"记录在`docs/IM-后端问题清单-2026-09-04.md`但未被带入本清单,本次复核重新收录):按查询参数区分的缓存,写操作后特定参数组合的结果有明显滞后
**证据强度**:✅ 实测确认(提升管理员后90秒以上仍查不到);⚠️2026-09-14本次复核未重新实测(判断优先级较低,仅确认历史记录未遗漏)
**现象**:`POST /groups/{topic}/admins/batch`提升成功(version从1到2),但`GET /groups/{topic}/members?role=admin`反复查询超过90秒仍然是`items:[]`——用直连接口的方式排除了前端自己的客户端缓存,是服务端按`role=admin`这个具体参数组合单独缓存了旧的空结果,写操作没有使它失效。
**接口**:`GET /v1/im/groups/{topic}/members?role=admin`(及其它类似的按查询参数缓存的接口)
---
<a id="新增86"></a>
## 新增 86(P2):`GET /calls` 响应字段结构与协议文档完全不同
**证据强度**:✅ 实测确认(真实响应逐字段核对)
**现象**:协议文档描述的字段形状和真实响应完全对不上——没有`peer`对象,只有扁平的`peer_user_id`(真的是`usr...`形式);没有`incoming`布尔值,而是`direction:"incoming"|"outgoing"`字符串;时间戳拆成了`created_at/started_at/finished_at/updated_at`四个字段,没有单一的`ts`;`topic`字段是`p2p...`哈希形式(同新增78的毛病)。
**接口**:`GET /v1/im/calls`
**前端现状**:已按实测字段改正类型声明和渲染逻辑。
---
<a id="新增87"></a>
## 新增 87(P2):群二维码铸造/解析两个接口对同一份群数据用了不同的字段名,解析接口还多了一个文档没提到的字段
**证据强度**:✅ 实测确认(真实铸造+解析双向验证)
**现象**:`POST /groups/{topic}/qrcodes`(铸造)响应把群信息放在`group_snapshot`字段下;`POST /qrcodes/resolve`(解析,`purpose:"join_group"`)响应把同样性质的数据放在`group`字段下——同一份数据两个接口用了不同字段名。另外`resolve`响应还带了一个协议文档完全没提过的`status`字段,扫描"自己已经在的群"时返回`status:"joined"`;"真的还不是成员"这个场景下`status`具体是什么值没有实测到(当时没有第二个测试账号)。
**接口**:`POST /v1/im/groups/{topic}/qrcodes`;`POST /v1/im/qrcodes/resolve`
**前端现状**:做了双重兜底读取(同时尝试`group_snapshot`和`group`两个字段名);`status`按"不是joined就走提交申请"的宽松逻辑兜底处理。
---
<a id="新增88"></a>
## 新增 88(P2,未定论,需要后续继续观察):部分明显应该命中的关键词搜索返回空结果
**证据强度**:⚠️实测确认现象,但成因未定论
**现象**:在真实群里搜索"验证"/"链接"等明显应该命中历史消息的关键词,一度返回`items:[]`,同一时间搜索"测试"能正常命中。2026-09-14复测:搜索"验证"这次能正确命中真实消息了,看起来比之前有改善;但"链接"依然返回空——不过这次没能确认这个群里是否真的存在包含"链接"二字的消息,不能算作确凿的回归证据。
**接口**:`GET /v1/im/topics/{topic}/messages/search`
**建议**:这条目前证据不够充分下定论(可能是搜索索引/召回策略的问题,也可能是测试数据本身的问题),建议后续有更多测试数据时再复核一次。
---
<a id="新增89"></a>
## 新增 89(P1,2026-09-24):文件审核任务无 Worker 消费,带图养成动态永久无法发布
**证据强度**:✅ 测试环境真实日志 + 数据库 + 代码确认
**现象**:用户发布带图动态后,动态保持 `status=reviewing,audit_status=pending`,`scope=friends`列表只查询 `published` 状态,因此列表返回空。实例动态 `1577072518975639552` 的两条图片审核任务(`aliyun_green` 和 `aliyun_sas`)均为 `pending`。
**根因**:文件上传时会无条件向 `im_file_review_tasks` 插入内容审核和恶意文件检测任务,但 IM 当前仅有阿里云提交/轮询客户端,没有实现读取、执行和回写这些任务的 Worker。测试库 `meeting_im.im_file_review_tasks` 共有 174 条任务,全部为 `pending`,`attempts=0`。
**当前临时策略**(2026-09-24 确认):图片审核暂时关闭并默认通过。`cultivate_server` 仍调用 IM 校验文件存在性、归属和上传完成状态,但不再因文件审核的 `pending/risky` 状态阻塞动态发布。文字内容审核保持原有逻辑。
**待正式解决**:
1. 在 IM 中实现文件审核 Worker,完成任务抢占、阿里云提交/轮询、重试、超时、失败降级和结果回写。
2. 区分内容审核与恶意文件检测是否真正启用,未启用的检测不得创建永久 `pending` 的任务。
3. Worker 稳定上线后恢复 `cultivate_server` 的图片审核门禁,并确定现有 174 条历史任务的补偿或废弃策略。
+3 -25
View File
@@ -2,32 +2,10 @@
-- Date: 2026-08-26 -- Date: 2026-08-26
-- Scope: run once in every tenant business database, such as tinode. -- Scope: run once in every tenant business database, such as tinode.
-- Notes: -- Notes:
-- * Do not run in hotgo. Admin/HotGo side needs its own admin_command/outbox tables. -- * Do not run in hotgo. Admin/HotGo side needs its own admin_command tables.
-- * Tables do not contain tenant_id because each tenant has an independent database. -- * Kafka consume idempotency/retry state is Redis-backed; MySQL only keeps manual dead letters.
CREATE TABLE IF NOT EXISTS im_kafka_consume_log ( DROP TABLE IF EXISTS im_kafka_consume_log;
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(96) NOT NULL,
consumer_group VARCHAR(128) NOT NULL,
source_topic VARCHAR(128) NOT NULL,
event_type VARCHAR(96) NOT NULL DEFAULT '',
biz_type VARCHAR(64) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'processing',
retry_count INT NOT NULL DEFAULT 0,
last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) NOT NULL DEFAULT '',
processing_deadline DATETIME(3),
succeeded_at DATETIME(3),
dead_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_kafka_consume_log_event_group(event_id, consumer_group),
INDEX idx_im_kafka_consume_log_status(status, processing_deadline),
INDEX idx_im_kafka_consume_log_biz(biz_type, biz_id),
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters ( CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -0,0 +1,92 @@
-- 仅用于本地环境:为 IM 业务表补充 tenant_id 字段。
--
-- 本脚本只修改表结构并回填现有数据,不修改索引、唯一约束或应用代码,
-- 也不会启用多商户共库能力。
USE `tinode`;
DELIMITER $$
DROP PROCEDURE IF EXISTS add_im_tenant_columns$$
CREATE PROCEDURE add_im_tenant_columns(IN target_schema VARCHAR(64), IN default_tenant_id BIGINT)
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE target_table VARCHAR(128);
DECLARE has_id INT DEFAULT 0;
DECLARE table_cursor CURSOR FOR
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema = target_schema
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.column_name = 'tenant_id'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN table_cursor;
table_loop: LOOP
FETCH table_cursor INTO target_table;
IF done = 1 THEN
LEAVE table_loop;
END IF;
SELECT COUNT(*) INTO has_id
FROM information_schema.columns
WHERE table_schema = target_schema
AND table_name = target_table
AND column_name = 'id';
SET @position_sql = IF(has_id > 0, ' AFTER `id`', ' FIRST');
SET @alter_sql = CONCAT(
'ALTER TABLE `', target_schema, '`.`', target_table,
'` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT ',
default_tenant_id,
' COMMENT ''租户ID''',
@position_sql
);
PREPARE alter_stmt FROM @alter_sql;
EXECUTE alter_stmt;
DEALLOCATE PREPARE alter_stmt;
END LOOP;
CLOSE table_cursor;
END$$
DELIMITER ;
-- 本地默认业务库,现有数据归属 tenant_id=1。
CALL add_im_tenant_columns('tinode', 1);
-- 本地独立业务库,现有数据归属 tenant_id=2。
CALL add_im_tenant_columns('meeting_im', 2);
DROP PROCEDURE IF EXISTS add_im_tenant_columns;
-- 验证:以下结果中 missing_count 应全部为 0。
SELECT 'tinode' AS db_name, COUNT(*) AS missing_count
FROM information_schema.tables t
WHERE t.table_schema = 'tinode'
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.column_name = 'tenant_id'
)
UNION ALL
SELECT 'meeting_im', COUNT(*)
FROM information_schema.tables t
WHERE t.table_schema = 'meeting_im'
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.column_name = 'tenant_id'
);
@@ -0,0 +1,153 @@
-- 仅用于本地环境:将高数据量表迁移为“共库、按商户分表”。
-- 目标命名:
-- im_001_friendships
-- im_001_messages
-- im_001_group_messages_YYYYMM
-- 测试环境不得执行本文件。
-- 1. 商户编号改为事务序列分配,下一编号按当前最大 ID 顺序递增。
USE `hotgo`;
CREATE TABLE IF NOT EXISTS `im_tenant_id_sequence` (
`sequence_name` VARCHAR(32) NOT NULL,
`next_id` BIGINT NOT NULL,
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`sequence_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `im_tenant_id_sequence` (`sequence_name`, `next_id`)
SELECT 'tenant', COALESCE(MAX(`id`), 0) + 1 FROM `im_tenant`
ON DUPLICATE KEY UPDATE `next_id` = GREATEST(`next_id`, VALUES(`next_id`));
-- 禁止绕过序列表使用 AUTO_INCREMENT 生成跳号或历史大整数编号。
ALTER TABLE `hg_admin_member` DROP FOREIGN KEY `fk_hg_admin_member_im_tenant`;
ALTER TABLE `im_tenant_config` DROP FOREIGN KEY `fk_im_tenant_config_tenant`;
ALTER TABLE `im_tenant` MODIFY `id` BIGINT NOT NULL;
ALTER TABLE `hg_admin_member` ADD CONSTRAINT `fk_hg_admin_member_im_tenant`
FOREIGN KEY (`tenant_id`) REFERENCES `im_tenant` (`id`);
ALTER TABLE `im_tenant_config` ADD CONSTRAINT `fk_im_tenant_config_tenant`
FOREIGN KEY (`tenant_id`) REFERENCES `im_tenant` (`id`);
UPDATE `im_tenant` AS target
JOIN `im_tenant` AS shared ON shared.`code`='default'
SET target.`db_name`=shared.`db_name`, target.`db_dsn`=shared.`db_dsn`
WHERE target.`id`<>shared.`id`;
-- 2. 月表路由元数据改为以 tenant_id 为第一维度。
USE `tinode`;
ALTER TABLE `im_group_message_shards`
DROP PRIMARY KEY,
DROP INDEX `uk_im_group_message_shards_table`,
DROP INDEX `idx_im_group_message_shards_status`,
ADD PRIMARY KEY (`tenant_id`, `shard_month`),
ADD UNIQUE INDEX `uk_im_group_message_shards_table` (`tenant_id`, `table_name`),
ADD INDEX `idx_im_group_message_shards_status` (`tenant_id`, `status`, `shard_month`);
DROP TABLE IF EXISTS `im_group_message_topic_shards`;
DROP TABLE IF EXISTS `im_group_message_dedup`;
-- 3. 创建好友与单聊消息分表并迁移现有数据。
CREATE TABLE IF NOT EXISTS `im_001_friendships` LIKE `im_friendships`;
ALTER TABLE `im_001_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT IGNORE INTO `im_001_friendships` SELECT * FROM `im_friendships` WHERE `tenant_id` = 1;
CREATE TABLE IF NOT EXISTS `im_001_messages` LIKE `im_messages`;
ALTER TABLE `im_001_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT IGNORE INTO `im_001_messages` SELECT * FROM `im_messages` WHERE `tenant_id` = 1;
CREATE TABLE IF NOT EXISTS `im_002_friendships` LIKE `im_friendships`;
ALTER TABLE `im_002_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT IGNORE INTO `im_002_friendships` SELECT * FROM `meeting_im`.`im_friendships`;
CREATE TABLE IF NOT EXISTS `im_002_messages` LIKE `im_messages`;
ALTER TABLE `im_002_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT IGNORE INTO `im_002_messages` SELECT * FROM `meeting_im`.`im_messages`;
-- 4. 迁移所有已存在的标准月表。过程只识别 im_group_messages_YYYYMM。
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_group_month_tables$$
CREATE PROCEDURE migrate_group_month_tables(IN source_schema VARCHAR(64), IN source_tenant BIGINT, IN tenant_code CHAR(3))
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE source_table VARCHAR(64);
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE month_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema = source_schema
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN month_cursor;
month_loop: LOOP
FETCH month_cursor INTO source_table;
IF done = 1 THEN
LEAVE month_loop;
END IF;
SET month_code = RIGHT(source_table, 6);
SET target_table = CONCAT('im_', tenant_code, '_group_messages_', month_code);
SET @create_sql = CONCAT('CREATE TABLE IF NOT EXISTS `tinode`.`', target_table,
'` LIKE `', source_schema, '`.`', source_table, '`');
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @alter_sql = CONCAT('ALTER TABLE `tinode`.`', target_table,
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT ', source_tenant, ' COMMENT ''商户ID''');
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @copy_sql = CONCAT('INSERT IGNORE INTO `tinode`.`', target_table,
'` SELECT * FROM `', source_schema, '`.`', source_table,
'` WHERE `tenant_id`=', source_tenant);
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE month_cursor;
END$$
DELIMITER ;
CALL migrate_group_month_tables('tinode', 1, '001');
CALL migrate_group_month_tables('meeting_im', 2, '002');
DROP PROCEDURE IF EXISTS migrate_group_month_tables;
-- 旧的非月表数据按创建月份归入 1 号商户月表(本地现有数据为 202608)。
INSERT IGNORE INTO `im_001_group_messages_202608`
(`tenant_id`,`message_id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,`status`,
`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,
`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,`deleted_at`,
`expires_at`,`created_at`,`updated_at`)
SELECT 1,`id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,`status`,
`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,
`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,`deleted_at`,
`expires_at`,`created_at`,`updated_at`
FROM `im_group_messages`
WHERE `tenant_id` = 1 AND DATE_FORMAT(`created_at`, '%Y%m') = '202608';
-- 5. 修正 1 号商户路由,并合入 2 号商户路由元数据。
UPDATE `im_group_message_shards`
SET `table_name` = CONCAT('im_001_group_messages_', `shard_month`)
WHERE `tenant_id` = 1;
INSERT INTO `im_group_message_shards`
(`tenant_id`,`shard_month`,`table_name`,`shard_start`,`shard_end`,`status`,`row_count`,
`min_created_at`,`max_created_at`,`created_at`,`updated_at`)
SELECT 2,`shard_month`,CONCAT('im_002_group_messages_', `shard_month`),`shard_start`,`shard_end`,`status`,
`row_count`,`min_created_at`,`max_created_at`,`created_at`,`updated_at`
FROM `meeting_im`.`im_group_message_shards`
ON DUPLICATE KEY UPDATE `table_name`=VALUES(`table_name`),`status`=VALUES(`status`);
-- Topic-to-month routes are Redis-backed and are rebuilt by runtime reads.
UPDATE `im_group_message_shards` AS s
SET s.`row_count`=(SELECT COUNT(*) FROM `im_001_group_messages_202608`),
s.`min_created_at`=(SELECT MIN(`created_at`) FROM `im_001_group_messages_202608`),
s.`max_created_at`=(SELECT MAX(`created_at`) FROM `im_001_group_messages_202608`)
WHERE s.`tenant_id`=1 AND s.`shard_month`='202608';
-- 验证结果。
SELECT `sequence_name`,`next_id` FROM `hotgo`.`im_tenant_id_sequence`;
SELECT `table_name`,`table_rows`
FROM information_schema.tables
WHERE table_schema = 'tinode'
AND table_name REGEXP '^im_[0-9]{3}_(friendships|messages|group_messages_[0-9]{6})$'
ORDER BY table_name;
@@ -0,0 +1,272 @@
-- 仅用于测试环境复制库 meeting_im_sharding_preview。
-- 源库 meeting_im 和注册库 hotgo 不在本脚本修改范围内。
USE `meeting_im_sharding_preview`;
-- 1. 共享业务表补充商户维度,复制库全部现有数据归属商户 2。
DELIMITER $$
DROP PROCEDURE IF EXISTS add_preview_tenant_columns$$
CREATE PROCEDURE add_preview_tenant_columns()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE target_table VARCHAR(128);
DECLARE has_id INT DEFAULT 0;
DECLARE table_cursor CURSOR FOR
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema = 'meeting_im_sharding_preview'
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema=t.table_schema
AND c.table_name=t.table_name
AND c.column_name='tenant_id'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN table_cursor;
table_loop: LOOP
FETCH table_cursor INTO target_table;
IF done = 1 THEN
LEAVE table_loop;
END IF;
SELECT COUNT(*) INTO has_id
FROM information_schema.columns
WHERE table_schema='meeting_im_sharding_preview'
AND table_name=target_table AND column_name='id';
SET @position_sql = IF(has_id > 0, ' AFTER `id`', ' FIRST');
SET @alter_sql = CONCAT('ALTER TABLE `meeting_im_sharding_preview`.`', target_table,
'` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT ''商户ID''',
@position_sql);
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE table_cursor;
END$$
DELIMITER ;
CALL add_preview_tenant_columns();
DROP PROCEDURE IF EXISTS add_preview_tenant_columns;
-- 2. 群消息路由元数据的唯一性增加商户维度。
ALTER TABLE `im_group_message_shards`
DROP PRIMARY KEY,
DROP INDEX `uk_im_group_message_shards_table`,
DROP INDEX `idx_im_group_message_shards_status`,
ADD PRIMARY KEY (`tenant_id`,`shard_month`),
ADD UNIQUE INDEX `uk_im_group_message_shards_table` (`tenant_id`,`table_name`),
ADD INDEX `idx_im_group_message_shards_status` (`tenant_id`,`status`,`shard_month`);
DROP TABLE IF EXISTS `im_group_message_topic_shards`;
DROP TABLE IF EXISTS `im_group_message_dedup`;
-- 3. 创建商户 2 的好友与单聊消息分表并复制快照数据。
CREATE TABLE `im_002_friendships` LIKE `im_friendships`;
ALTER TABLE `im_002_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT INTO `im_002_friendships` SELECT * FROM `im_friendships` WHERE `tenant_id`=2;
CREATE TABLE `im_002_messages` LIKE `im_messages`;
ALTER TABLE `im_002_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT INTO `im_002_messages` SELECT * FROM `im_messages` WHERE `tenant_id`=2;
-- 4. 将所有现存标准月表复制为商户 2 的月表。
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_preview_group_month_tables$$
CREATE PROCEDURE migrate_preview_group_month_tables()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE source_table VARCHAR(64);
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE month_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema='meeting_im_sharding_preview'
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN month_cursor;
month_loop: LOOP
FETCH month_cursor INTO source_table;
IF done = 1 THEN
LEAVE month_loop;
END IF;
SET month_code = RIGHT(source_table, 6);
SET target_table = CONCAT('im_002_group_messages_', month_code);
SET @create_sql = CONCAT('CREATE TABLE `', target_table, '` LIKE `', source_table, '`');
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @alter_sql = CONCAT('ALTER TABLE `', target_table,
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT ''商户ID''');
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @copy_sql = CONCAT('INSERT INTO `', target_table, '` SELECT * FROM `', source_table,
'` WHERE `tenant_id`=2');
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE month_cursor;
END$$
DELIMITER ;
CALL migrate_preview_group_month_tables();
DROP PROCEDURE IF EXISTS migrate_preview_group_month_tables;
UPDATE `im_group_message_shards`
SET `table_name`=CONCAT('im_002_group_messages_',`shard_month`)
WHERE `tenant_id`=2;
-- 5. 补齐 default 商户(ID=1,分表编号 001)的核心分表。
-- tinode 是 default 商户的原库,只从该库读取,不修改原表。
CREATE TABLE `im_001_friendships` LIKE `im_friendships`;
ALTER TABLE `im_001_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT INTO `im_001_friendships` (
`id`,`tenant_id`,`user_id`,`peer_id`,`status`,`remark`,`topic`,`created_at`,`updated_at`
)
SELECT `id`,1,`user_id`,`peer_id`,`status`,`remark`,`topic`,`created_at`,`updated_at`
FROM `tinode`.`im_friendships`;
CREATE TABLE `im_001_messages` LIKE `im_messages`;
ALTER TABLE `im_001_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT INTO `im_001_messages` (
`id`,`tenant_id`,`createdat`,`updatedat`,`deletedat`,`delid`,`seqid`,`topic`,`from`,
`client_msg_id`,`version`,`recalled_at`,`recalled_by`,`head`,`content`,`content_nonce`,`key_version`
)
SELECT `id`,1,`createdat`,`updatedat`,`deletedat`,`delid`,`seqid`,`topic`,`from`,
`client_msg_id`,`version`,`recalled_at`,`recalled_by`,`head`,`content`,`content_nonce`,`key_version`
FROM `tinode`.`im_messages`;
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_default_group_month_tables$$
CREATE PROCEDURE migrate_default_group_month_tables()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE source_table VARCHAR(64);
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE month_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema='tinode'
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN month_cursor;
month_loop: LOOP
FETCH month_cursor INTO source_table;
IF done = 1 THEN
LEAVE month_loop;
END IF;
SET month_code = RIGHT(source_table, 6);
SET target_table = CONCAT('im_001_group_messages_', month_code);
SET @create_sql = CONCAT('CREATE TABLE `', target_table,
'` LIKE `im_group_messages_', month_code, '`');
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @alter_sql = CONCAT('ALTER TABLE `', target_table,
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT ''商户ID''');
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @copy_sql = CONCAT(
'INSERT INTO `', target_table, '` (`tenant_id`,`message_id`,`topic`,`group_id`,`seqid`,',
'`from_user`,`client_msg_id`,`msg_type`,`status`,`content`,`content_nonce`,`key_version`,',
'`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,`file_count`,',
'`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,',
'`deleted_at`,`expires_at`,`created_at`,`updated_at`) ',
'SELECT 1,`message_id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,',
'`status`,`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,',
'`reply_seqid`,`root_seqid`,`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,',
'`recalled_at`,`recalled_by`,`deleted_at`,`expires_at`,`created_at`,`updated_at` ',
'FROM `tinode`.`', source_table, '`'
);
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE month_cursor;
END$$
DELIMITER ;
CALL migrate_default_group_month_tables();
DROP PROCEDURE IF EXISTS migrate_default_group_month_tables;
INSERT INTO `im_group_message_shards` (
`tenant_id`,`shard_month`,`table_name`,`shard_start`,`shard_end`,`status`,`row_count`,
`min_created_at`,`max_created_at`,`created_at`,`updated_at`
)
SELECT 1,`shard_month`,CONCAT('im_001_group_messages_',`shard_month`),`shard_start`,`shard_end`,
`status`,`row_count`,`min_created_at`,`max_created_at`,`created_at`,`updated_at`
FROM `tinode`.`im_group_message_shards`;
-- Topic-to-month routes are Redis-backed and are rebuilt by runtime reads.
-- 以实际复制后的物理表为准刷新统计,避免源库路由统计滞后。
DELIMITER $$
DROP PROCEDURE IF EXISTS refresh_default_group_shard_stats$$
CREATE PROCEDURE refresh_default_group_shard_stats()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE shard_cursor CURSOR FOR
SELECT `shard_month`
FROM `im_group_message_shards`
WHERE `tenant_id`=1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN shard_cursor;
shard_loop: LOOP
FETCH shard_cursor INTO month_code;
IF done = 1 THEN
LEAVE shard_loop;
END IF;
SET target_table = CONCAT('im_001_group_messages_', month_code);
SET @stats_sql = CONCAT(
'UPDATE `im_group_message_shards` SET ',
'`row_count`=(SELECT COUNT(*) FROM `', target_table, '`),',
'`min_created_at`=(SELECT MIN(`created_at`) FROM `', target_table, '`),',
'`max_created_at`=(SELECT MAX(`created_at`) FROM `', target_table, '`) ',
'WHERE `tenant_id`=1 AND `shard_month`=''', month_code, ''''
);
PREPARE stmt FROM @stats_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE shard_cursor;
END$$
DELIMITER ;
CALL refresh_default_group_shard_stats();
DROP PROCEDURE IF EXISTS refresh_default_group_shard_stats;
-- 6. 验证:missing_tenant_id 必须为 0,两个商户的分表与路由均存在。
SELECT COUNT(*) AS missing_tenant_id
FROM information_schema.tables t
WHERE t.table_schema='meeting_im_sharding_preview'
AND t.table_type='BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema=t.table_schema
AND c.table_name=t.table_name
AND c.column_name='tenant_id'
);
SELECT 'friendships' AS data_type,
(SELECT COUNT(*) FROM `im_friendships` WHERE `tenant_id`=2) AS source_count,
(SELECT COUNT(*) FROM `im_002_friendships`) AS target_count
UNION ALL
SELECT 'messages',
(SELECT COUNT(*) FROM `im_messages` WHERE `tenant_id`=2),
(SELECT COUNT(*) FROM `im_002_messages`);
SELECT s.`shard_month`,s.`table_name`,s.`status`,s.`row_count`
FROM `im_group_message_shards` s
WHERE s.`tenant_id` IN (1,2)
ORDER BY s.`tenant_id`,s.`shard_month`;
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema='meeting_im_sharding_preview'
AND t.table_name REGEXP '^im_(001|002)_(friendships|messages|group_messages_[0-9]{6})$'
ORDER BY t.table_name;
@@ -0,0 +1,134 @@
-- MySQL 8.0. Runtime fixes for the shared meeting_im database.
-- Execute with a privileged account that can read hotgo.im_tenant.
CREATE TABLE IF NOT EXISTS im_user_account_sequences (
tenant_id BIGINT NOT NULL,
id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- If this table was created by the older independent-database script, its
-- primary key may still be only (id). Shared DB must key by tenant_id too.
ALTER TABLE im_user_account_sequences
DROP PRIMARY KEY,
ADD PRIMARY KEY (tenant_id, id);
INSERT INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM im_users
WHERE tenant_id > 0
GROUP BY tenant_id
ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value));
INSERT IGNORE INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT id, 1, 999
FROM hotgo.im_tenant
WHERE state = 1;
DROP TABLE IF EXISTS im_login_lockouts;
-- HTTP write idempotency is Redis-backed. Keep it out of MySQL.
DROP TABLE IF EXISTS im_http_idempotency;
DROP TABLE IF EXISTS im_group_idempotency;
DROP TABLE IF EXISTS im_group_message_dedup;
DROP TABLE IF EXISTS im_group_message_topic_shards;
DROP TABLE IF EXISTS im_kafka_outbox;
DROP TABLE IF EXISTS im_kafka_consume_log;
DROP TABLE IF EXISTS im_business_events_outbox;
DROP TABLE IF EXISTS im_group_events_outbox;
DROP TABLE IF EXISTS im_login_failure_logs;
DROP TABLE IF EXISTS im_action_tokens;
-- User identity indexes must be unique inside one tenant, not globally.
ALTER TABLE im_users
DROP INDEX uk_im_users_account_no,
DROP INDEX users_phone,
DROP INDEX users_username,
ADD UNIQUE INDEX uk_im_users_tenant_account_no (tenant_id, account_no),
ADD UNIQUE INDEX uk_im_users_tenant_phone (tenant_id, phone),
ADD UNIQUE INDEX uk_im_users_tenant_username (tenant_id, username);
ALTER TABLE im_auth
DROP INDEX auth_userid,
ADD UNIQUE INDEX auth_tenant_userid (tenant_id, userid);
-- Topic/subscription runtime indexes for shared-database deployments.
-- These tables are on hot paths for topic lookup, message seq updates,
-- unread calculation and subscription permission checks. Keep tenant_id as
-- the leading column so one tenant does not scan another tenant's rows.
SET @schema_name := DATABASE();
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_topics'
AND index_name = 'uk_topics_tenant_name'
) = 0,
'ALTER TABLE im_topics ADD UNIQUE INDEX uk_topics_tenant_name(tenant_id, name)',
'SELECT ''skip uk_topics_tenant_name'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_topics'
AND index_name = 'idx_topics_tenant_name_state_seqid'
) = 0,
'ALTER TABLE im_topics ADD INDEX idx_topics_tenant_name_state_seqid(tenant_id, name, state, seqid)',
'SELECT ''skip idx_topics_tenant_name_state_seqid'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_topics'
AND index_name = 'idx_topics_tenant_owner'
) = 0,
'ALTER TABLE im_topics ADD INDEX idx_topics_tenant_owner(tenant_id, owner)',
'SELECT ''skip idx_topics_tenant_owner'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_subscriptions'
AND index_name = 'idx_subscriptions_tenant_topic_deletedat'
) = 0,
'ALTER TABLE im_subscriptions ADD INDEX idx_subscriptions_tenant_topic_deletedat(tenant_id, topic, deletedat)',
'SELECT ''skip idx_subscriptions_tenant_topic_deletedat'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_subscriptions'
AND index_name = 'idx_subscriptions_tenant_user_topic_deletedat'
) = 0,
'ALTER TABLE im_subscriptions ADD INDEX idx_subscriptions_tenant_user_topic_deletedat(tenant_id, userid, topic, deletedat)',
'SELECT ''skip idx_subscriptions_tenant_user_topic_deletedat'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Default app release rows used by /v1/im/app/releases/latest.
INSERT INTO hotgo.im_app_releases (
platform, channel, latest_version, min_supported_version, update_type,
release_notes, remind_after_seconds
) VALUES
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
('web', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0)
ON DUPLICATE KEY UPDATE
latest_version = VALUES(latest_version),
min_supported_version = VALUES(min_supported_version),
update_type = VALUES(update_type),
release_notes = VALUES(release_notes),
remind_after_seconds = VALUES(remind_after_seconds),
updated_at = CURRENT_TIMESTAMP(3);
+7 -29
View File
@@ -1,8 +1,9 @@
-- MySQL 8.0. Admin message governance and authentication audit tables. -- MySQL 8.0. Admin message governance and authentication audit tables.
-- Execute this script in each tenant business database. -- Execute this script in the shared meeting_im database; tenant_id provides tenant isolation.
CREATE TABLE IF NOT EXISTS im_user_login_logs ( CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '', username VARCHAR(64) NOT NULL DEFAULT '',
account_type VARCHAR(24) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '',
@@ -24,40 +25,17 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs (
metadata JSON NULL, metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_session_close (tenant_id, session_id, logout_at),
INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC), INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC), INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time (device_id(64), login_at DESC), INDEX idx_im_user_login_device_time (device_id(64), login_at DESC),
INDEX idx_im_user_login_request (request_id) INDEX idx_im_user_login_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_login_failure_logs ( DROP TABLE IF EXISTS im_login_failure_logs;
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NULL, DROP TABLE IF EXISTS im_login_lockouts;
account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '',
failure_type VARCHAR(32) NOT NULL,
reason VARCHAR(96) NOT NULL DEFAULT '',
detail VARCHAR(500) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium',
risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
request_id VARCHAR(64) NOT NULL DEFAULT '',
attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_login_failure_account_time (account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time (user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time (ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time (failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_message_admin_actions ( CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -0,0 +1,5 @@
-- Persist client-provided upload filename for downstream business modules.
-- Apply before deploying im_server changes which read/write im_fileuploads.original_name.
ALTER TABLE im_fileuploads
ADD COLUMN original_name VARCHAR(255) NOT NULL DEFAULT '' AFTER location;
@@ -0,0 +1,44 @@
-- IM file-message link msgid Snowflake compatibility migration.
-- Scope: the shared business database (for example meeting_im).
-- Fixes: im_filemsglinks.msgid was INT (max 2147483647) while message ids are
-- snowflake int64 values (~1.58e18), so every INSERT into the link table
-- failed with MySQL error 1264 "Out of range value for column 'msgid'".
-- Under Kafka persistence that failure retried forever and stalled the
-- consumer partition, blocking persistence for every message queued
-- behind it.
-- Notes: im_filemsglinks is a global (non tenant-sharded) table, but in
-- tenant-scoped-DB deployments every tenant database has its own copy —
-- run this script once per database.
-- Re-running is safe (MODIFY to the same shape is idempotent).
-- NOT REVERSIBLE: converting back to INT would truncate snowflake ids.
-- Review and back up before execution. This script does not replay dead letters.
-- 1. Preflight: confirm the live column shape before touching anything.
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_TYPE, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'im_filemsglinks'
AND COLUMN_NAME = 'msgid';
-- 2. Upgrade the column (keeps the nullable semantics of the original schema).
ALTER TABLE `im_filemsglinks`
MODIFY COLUMN `msgid` BIGINT DEFAULT NULL;
-- 3. Post-check: this query must return zero rows.
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'im_filemsglinks'
AND COLUMN_NAME = 'msgid'
AND DATA_TYPE <> 'bigint';
-- 4. Observe pending recovery work caused by the overflow (and, before the
-- startup schema-contract check existed, by any other drift). Replay only
-- after the post-check passes and the referenced files still exist.
SELECT status, last_reason, COUNT(*) AS entry_count,
MIN(created_at) AS first_created_at, MAX(created_at) AS last_created_at
FROM im_kafka_dead_letters
WHERE last_error LIKE '%Out of range value for column %'
OR last_error LIKE '%file lifecycle enqueue failed: not found%'
GROUP BY status, last_reason
ORDER BY status, last_reason;
@@ -0,0 +1,69 @@
-- IM private-message Snowflake ID compatibility migration.
-- Scope: the shared business database (for example meeting_im).
-- Review and back up before execution. This script does not replay dead letters.
-- 1. Preflight: every listed id column must become BIGINT.
SELECT TABLE_NAME, COLUMN_TYPE, IS_NULLABLE, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND (TABLE_NAME = 'im_messages' OR TABLE_NAME REGEXP '^im_[0-9]+_messages$')
AND COLUMN_NAME = 'id'
ORDER BY TABLE_NAME;
-- 2. Upgrade the template/base table used by new tenant shards.
ALTER TABLE `im_messages`
MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT;
-- 3. Upgrade every existing tenant private-message shard.
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_private_message_id_to_bigint$$
CREATE PROCEDURE migrate_private_message_id_to_bigint()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE shard_table VARCHAR(64);
DECLARE shard_cursor CURSOR FOR
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME REGEXP '^im_[0-9]+_messages$'
ORDER BY TABLE_NAME;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN shard_cursor;
shard_loop: LOOP
FETCH shard_cursor INTO shard_table;
IF done = 1 THEN
LEAVE shard_loop;
END IF;
SET @alter_message_id_sql = CONCAT(
'ALTER TABLE `', REPLACE(shard_table, '`', '``'),
'` MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT'
);
PREPARE alter_message_id_stmt FROM @alter_message_id_sql;
EXECUTE alter_message_id_stmt;
DEALLOCATE PREPARE alter_message_id_stmt;
END LOOP;
CLOSE shard_cursor;
END$$
CALL migrate_private_message_id_to_bigint()$$
DROP PROCEDURE migrate_private_message_id_to_bigint$$
DELIMITER ;
-- 4. Post-check: this query must return zero rows.
SELECT TABLE_NAME, COLUMN_TYPE, IS_NULLABLE, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND (TABLE_NAME = 'im_messages' OR TABLE_NAME REGEXP '^im_[0-9]+_messages$')
AND COLUMN_NAME = 'id'
AND DATA_TYPE <> 'bigint';
-- 5. Observe pending recovery work. Replay only after the post-check passes.
SELECT status, last_reason, COUNT(*) AS message_count,
MIN(created_at) AS first_created_at, MAX(created_at) AS last_created_at
FROM im_kafka_dead_letters
WHERE event_type = 'message.persist.p2p'
GROUP BY status, last_reason
ORDER BY status, last_reason;
+19 -5
View File
@@ -1,15 +1,29 @@
-- 在每个租户业务库执行。用于并发安全地生成 im_users.account_no。 -- 在 meeting_im 共库执行。用于并发安全地生成每个租户的 im_users.account_no。
CREATE TABLE IF NOT EXISTS im_user_account_sequences ( CREATE TABLE IF NOT EXISTS im_user_account_sequences (
tenant_id BIGINT NOT NULL,
id TINYINT UNSIGNED NOT NULL, id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL, next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3), ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id) PRIMARY KEY (tenant_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 兼容已有用户数据,从当前最大账号编号继续递增,账号编号最小为 1000。 -- 如果历史脚本已经创建过单库版 PRIMARY KEY(id),需要先改成共库主键。
INSERT INTO im_user_account_sequences (id, next_value) ALTER TABLE im_user_account_sequences
SELECT 1, GREATEST(COALESCE(MAX(account_no), 999), 999) DROP PRIMARY KEY,
ADD PRIMARY KEY (tenant_id, id);
-- 兼容已有用户数据,从每个租户当前最大账号编号继续递增,账号编号最小为 1000。
INSERT INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM im_users FROM im_users
WHERE tenant_id > 0
GROUP BY tenant_id
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value)); next_value = GREATEST(next_value, VALUES(next_value));
-- 如果当前租户暂时还没有用户,也要先塞一行,否则新注册无法生成账号编号。
INSERT IGNORE INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT id, 1, 999
FROM hotgo.im_tenant
WHERE state = 1;
@@ -0,0 +1,36 @@
-- 登录失败日志表(共库多租户版)。
-- 背景:登录失败日志仅记录触发账号锁定的事件(见 session.go lockedNow 分支),
-- 普通失败不再落库;锁定计数器存 Redis,im_login_lockouts 不再使用。
-- 执行范围:每个租户业务库(共库模式即 tinode / meeting_im 库各执行一次)。
-- 上游 scripts/IM-租户业务库初始化.sql 中的同名表无 tenant_id 列,勿用旧版执行。
CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`account_type` varchar(24) NOT NULL DEFAULT '',
`account_hash` char(64) NOT NULL DEFAULT '',
`account_mask` varchar(128) NOT NULL DEFAULT '',
`failure_type` varchar(32) NOT NULL,
`reason` varchar(96) NOT NULL DEFAULT '',
`detail` varchar(500) NOT NULL DEFAULT '',
`ip` varchar(64) NOT NULL DEFAULT '',
`region` varchar(128) NOT NULL DEFAULT '',
`device_id` varchar(255) NOT NULL DEFAULT '',
`platform` varchar(32) NOT NULL DEFAULT 'unknown',
`app_version` varchar(32) NOT NULL DEFAULT '',
`user_agent` varchar(512) NOT NULL DEFAULT '',
`risk_level` varchar(16) NOT NULL DEFAULT 'medium',
`risk_action` varchar(24) NOT NULL DEFAULT 'audit_only',
`request_id` varchar(64) NOT NULL DEFAULT '',
`attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`metadata` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_im_login_failure_tenant_time` (`tenant_id`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_request` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+3 -30
View File
@@ -17,36 +17,9 @@ CREATE TABLE IF NOT EXISTS im_group_message_shards (
KEY idx_im_group_message_shards_status (status, shard_month) KEY idx_im_group_message_shards_status (status, shard_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards ( DROP TABLE IF 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_im_group_message_topic_shards_seq (topic, max_seq),
KEY idx_im_group_message_topic_shards_month (shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_message_dedup ( DROP TABLE IF 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_im_group_message_dedup_expires (expires_at),
KEY idx_im_group_message_dedup_topic_seq (topic, seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_messages_202609 ( CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
message_id BIGINT NOT NULL, message_id BIGINT NOT NULL,
@@ -104,4 +77,4 @@ ON DUPLICATE KEY UPDATE
-- Historical migration is deliberately separate from schema deployment. -- Historical migration is deliberately separate from schema deployment.
-- Backfill im_group_messages into the matching month table in bounded batches, -- Backfill im_group_messages into the matching month table in bounded batches,
-- then populate im_group_message_topic_shards before removing legacy reads. -- then warm Redis topic routes before removing legacy reads.
@@ -249,9 +249,9 @@ ALTER TABLE im_topictags
ALTER TABLE im_subscriptions ALTER TABLE im_subscriptions
ADD UNIQUE KEY uk_subscriptions_tenant_topic_user (tenant_id, topic, userid), ADD UNIQUE KEY uk_subscriptions_tenant_topic_user (tenant_id, topic, userid),
ADD KEY idx_subscriptions_tenant_user_topic_deleted ADD KEY idx_subscriptions_tenant_user_topic_deletedat
(tenant_id, userid, topic, deletedat), (tenant_id, userid, topic, deletedat),
ADD KEY idx_subscriptions_tenant_topic_deleted ADD KEY idx_subscriptions_tenant_topic_deletedat
(tenant_id, topic, deletedat), (tenant_id, topic, deletedat),
DROP INDEX subscriptions_topic_userid; DROP INDEX subscriptions_topic_userid;
+604
View File
@@ -0,0 +1,604 @@
# MeetingIM Web 端全功能测试用例
> 编制日期:2026-10-16;三域名实测方案补充:2026-10-17
> 适用环境:MeetingIM Web 测试环境
> 测试方式:以 Web 页面人工操作为主,浏览器 Network、WebSocket Frames、接口响应和数据库核验为辅
> 依据:当前 `dev` 代码、`IM-前后端对接文档.md`、会议笔记/App 更新/青少年模式/第三方对接文档及现有问题清单
## 1. 测试目标
本文用于对 Web 端进行完整业务验收,覆盖:
1. 登录、注册、企业入口和账号安全。
2. WebSocket 建连、消息首页、单聊、群聊、消息状态和断线恢复。
3. 好友、黑名单、群成员治理、群公告和群文件。
4. 文件上传下载、收藏、转发、聊天记录搜索和媒体预览。
5. 音视频通话、工作台、组织、会议、日报、月报和会议笔记。
6. 个人资料、隐私、版本检查、举报、反馈和跨租户隔离。
7. 页面四态:加载、空数据、正常数据、失败/重试。
本文不把“接口返回 200”视为完成。每个用例至少同时检查页面结果、网络请求、刷新后的持久化结果;涉及双用户的场景还要检查接收方实时表现和离线补偿。
## 2. 当前服务边界
| 能力 | Web 请求入口 | 服务归属 |
| --- | --- | --- |
| 登录、聊天、好友、群、文件、通话、资料、安全 | `/v1/im/*`、`/v0/channels`、`/v0/file/*` | `im_server` |
| 工作台、组织、考勤、会议、会议笔记、日报、月报、信箱 | `/v1/workbench/*` | `im_workbench` |
| 用户条款、隐私政策 | `/legal/*` | `im_server` |
| Web 页面 | `https://web1.118im.com`、`https://web2.118im.com`、`https://web3.118im.com` | 本轮三个独立登录域名 |
| REST API | `https://api.118im.com` | API Gateway |
| WebSocket | `wss://api.118im.com/v0/channels?apikey=...` | API Gateway -> `im_server` |
工作台页面如果仍请求 `/v1/im/workbench`、`/v1/im/meetings` 等旧路径,应直接记录为前端路由缺陷。新路径必须是 `/v1/workbench/*`。
### 2.1 验收判定优先级
代码和文档可能处于不同迭代阶段,验收时按以下顺序判定:
1. 当前测试环境实际行为和当前 `dev` 代码是实现事实。
2. 专项文档(如工作台拆分、App 版本、会议笔记)优先于旧版综合文档。
3. `IM-前后端对接文档.md` 是完整目标契约;其中标记“新增”、“需改造”、“待实现”的内容必须通过实测确认,不能仅根据文档判定通过。
4. 历史问题清单只用于选取高风险回归场景,实测通过后应更新结论,不得将历史结论直接当作当前缺陷。
### 2.2 参考文档
- `docs/IM-前后端对接文档.md`
- `docs/IM-工作台服务拆分迁移稳定方案.md`
- `docs/IM-会议笔记前后端对接文档.md`
- `docs/IM-App版本更新前后端对接文档.md`
- `docs/third/IM-声网音视频通话完整调用流程.md`
- `docs/third/IM-文件安全第三方接入记录.md`
- `docs/problems/IM-后端问题清单 (3).md`
## 3. 测试环境与账号准备
### 3.1 三域名浏览器准备
| 编号 | 准备项 | 要求 |
| --- | --- | --- |
| ENV-01 | 页面 A | `https://web1.118im.com/tenant`,从企业码 `system` 进入,注册并登录用户 A,负责发起操作 |
| ENV-02 | 页面 B | `https://web2.118im.com/tenant`,从企业码 `system` 进入,注册并登录用户 B,负责接收、审批、回执 |
| ENV-03 | 页面 C | `https://web3.118im.com/tenant`,从企业码 `system` 进入,注册并登录用户 C,负责群第三成员、离线恢复和旁观校验 |
| ENV-04 | 开发者工具 | 保留 Network 日志,开启 Preserve log,显示 WS Frames |
| ENV-05 | 网络模拟 | 准备 Offline、Slow 3G、断网 15 秒后恢复三种场景 |
| ENV-06 | 缓存场景 | 对指定域名分别执行普通刷新、强制刷新、退出重登和清站点数据;不要一次清掉三个域名 |
三个域名用于在同一浏览器中并行保留三个登录态;实测时分别核对 Cookie 的 Domain、LocalStorage、Token 和 WebSocket,不能仅凭域名不同就推定不会串号。三个页面都选 `system`,此举只实现**不同账号并行登录同一商户**。跨租户用例需另备真实可用的第二商户,并另行执行。
**当前页面观察(2026-10-17)**:三个 `/tenant` 页均可输入企业码;`web1` 输入 `system` 可进入 `/login`,页面显示 `system merchant`。`/tenant` 的“企业注册”进入 `/enterprise-register`,其字段为统一社会信用代码、营业执照、企业邮箱和提交审核,属于**申请新企业**。当前 `/login` 可见控件中未出现“新用户注册”入口。开始执行第 3.4 节前,先完成 `FLOW-REG-01` 的入口检查;若入口仍缺失,记录 Web 前端阻塞,不要把企业申请当作用户 A/B/C 注册成功,也不要为了绕过页面手工插入账号后宣称注册用例通过。
### 3.2 测试账号矩阵
| 代号 | 租户 | 角色/关系 | 必备条件 |
| --- | --- | --- | --- |
| A | `system` | 新注册用户 | 在 `web1` 注册,作为发起方和群主;建议短信方式绑定手机号 |
| B | `system` | 新注册用户 | 在 `web2` 注册,作为接收方;建议有效邀请码方式验证推荐关系 |
| C | `system` | 新注册用户 | 在 `web3` 注册,作为第三方、离线方;与 A/B 使用不同手机号 |
| O | `system` | 群主 | 优先由 A 创建群 G1 后承担,无需额外账号 |
| M | `system` | 群管理员 | 由 A 将 B 提升为管理员 |
| N | `system` | 普通群成员 | 由 C 承担 |
| X | 另一个真实商户 | 跨租户用户 | 仅执行第 17 章时准备;不得把 `web3` 当成另一个商户 |
| P | `system` | 无手机号用户 | 仅当页面和服务端支持时注册;用于忘记密码负向测试 |
| R | `system` | 管理范围用户 | 如 A/B/C 均无组织管理权限,另备管理账号 |
注册前选定未使用的用户名/手机号,并准备 `system` 商户有效的邀请码;不要复用已有测试用户作为“新注册成功”的证据。注册完成后记录每个账号的 `username`、`public_account`、`usr...` 用户 ID、群 Topic 和初始资料版本。不要在本文或缺陷单中记录密码、完整 Token、验证码票据和 OSS 签名参数。
### 3.3 测试数据
| 数据 | 要求 |
| --- | --- |
| 群 G1 | A/O 为群主,B/M 为管理员,C/N 为普通成员,开启群公告和群文件 |
| 群 G2 | A 不在群内,用于越权测试 |
| 文件 F1 | 小于 1 MB 的 PNG |
| 文件 F2 | 2~5 MB 的 PDF |
| 文件 F3 | 10 MB 左右的视频 |
| 文件 F4 | 大于前端允许上限的文件 |
| 消息数据 | 文本、链接、图片、视频、语音、文件、名片、回复、已撤回消息各至少一条 |
| 工作台数据 | 部门、成员、会议、日报、月报、会议笔记各至少一条 |
### 3.4 从注册开始的三页面执行剧本
按下表顺序执行,记录每一步是否能通过**可见 Web 页面**完成。注册成功才进入好友、单聊和群聊;入口不可达时,后续依赖新账号的用例标记“阻塞”,可另用预置账号单独继续其他功能验收,但不覆盖注册结果。`system` 商户已存在,用户注册应提交 `registration_type=0` 和 `tenant_code=system`;“企业注册”审核表单不是本节注册动作。
| ID | 优先级 | 页面/操作 | 页面、Network/WS 与跨页预期 |
| --- | --- | --- | --- |
| FLOW-REG-01 | P0 | A 从 `web1/tenant` 输入 `system` 进入登录页,寻找“新用户注册”;B/C 同样检查 | 可从页面进入 **system 企业内用户注册**;若只有 `/enterprise-register`,记录实际页面截图和 URL,判“注册入口阻塞” |
| FLOW-REG-02 | P0 | A 在注册页填独立的用户名、手机号、密码,选择短信验证码 | 显示 `system` 企业上下文;注册请求带 `registration_type=0`、`tenant_code=system`,不进入 `default` |
| FLOW-REG-03 | P0 | A 点击获取验证码,输入测试环境当前有效验证码,完成校验并提交注册 | 创建 challenge,按接口倒计时;校验后一次性票据用于注册;成功返回 `usr...`、最终 username、Token,A 进入消息首页;测试码 `123456` 仅在该部署仍明确启用时使用 |
| FLOW-REG-04 | P0 | B 在 `web2` 使用另一组用户名/手机号和有效 5 位邀请码注册 | 统一输入框识别邀请码,保存手机号和推荐关系;邀请码方式不冒充“手机号已短信验证”;B 进入独立登录态 |
| FLOW-REG-05 | P0 | C 在 `web3` 使用第三组用户名/手机号注册 | C 与 A/B 均属于 `system` 且 ID 各异;三个域名同时在线,各自显示自己的资料和 WebSocket |
| FLOW-REG-06 | P0 | 三页分别刷新,再单独退出并重新登录 B | A/C 仍保持各自会话;B 退出后只清 `web2` 登录态,重登到 B,不显示 A/C 的会话或缓存 |
| FLOW-REG-06A | P0 | A/B/C 三个页面同时在线,分别查看个人资料和会话页 | 各页身份、Token 和 WS 登录用户分别对应 A/B/C;如果共享顶级域 Cookie 导致串号,记录为多开阻塞 |
| FLOW-REG-07 | P0 | A 搜索 B 并申请好友,B 在 `web2` 接受,A 给 B 发消息 | B 实时出现申请/消息;A/B 会话、未读、回执、历史一致;C 无关页面不误收单聊 |
| FLOW-REG-08 | P0 | A 创建 G1 并邀请 B/C,B/C 分别加入 | 三页成员数一致;A 发群消息 B/C 实时收到,B/C 回信 A 可见 |
| FLOW-REG-09 | P0 | 将 C 页面断网,A/B 继续在 G1 发消息,C 恢复网络 | C 按群 Topic seq 补齐离线消息,顺序正确、无重复,未读数只计算 C 自己的状态 |
| FLOW-REG-10 | P1 | A 把 B 设为管理员;B 修改公告或审批入群,C 查看 | B 的治理入口和服务端权限同步;C 仅见自己有权看的通知,三页刷新后状态一致 |
| FLOW-REG-11 | P1 | A 给 B 发图片/PDF;B 下载,C 从群内访问群文件 | 上传走 OSS/文件服务;仅授权会话/群成员能下载;文件内容与原文件一致 |
| FLOW-REG-12 | P1 | 三页依次完成资料修改、收藏、搜索、工作台检查 | 每次操作按第 7~16 章对应详细用例记录;A/B/C 的资料、未读和权限不能串号 |
| FLOW-REG-13 | P1 | 同一 `system` 商户内用独立新账号再次输入 B 已用的用户名或手机号 | 分别提示用户名/手机号已占用,不覆盖 B,也不误判为跨租户冲突 |
| FLOW-REG-14 | P1 | 依次输入 2 位用户名、大写或下划线用户名、无效邀请码、错误或过期验证码 | 页面给出可定位的校验错误;绕过前端提交仍由服务端拒绝;不创建用户 |
| FLOW-REG-15 | P1 | A 注册完成后在 `web2` 用 A 登录,再从 `web2` 登出 | A 在 `web1` 的会话按产品多端规则保持或收到明确互踢提示;B/C 账号不能被顶替或清空 |
注册失败分支还需覆盖:用户名重复或格式非法、手机号重复、密码不符合策略、验证码错误/过期/重放、无效/过期/撤销邀请码、频繁取码、`system` 商户停用或用户配额满。负向用例不得消耗 A/B/C 计划使用的有效用户名和手机号。
## 4. 通用验收规则
### 4.1 每条用例必须记录
| 字段 | 内容 |
| --- | --- |
| 测试结果 | 通过 / 失败 / 阻塞 / 不适用 |
| 测试账号 | 账号代号,不写密码 |
| 时间 | 精确到秒,便于查询日志 |
| Request ID | HTTP 响应或请求头中的 `X-Request-ID` |
| Topic/Seq | 消息相关用例记录 Topic 和 Seq,按字符串保存 |
| 截图 | 页面结果和必要的 Network/WS Frames |
| 缺陷说明 | 实际结果、预期结果、复现率、是否刷新后仍存在 |
### 4.2 通用网络检查
所有 `/v1/im/*` 和 `/v1/workbench/*` 请求应满足:
- 使用 HTTPS,不出现 Mixed Content。
- 登录后携带 `Authorization: Bearer ...`,日志中必须打码。
- 携带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`。
- 写请求携带 `Idempotency-Key`;重试同一操作不得重复创建数据。
- 成功响应包含 `code=0`、`request_id`、`server_time`。
- 失败时前端按稳定 `reason` 展示本地化文案,不能直接展示服务端英文 `message`。
- ID、Topic、Seq、cursor 在 JavaScript 中按字符串处理,不能发生精度丢失或科学计数法。
### 4.3 WebSocket 检查
- Upgrade 成功后第一条业务帧必须是 `hi`,且携带当前企业码。
- `hi` 成功后才允许 `login`;登录成功后再订阅 `me`、`fnd` 和会话 Topic。
- 页面不能建立无上限重复连接;刷新、重登、切换企业后旧连接应关闭。
- 断线重连后按 Topic seq 增量补偿,不重复展示、不丢消息。
- 同一消息重发使用相同 `client_mid`,服务端只能生成一条业务消息。
## 5. P0 冒烟用例
以下用例应在每次 Web 发版后优先执行。
| ID | 场景 | Web 操作 | 预期结果 |
| --- | --- | --- | --- |
| SMK-01 | 打开企业入口 | 依次访问 `web1`、`web2`、`web3` 的 `/tenant` | 页面正常加载,无白屏、无限刷新和 404 静态资源 |
| SMK-01A | 新用户注册入口 | 三页输入 `system` 后,从登录页打开新用户注册 | 进入 `system` 内用户注册页;若只有企业申请入口,记录 P0 阻塞 |
| SMK-01B | 三账号注册 | A/B/C 各在指定域名完成注册 | 三个新账号均属于 `system`,各自进入消息首页且互不串号 |
| SMK-02 | 企业登录 | 在 `web1` 输入 `system`、A 账号密码登录 | `hi` 绑定 `system`,登录成功进入消息页 |
| SMK-03 | Token 恢复 | 刷新页面 | 使用 Token 恢复,不再次要求输入密码,旧 WS 被关闭 |
| SMK-04 | 消息首页 | 进入消息页 | 会话列表、未读数、系统消息入口正常 |
| SMK-05 | 单聊文本 | A 给 B 发消息 | A 获得 ACK/seq,B 实时收到且刷新后仍存在 |
| SMK-06 | 单聊图片 | A 上传并发送 PNG | B 可查看原图,刷新后可再次打开 |
| SMK-07 | 已读回执 | B 打开会话 | A 端已读状态更新且游标不回退 |
| SMK-08 | 好友列表 | 打开通讯录 | 好友昵称、备注、头像、在线状态正常 |
| SMK-09 | 群聊文本 | A 在 G1 发消息 | 在线成员实时收到,离线后重连可补偿 |
| SMK-10 | 群成员 | 打开 G1 成员页 | 成员数量和列表一致,分页无重复/缺失 |
| SMK-11 | 文件下载 | 发送并下载 PDF | 下载成功,文件大小和内容正确 |
| SMK-12 | 个人资料 | 修改昵称后刷新 | 当前页、会话、好友/群成员投影最终一致 |
| SMK-13 | 工作台 | 打开工作台 | 请求走 `/v1/workbench/*`,模块正常显示 |
| SMK-14 | 登出 | 点击退出登录 | Token 被撤销,返回登录页,后续请求为 401 |
| SMK-15 | 跨租户 | 用 `system` Token 请求另一个真实商户的数据 | 返回 403/404,不出现跨租户数据;需额外商户数据,不能用 `web3` 代替 |
## 6. 登录、注册与账号安全
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-AUTH-01 | P0 | 企业码 `system` + 正确账号密码登录 | 登录成功;URL、日志、LocalStorage 不出现明文密码 |
| WEB-AUTH-02 | P0 | 输入错误密码 | 页面显示“账号或密码错误”,保留账号、清空密码,不泄露账号是否存在 |
| WEB-AUTH-03 | P0 | 输入不存在账号 | 与错误密码的状态码、页面文案一致 |
| WEB-AUTH-04 | P0 | 输入不存在或停用企业码 | 显示企业不可用,不建立已登录 Session |
| WEB-AUTH-05 | P0 | A 登录后切到另一个真实商户再刷新 | 不得携带 `system` Token 自动进入;应清租户缓存并重新登录 |
| WEB-AUTH-06 | P0 | 登录成功后普通刷新、强刷、关闭再打开 | Token 恢复稳定,只保留一个有效 WebSocket |
| WEB-AUTH-07 | P1 | 连续快速点击登录按钮 | 只提交一次或正确禁用按钮,不产生并发登录风暴 |
| WEB-AUTH-08 | P1 | 模拟登录接口 Slow 3G | 展示加载态,可恢复,不允许重复提交 |
| WEB-AUTH-09 | P1 | 在注册页打开用户条款和隐私政策 | 在页内弹窗加载 `/legal/*` 正文,无需登录;关闭后保留注册表单,不自动勾选协议 |
| WEB-AUTH-10 | P0 | 从登录页进入 `system` 商户的新用户注册页 | 页面入口可见,注册按企业用户 `registration_type=0`、`tenant_code=system` 处理;不得进入 `/enterprise-register` 企业申请流程 |
| WEB-AUTH-11 | P0 | 用户名注册,验证码框填写有效 5 位邀请码 | 注册成功,邀请码可重复使用,建立推荐关系 |
| WEB-AUTH-12 | P0 | 手机号注册,使用测试验证码 | 验证成功后注册;倒计时以接口返回值为准 |
| WEB-AUTH-13 | P1 | 验证码填写错误、过期、重复使用 | 分别显示明确状态,已消费票据不可重放 |
| WEB-AUTH-14 | P1 | 倒计时内重复点击获取验证码 | 按 `retry_after_seconds` 禁用,不生成多个有效挑战 |
| WEB-AUTH-15 | P0 | 有手机号账号执行忘记密码 | 发码、校验、重置成功;旧 Token 和旧密码失效 |
| WEB-AUTH-16 | P1 | 无手机号账号 P 点击忘记密码 | 提示联系管理员,不进入短信流程 |
| WEB-AUTH-17 | P0 | 登录后点击退出 | `/v1/im/auth/logout` 成功,缓存和 Token 清除,后退不能回主页面 |
| WEB-AUTH-18 | P1 | 两个浏览器登录同一账号,一个修改密码 | 旧 Token 失效;另一端退出或下次请求进入登录页 |
| WEB-AUTH-19 | P1 | 直接篡改 LocalStorage 中 Token | 返回认证失败,不出现重试死循环 |
| WEB-AUTH-20 | P1 | 从 `/tenant` 单独进入企业申请页上传营业执照,或进入问题反馈页上传截图 | 上传进度和错误态正常,文件走 OSS/文件服务,不把 base64 正文塞进业务 JSON;企业申请成功不等于 `system` 用户注册成功 |
## 7. 启动、导航、会话列表与系统通知
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-HOME-01 | P0 | 登录后进入首页 | bootstrap、WS 订阅和会话列表顺序正确,无重复初始化 |
| WEB-HOME-02 | P0 | 检查总未读数和每会话未读数 | 数量一致;打开会话后对应未读归零,总数同步减少 |
| WEB-HOME-03 | P1 | 完成三账号主流程后,A 再从 `web2` 登录同一账号,一端读消息 | 另一端未读状态最终同步,不出现负数;测试结束后 `web2` 重新登录 B |
| WEB-HOME-04 | P1 | 置顶一个群聊 | 刷新后仍置顶;取消后恢复更新时间排序 |
| WEB-HOME-05 | P1 | 置顶一个单聊 | 刷新后列表可识别置顶;若页面丢失置顶,记录批量设置读取缺口 |
| WEB-HOME-06 | P1 | 设置会话免打扰 | 刷新后保持,来消息不改变设置 |
| WEB-HOME-07 | P0 | 浏览器切换 Offline 15 秒,B 连续发 3 条消息,再恢复 | A 自动重连并补齐 3 条,顺序正确、无重复 |
| WEB-HOME-08 | P1 | WS 重连期间切换会话 | 页面不白屏,不把消息写入错误会话 |
| WEB-HOME-09 | P1 | 搜索会话名称和消息摘要 | 只显示当前租户可见会话,清空关键字恢复完整列表 |
| WEB-HOME-10 | P1 | 打开系统消息页 | 好友、群、会议、安全等通知标题和摘要可读,不显示机器码 |
| WEB-HOME-11 | P1 | 点击系统通知 | 跳转到正确对象;已删除对象显示合理空态 |
| WEB-HOME-12 | P1 | 同一通知重复标记已读 | 已读时间保持第一次持久化值,不重复增加未读/已读计数 |
| WEB-HOME-13 | P1 | 空账号首次登录 | 会话页显示空态和可用入口,不出现错误占位 |
| WEB-HOME-14 | P0 | 把 API Key 改成无效值后打开页面 | WS Upgrade 403,页面提示环境配置问题并停止无限重连 |
## 8. 单聊与消息状态
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-P2P-01 | P0 | A 从好友页点击“发消息” | 创建/取得唯一 P2P Topic,进入正确会话 |
| WEB-P2P-02 | P0 | A 连续发送 10 条文本 | Seq 严格递增,B 顺序一致,刷新后历史一致 |
| WEB-P2P-03 | P0 | 发送按钮双击或网络重试 | 相同 `client_mid` 只落一条消息 |
| WEB-P2P-04 | P1 | 发送空白、超长文本 | 前端拦截;绕过前端后服务端仍校验 |
| WEB-P2P-05 | P0 | B 在线接收 | B 无需刷新实时收到;会话摘要和未读更新 |
| WEB-P2P-06 | P0 | B 离线,A 发消息,B 再登录 | B 按 seq 拉取补偿;Web 不依赖移动 Push 才能恢复消息 |
| WEB-P2P-07 | P1 | B 收到后不打开/打开会话 | A 端送达、已读状态分别正确变化 |
| WEB-P2P-08 | P1 | A 删除本地会话后重新搜索 B | 历史保留策略符合产品约定,不重复创建 Topic |
| WEB-P2P-09 | P0 | A 拉黑 B,B 尝试发送 | 服务端拒绝;A 不收到;双方页面关系状态一致 |
| WEB-P2P-10 | P0 | A 删除 B 好友,双方尝试发送 | 历史可查看,新增消息被拒绝并展示好友关系提示 |
| WEB-P2P-11 | P1 | A 修改 B 的备注 | 好友列表、资料页、会话标题一致;原昵称仍可查看 |
| WEB-P2P-12 | P1 | 向上翻页历史消息 | 使用 WS `get data before/limit`,无重复、无跳页 |
| WEB-P2P-13 | P1 | 清除聊天记录 | 只影响当前用户可见历史,不误删对方数据;刷新后保持 |
| WEB-P2P-14 | P1 | 直接在地址栏访问无权限 P2P Topic | 返回无权限/不存在,不泄露对方资料 |
## 9. 消息类型与消息操作
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-MSG-01 | P0 | 发送 Unicode 表情和多行文本 | 编码正确,刷新后内容一致 |
| WEB-MSG-02 | P1 | 发送网页链接 | 显示链接卡片或可点击链接;收藏“链接”分类可检索到 |
| WEB-MSG-03 | P0 | 上传并发送图片 | 本地预览、上传中、成功、失败重试状态完整 |
| WEB-MSG-04 | P0 | 上传并发送视频 | 封面、大小、时长字段正常,可播放/下载 |
| WEB-MSG-05 | P1 | 浏览器授权麦克风后发送语音 | 授权拒绝有提示;授权后录制、取消、发送正常 |
| WEB-MSG-06 | P0 | 发送 PDF/普通文件 | 文件名、大小、类型正确,收件人可下载 |
| WEB-MSG-07 | P1 | 发送联系人名片 | 点击名片进入正确用户资料,不串租户 |
| WEB-MSG-08 | P1 | 回复一条消息 | 引用摘要正确;原消息撤回后引用展示降级合理 |
| WEB-MSG-09 | P1 | 群聊 @单个成员 | 被提醒人收到正确提示,其他成员无错误强提醒 |
| WEB-MSG-10 | P1 | 群主/管理员使用 @所有人 | 权限允许;普通成员绕过 UI 调用应被拒绝 |
| WEB-MSG-11 | P0 | 在允许时间内撤回自己的消息 | 双方实时变成“已撤回”,刷新后仍一致 |
| WEB-MSG-12 | P1 | 超时撤回、撤回他人消息 | 服务端拒绝,原消息不变 |
| WEB-MSG-13 | P1 | 编辑文本消息 | 内容和版本更新;另一端实时或通过 mutation 水位同步 |
| WEB-MSG-14 | P1 | 多选删除“仅自己” | 当前用户隐藏,其他用户仍可见 |
| WEB-MSG-15 | P1 | 逐条转发到单聊和群聊 | 每条消息结构、发送人和文件引用正确 |
| WEB-MSG-16 | P1 | 合并转发多条混合消息 | 打开合并卡片能看到稳定快照,不依赖源会话权限 |
| WEB-MSG-17 | P0 | 并发快速转发 12 次 | 页面成功/失败必须与实际落库一致,不得失败后实际已发送 |
| WEB-MSG-18 | P1 | 带附言转发 | 附言和转发内容类型一致,接收端可正常渲染 |
| WEB-MSG-19 | P1 | 批量撤回部分成功、部分失败 | 页面逐条展示结果,成功项撤回,失败项保留 |
| WEB-MSG-20 | P1 | 页面刷新后同步消息变更 | mutation 水位连续,不重复应用撤回/编辑事件 |
## 10. 通讯录、好友与黑名单
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-FRIEND-01 | P0 | 打开通讯录 | 分组、首字母、搜索、头像、备注正确 |
| WEB-FRIEND-02 | P0 | 精确搜索 C 的用户名/公开账号 | 返回 C,ID 保持 `usr...`,可进入资料页 |
| WEB-FRIEND-03 | P0 | 搜索跨租户 X | 不返回结果,不泄露用户存在性 |
| WEB-FRIEND-04 | P1 | C 设置“仅好友可见”,A 查看 C 资料 | 非好友返回受限页/403;不能显示完整隐私资料 |
| WEB-FRIEND-05 | P0 | A 向 C 发好友申请 | C 的新朋友入口实时或刷新后出现,未读数增加 |
| WEB-FRIEND-06 | P1 | 重复发送同一好友申请 | 不创建重复申请,页面显示当前状态 |
| WEB-FRIEND-07 | P0 | C 接受申请 | 双方好友列表出现,P2P Topic 可用,系统通知正确 |
| WEB-FRIEND-08 | P1 | C 拒绝申请 | A 收到可读通知,双方不建立好友关系 |
| WEB-FRIEND-09 | P1 | 接受/拒绝不存在或已处理申请 | 返回资源不存在/状态冲突,不出现 500 |
| WEB-FRIEND-10 | P1 | A 修改好友备注后刷新资料页 | 备注保持,原昵称单独存在,不被备注覆盖丢失 |
| WEB-FRIEND-11 | P0 | A 拉黑 B | 黑名单页出现;好友列表、会话上下文和资料页状态一致 |
| WEB-FRIEND-12 | P0 | B 在被拉黑后发送/呼叫 A | 服务端拒绝,A 不收到消息或呼叫 |
| WEB-FRIEND-13 | P1 | A 解除拉黑 | 黑名单移除,关系按业务规则恢复,不自动制造错误好友关系 |
| WEB-FRIEND-14 | P1 | 批量移出黑名单 | 成功和失败项分别显示,刷新后保持 |
| WEB-FRIEND-15 | P0 | A 删除 B 好友 | 双方关系解除,历史保留,发送权限关闭 |
| WEB-FRIEND-16 | P1 | 生成个人二维码并解析 | 展示资料正确;过期、跨用途 Token 被拒绝 |
## 11. 群聊、成员与治理
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-GRP-01 | P0 | A 创建群并选择 B/N | 创建成功,群主/成员/订阅一致,成员数准确 |
| WEB-GRP-02 | P1 | 重复点击创建或重放幂等键 | 只创建一个群 |
| WEB-GRP-03 | P0 | 打开群列表并按拥有/管理/加入筛选 | 分类和角色正确,分页无重漏 |
| WEB-GRP-04 | P0 | 修改群名、头像、简介 | 所有在线成员实时或刷新后看到新资料 |
| WEB-GRP-05 | P1 | 使用旧 version 并发修改群资料 | 一个成功,一个 `VERSION_CONFLICT`,页面可刷新恢复 |
| WEB-GRP-06 | P0 | 拉取全部群成员 | 首屏和分页完整,成员总数与实际列表一致 |
| WEB-GRP-07 | P1 | 点击单个成员资料 | 只请求单成员详情,不重复拉全量成员页 |
| WEB-GRP-08 | P1 | 搜索成员、按管理员角色筛选 | 写操作后结果及时更新,不出现 90 秒旧缓存 |
| WEB-GRP-09 | P0 | O 将 N 设为管理员 | 角色、权限按钮、成员列表和 WS 管理事件一致 |
| WEB-GRP-10 | P0 | O 取消 M 管理员 | M 立即失去治理权限,绕过 UI 调用也被拒绝 |
| WEB-GRP-11 | P0 | M 移除普通成员 N | N 离群,群成员数和会话状态同步 |
| WEB-GRP-12 | P1 | M 尝试移除 O、自己或同级不允许对象 | 页面禁用;绕过 UI 仍被服务端拒绝 |
| WEB-GRP-13 | P0 | O 禁言 N,N 发送消息 | 发送被拒绝,其他成员不收到 |
| WEB-GRP-14 | P1 | M 尝试禁言群主或用户自我禁言 | 应被拒绝;若成功,记录角色层级缺陷 |
| WEB-GRP-15 | P0 | 开启全员禁言 | 普通成员不可发送,群主/管理员按规则可发送 |
| WEB-GRP-16 | P1 | O 设置允许/禁止成员邀请 | 普通成员邀请入口和服务端权限同步 |
| WEB-GRP-17 | P0 | 直接邀请用户入群 | 无审批时直接加入;有审批时生成待处理申请 |
| WEB-GRP-18 | P0 | 通过二维码申请入群 | Token 有效时进入正确群;过期/跨群 Token 拒绝 |
| WEB-GRP-19 | P0 | 管理员同意/拒绝入群申请 | 申请状态、成员关系、系统消息和成员数一致 |
| WEB-GRP-20 | P1 | 并发同意达到成员上限的两个申请 | 至多一个成功,成员数不超过上限 |
| WEB-GRP-21 | P1 | 发布、编辑、删除群公告 | 成员实时看到;权限和版本控制正确 |
| WEB-GRP-22 | P1 | 普通成员查看公告并标记已读 | 已读状态保持;已读人数/总人数可正确展示 |
| WEB-GRP-23 | P1 | 修改本人群昵称 | 群成员页和新消息发送者名称更新,个人昵称不被改动 |
| WEB-GRP-24 | P0 | O 转让群主给 M | 所有权、角色、权限原子切换;旧群主不能继续执行群主专属操作 |
| WEB-GRP-25 | P1 | 普通成员退出群 | 会话和成员列表更新;群主不能直接退出而不转让/解散 |
| WEB-GRP-26 | P0 | O 解散群 | 所有成员收到状态变化,后续访问返回 `GROUP_DISSOLVED`/410 |
| WEB-GRP-27 | P1 | 非成员访问 G2 资料、成员、设置、文件 | 返回 403/404,不泄露群数据 |
| WEB-GRP-28 | P0 | 群消息断网恢复 | 恢复后按群 Topic seq 补齐,顺序正确、无重复 |
## 12. 文件、收藏与聊天记录搜索
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-FILE-01 | P0 | 上传 PNG/PDF/视频 | 进度、取消、失败重试、成功态完整,文件存入 OSS/文件服务 |
| WEB-FILE-02 | P0 | 聊天附件上传时检查请求 | 必须带正确 Topic 和 purpose,不能只上传后裸引用 |
| WEB-FILE-03 | P1 | 上传超限文件 F4 | 前端预检查并阻止;服务端也返回明确 `FILE_TOO_LARGE` |
| WEB-FILE-04 | P1 | 上传不允许类型或伪造 MIME | 服务端校验,不因扩展名放行 |
| WEB-FILE-05 | P0 | 接收者下载附件 | 鉴权成功;对象存储 302 可跟随;文件完整 |
| WEB-FILE-06 | P0 | 非会话成员使用已知 file_id 下载 | 返回 `FILE_FORBIDDEN`/404,不泄露 OSS 地址 |
| WEB-FILE-07 | P1 | 下载中断后 Range 续传 | 只下载剩余部分,ETag/大小一致 |
| WEB-FILE-08 | P1 | Token 失效后重新登录继续下载 | 使用新 Token 恢复,不重复已完成分片 |
| WEB-FILE-09 | P1 | 下载不存在、审核中、已拒绝文件 | 分别显示不可用/审核中/违规提示,不出现 500 |
| WEB-FAV-01 | P0 | 收藏文本、图片、语音、文件、链接 | 各分类可查询,重复收藏不产生重复项 |
| WEB-FAV-02 | P1 | 取消收藏 | 列表立即更新,刷新后保持 |
| WEB-FAV-03 | P1 | 收藏列表分页超过 20 条 | 无边界记录丢失、重复和死循环 |
| WEB-FAV-04 | P1 | 从收藏转发给好友/群 | 内容可用、权限正确,转发结果与实际落库一致 |
| WEB-SEARCH-01 | P0 | 搜索明确存在的文本关键词 | 返回全部合法命中,点击后定位到上下文消息 |
| WEB-SEARCH-02 | P1 | 按文件、链接、图片、视频筛选 | 类型正确,ID 保持 `usr...`/Topic 原格式 |
| WEB-SEARCH-03 | P1 | 搜索已撤回、仅自己删除、无权限消息 | 按可见性过滤,不泄露内容 |
| WEB-SEARCH-04 | P1 | 特殊字符、中文、大小写、空关键词 | 页面稳定,结果符合约定,不执行危险模糊查询 |
## 13. 实时语音与视频通话
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-CALL-01 | P0 | A 向在线好友 B 发起语音通话 | B 实时收到邀请,双方显示同一 call_id |
| WEB-CALL-02 | P0 | B 接听并挂断 | 状态按邀请、接通、结束变化;通话记录双方一致 |
| WEB-CALL-03 | P1 | B 拒绝 | A 显示已拒绝,记录结束原因正确 |
| WEB-CALL-04 | P1 | B 不接听直到超时 | 双方记录为未接/超时,不永久停留呼叫中 |
| WEB-CALL-05 | P1 | A 主叫时取消 | B 邀请立即消失,状态一致 |
| WEB-CALL-06 | P1 | 拒绝浏览器麦克风/摄像头权限 | 页面提示授权方法,不创建假通话中状态 |
| WEB-CALL-07 | P1 | 通话中开关麦克风、摄像头、扬声器 | 本地状态和对端媒体表现符合预期 |
| WEB-CALL-08 | P1 | 通话中刷新或断网恢复 | 通话按产品策略恢复或结束,不产生多条重复记录 |
| WEB-CALL-09 | P0 | 被拉黑/非好友发起通话 | 服务端拒绝,被叫不收到邀请 |
| WEB-CALL-10 | P1 | 查看通话记录 | `peer`、方向、开始/接通/结束时间、duration、topic 均可正确渲染 |
| WEB-CALL-11 | P1 | 同账号多浏览器收到来电 | 接听一个后其他端及时结束响铃 |
| WEB-CALL-12 | P1 | 检查声网 Token 获取 | 只在合法通话成员请求时返回,过期后不可继续加入 |
## 14. 工作台与办公协同
本章页面请求必须走 `/v1/workbench/*`。如果网关返回 404,先确认路径是否仍误用 `/v1/im/*`。
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-WB-01 | P0 | 打开工作台 | 模块、排序、权限、未读数正常,请求走新服务路径 |
| WEB-WB-02 | P1 | 普通用户与管理用户分别打开 | 模块权限和入口不同,不只依赖前端隐藏 |
| WEB-WB-03 | P1 | 刷新工作台 | Redis Token 鉴权通过,不随机 401,不要求二次登录 |
| WEB-ORG-01 | P0 | 展开组织树 | 根部门、子部门、成员按租户返回,展开状态稳定 |
| WEB-ORG-02 | P1 | 手机注册测试账号查看组织 | 按产品规则显示允许的组织范围 |
| WEB-ORG-03 | P1 | 邀请进入账号查看组织 | 只显示本人或配置范围,不泄露全部组织 |
| WEB-ORG-04 | P1 | 搜索部门/成员 | 只返回可见范围,跨租户同名成员不可见 |
| WEB-ATT-01 | P1 | 打开考勤页并打卡 | 班次、位置、打卡状态和时间正确,重复打卡有明确结果 |
| WEB-MTG-01 | P0 | 创建会议并选择真实参会人 | 会议创建、参与人、通知和未读数一致 |
| WEB-MTG-02 | P1 | 添加不存在/跨租户 user_id | 服务端拒绝,不创建幽灵参会人 |
| WEB-MTG-03 | P1 | 修改会议并使用旧 version 冲突 | 乐观锁生效,页面可刷新到最新版本 |
| WEB-MTG-04 | P1 | 参会人接受/拒绝 | 发起人实时或刷新后看到状态,重复响应幂等 |
| WEB-MTG-05 | P1 | 取消会议并提交错误 version | 应返回版本冲突,不得无条件取消 |
| WEB-MTG-06 | P1 | 发送会议提醒 | 只提醒未响应/目标用户,不重复轰炸 |
| WEB-NOTE-01 | P0 | 记录员上传会议笔记 PDF | 文件上传并登记,列表和详情出现文件 |
| WEB-NOTE-02 | P1 | 普通参会人预览/下载 | 根据权限放行;非参会人拒绝 |
| WEB-NOTE-03 | P1 | 替换、删除会议笔记 | version 校验正确,旧文件引用按策略处理 |
| WEB-DAILY-01 | P0 | 创建日报草稿、编辑并提交 | 内容持久化,状态从 draft 到 submitted |
| WEB-DAILY-02 | P1 | 提交空白日报 | 服务端拒绝,不允许仅靠前端必填校验 |
| WEB-DAILY-03 | P1 | 输入非法日期、未来异常年份 | 返回校验错误,不创建数据 |
| WEB-DAILY-04 | P1 | 管理者查看统计和未提交人员 | 权限、数量和列表一致,提醒可追踪 |
| WEB-MONTH-01 | P0 | 创建月报、保存总结/计划并提交 | 必填项、状态、版本和详情正确 |
| WEB-MONTH-02 | P1 | 月报分页和管理范围 | 无跨部门/跨租户越权,分页稳定 |
| WEB-MAIL-01 | P1 | 查看总经理信箱说明并提交 | 必填、匿名、长度、附件数量按配置校验 |
| WEB-MAIL-02 | P1 | 重复点击提交 | 幂等键防止重复工单 |
## 15. 个人资料、设置与隐私
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-ME-01 | P0 | 打开个人资料 | username、昵称、脱敏手机号、头像、签名字段完整 |
| WEB-ME-02 | P0 | 修改昵称和签名 | version 递增,刷新保持,好友/群投影最终同步 |
| WEB-ME-03 | P1 | 两个浏览器并发编辑同一资料 | 一个成功,另一个版本冲突,不覆盖新值 |
| WEB-ME-04 | P1 | 设置出生日期、性别、地区、邮箱 | 格式与长度校验符合 field_rules |
| WEB-ME-05 | P0 | 上传头像 | 文件用途为用户头像,成功后所有主要页面可访问 |
| WEB-ME-06 | P1 | 生成并展示个人二维码 | 二维码包含服务端 Token,不直接暴露内部用户 ID |
| WEB-PRIV-01 | P0 | profile_visibility 在 everyone/friends/nobody 间切换 | C/A 分别查看时权限立即符合设置 |
| WEB-PRIV-02 | P0 | 关闭按账号/手机号/二维码/群添加 | 对应搜索或添加入口和服务端结果一致 |
| WEB-PRIV-03 | P1 | 关闭已读回执 | 对端不再获得已读状态,不能只在 UI 隐藏 |
| WEB-PRIV-04 | P1 | 开启好友申请验证 | 新申请进入 pending;关闭后按产品规则直接建立/简化流程 |
| WEB-PRIV-05 | P1 | 开启入群验证 | 邀请进入审批态,不能直接入群 |
| WEB-SET-01 | P1 | 点击清空缓存 | 只清浏览器缓存,不删除服务端消息;重新加载可恢复 |
| WEB-SET-02 | P0 | 修改登录密码 | 成功后旧 Token 失效,新密码可登录 |
| WEB-SET-03 | P1 | 输入错误旧密码或弱密码 | 页面按 reason 提示,密码不变 |
| WEB-SET-04 | P1 | 发起账号注销并完成验证 | Token 撤销、账号不可继续登录;重复提交幂等 |
| WEB-VER-01 | P1 | Web 端检查版本 | `platform=web` 返回可用结果;推荐更新可关闭,强更不可绕过 |
青少年模式已从当前 Web 产品入口移除,不作为 Web 发版阻塞项;如后续恢复入口,应单独执行 `IM-青少年模式前后端对接文档.md` 的完整流程。
## 16. 举报、反馈与安全
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-SEC-01 | P1 | 发送疑似风险 URL | 页面按 allow/warn/block 展示,不能直接放行 block |
| WEB-SEC-02 | P1 | 风险警告选择继续 | risk token 单次有效,重复使用被拒绝 |
| WEB-SEC-03 | P1 | 风险警告选择取消 | 不发送原消息,不生成可继续使用的 token |
| WEB-ABUSE-01 | P0 | 举报用户、群和消息 | 目标类型/ID正确,提交后有回执和 report_id |
| WEB-ABUSE-02 | P1 | 选择 requires_description 的原因但留空 | 前端和服务端都拒绝 |
| WEB-ABUSE-03 | P1 | 上传举报证据 | 仅合法 file_id 可绑定,超数量/无权限文件拒绝 |
| WEB-ABUSE-04 | P1 | 重复点击举报提交 | 只生成一个举报工单 |
| WEB-FB-01 | P0 | 打开意见反馈 | 分类、长度、附件策略和诊断说明来自服务端配置 |
| WEB-FB-02 | P0 | 提交文本反馈 | 成功后显示受理结果并清空草稿 |
| WEB-FB-03 | P1 | 带截图提交反馈 | 上传成功、文件审核状态和业务引用正确 |
| WEB-FB-04 | P1 | 内容超长、附件超限、联系方式非法 | 页面定位到对应字段,服务端返回明确校验错误 |
| WEB-FB-05 | P1 | 勾选/取消上传诊断信息 | 只有明确同意时才提交脱敏诊断,不上传 Token/密码 |
## 17. 跨租户、安全与可靠性专项
| ID | 优先级 | 操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-TENANT-01 | P0 | `system` 与另一个真实商户使用同名 username 分别登录 | 各自进入正确账号,Token 绑定各自租户;另备第二商户执行,三个 Web 域名不是三个商户 |
| WEB-TENANT-02 | P0 | 把 `system` Token 放到第二商户请求头 | `TENANT_MISMATCH`/认证失败,不返回任何业务数据 |
| WEB-TENANT-03 | P0 | 已知跨租户 user/topic/group/file ID 后直接访问 | 统一 403/404,不暴露对象存在性 |
| WEB-TENANT-04 | P0 | 切换租户后查看会话、搜索、收藏、上传任务 | 不出现上一租户缓存数据 |
| WEB-TENANT-05 | P1 | 同名群/系统 Topic 在两个租户同时活动 | 消息、未读、系统通知完全隔离 |
| WEB-REL-01 | P0 | HTTP 写请求重复提交相同幂等键和相同 Body | 返回首次结果,不重复写 |
| WEB-REL-02 | P0 | 相同幂等键改 Body 重放 | 返回 `IDEMPOTENCY_CONFLICT` |
| WEB-REL-03 | P1 | 关键请求超时后刷新页面 | 先查询真实状态,不能直接重复创建 |
| WEB-REL-04 | P0 | 同一 Token 并发发起 20 个只读请求 | 不应随机 401;出现时记录全部 request_id |
| WEB-REL-05 | P1 | 服务端 429 | 页面按 `retry_after_seconds` 倒计时,不立即重试 |
| WEB-REL-06 | P1 | 服务端 500/502/503 | 展示可重试错误,不清有效登录态,不无限重试 |
| WEB-REL-07 | P1 | 浏览器后退/前进恢复页面 | 不重复写操作,不展示其他账号缓存 |
| WEB-REL-08 | P1 | 登录页和业务页检查 Console | 无 Token、密码、完整手机号、身份证号和 OSS 签名泄漏 |
## 18. 当前高风险专项回归
以下项目来自历史问题清单。无论清单是否已过期,都应在 Web 验收中重新确认,以实际结果更新状态。
| ID | 对应风险 | Web 验证方法 | 通过标准 |
| --- | --- | --- | --- |
| RISK-01 | 并发转发真假失败不一致 | 同一目标并发转发 12 次,对照页面、WS 和历史 | 成功数、响应数、落库数一致 |
| RISK-02 | Bearer Token 并发随机 401 | 同 Token 并发加载首页各模块 | 无随机 401;失败有可追踪日志 |
| RISK-03 | 文件下载 SQL/死锁错误 | 连续打开图片、头像、会议文件 50 次 | 无 500、死锁直返和错误列异常 |
| RISK-04 | 好友拉黑状态不一致 | 拉黑后对照好友列表、资料页、会话上下文 | relationship/capabilities 一致 |
| RISK-05 | 群成员计数双真源 | 对照群详情、设置、二维码、成员分页 | member_count 全部一致 |
| RISK-06 | 群角色筛选缓存滞后 | 提升/撤销管理员后立即筛选 | 立即返回新角色,不读旧缓存 |
| RISK-07 | 搜索召回不稳定 | 发送唯一关键词后循环搜索 10 次 | 每次命中,定位上下文一致 |
| RISK-08 | 用户 ID/Topic 格式不一致 | 检查搜索、收藏、通话记录 | 均返回可寻址 ID,不裁剪 `usr` 或返回内部 hash |
| RISK-09 | 群二维码字段不一致 | 创建二维码并立即解析 | 创建/解析的 group 字段契约一致 |
| RISK-10 | 通话记录字段偏离文档 | 完成一轮呼叫后查看列表 | peer、incoming、时间和 duration 可直接渲染 |
| RISK-11 | 群公告已读人数缺失 | 3 人查看公告后检查详情 | 已读/总人数准确,状态可刷新恢复 |
| RISK-12 | 文件审核任务不消费 | 上传需审核图片并观察状态 | 在合理时间内进入 approved/rejected,不永久 pending |
## 19. 浏览器兼容与体验
| ID | 场景 | 预期结果 |
| --- | --- | --- |
| WEB-UX-01 | Chrome、Edge、Firefox 最新版登录和收发消息 | 核心流程一致 |
| WEB-UX-02 | 1366x768、1920x1080、窄屏窗口 | 无按钮遮挡、文本溢出和不可滚动区域 |
| WEB-UX-03 | 浏览器缩放 80%/100%/125%/150% | 布局可用,弹窗和菜单不超出视口 |
| WEB-UX-04 | 中文、英文、长昵称、长群名 | 截断和换行合理,不覆盖相邻控件 |
| WEB-UX-05 | 空列表、加载中、请求失败、权限拒绝 | 四态完整且文案准确 |
| WEB-UX-06 | 键盘 Tab 导航、Enter 发送、Esc 关闭弹窗 | 行为一致,不误提交危险操作 |
| WEB-UX-07 | 刷新时存在未发送草稿 | 草稿按产品策略恢复,不串会话 |
| WEB-UX-08 | 页面运行 2 小时并持续收消息 | 内存、WS 数量和事件监听不持续增长 |
## 20. 测试结果记录模板
```markdown
### [用例 ID] 用例名称
- 结果:通过 / 失败 / 阻塞 / 不适用
- 环境:测试环境 / 浏览器版本
- 账号:A、B
- 时间:YYYY-MM-DD HH:mm:ss
- Request ID:
- Topic / Seq:
- 前置条件:
- 操作步骤:
1. [填写操作一]
2. [填写操作二]
- 预期结果:
- 实际结果:
- 刷新后结果:
- 接收方结果:
- Network / WS 证据:
- 截图或录像:
- 缺陷编号:
```
## 21. 发版通过门禁
Web 版本满足以下条件才可判定通过:
1. P0 冒烟用例全部通过。
2. 所有 P0 业务用例通过,P1 无数据丢失、越权、安全或核心流程阻塞问题。
3. 单聊、群聊各完成一次在线实时、离线补偿、断线重连和多端已读闭环。
4. 文件上传、下载、无权限下载、Range 续传和 OSS 链路通过。
5. 好友、拉黑、群治理均完成 UI 权限和服务端绕过校验。
6. 工作台全部请求已切到 `/v1/workbench/*`,没有旧路径 404。
7. 跨租户专项全部通过,浏览器缓存中不出现上一租户数据。
8. 无未解释的 401、403、404、409、500、502、503;预期错误必须有正确 `reason`。
9. Console 和请求日志不泄露密码、Token、身份证号、完整手机号或 OSS 临时签名。
10. 所有失败用例均有 Request ID、复现步骤和页面/网络证据。
## 22. 三域名实测记录(2026-10-17)
本节记录本轮实际页面操作,不把准备状态当作注册成功。测试商户为 `system`,浏览器为 Codex 内置浏览器。测试账号密码、Token 不写入本文。
| 用例 | 结果 | 实测记录 |
| --- | --- | --- |
| ENV-01~03、SMK-01 | 通过 | `web1`、`web2`、`web3` 的企业码页均能以 `system` 进入登录页,显示 `system merchant`。 |
| FLOW-REG-01、SMK-01A、WEB-AUTH-10(入口) | 通过 | 首次复用的 `web2`、`web3` 登录页标签未显示注册链接;重新打开三个域名的登录页后,均可见“注册账号”,目标 URL 分别为各自域名的 `/register`。旧标签状态未作为当前版本缺陷。 |
| FLOW-REG-02、04、05(表单准备) | 部分通过 | 三个域名的 `/register` 均显示 `system merchant`,可填写用户名、手机号、密码、确认密码;本轮先准备三个不同的仅用户名账号,未填写手机号,也未触发短信发送。 |
| FLOW-REG-03~05、SMK-01B(注册过程) | 未见证 | 用户随后自行完成三个账号的注册并登录;本轮只核对登录结果,未观察协议勾选、注册请求和验证码/邀请码分支,不能据此判这些注册流程通过。 |
| FLOW-REG-06A、SMK-04(身份/首页) | 通过 | 三域名同时登录 `qa261017a` (`usruLg9kkkulUg`)、`qa261017b` (`usrOTmpE042BPc`)、`qa261017c` (`usradhJQqzgP8U`);各自资料页身份不同,初始会话空态正常。 |
| FLOW-REG-07、WEB-FRIEND-02/05/07、SMK-08 | 通过,实时刷新待改进 | A 搜索用户名找到 B/C 的正确 `usr...` ID,发送申请;B/C 的“新朋友”各出现待处理申请并可通过。B 再申请 C,C 通过。三对关系均持久化。审批方列表立即更新;申请方原通讯录未立即出现新好友,刷新后才出现。 |
| FLOW-REG-07、WEB-P2P-01/05/07、SMK-05/07 | 通过 | A/B 在同一 P2P Topic `p2puLg9kkkulUg5OakTTjYE9w` 双向发文本;双方在线实时收到,发送端出现“已送达/已读”。C 无关会话未显示 A-B 消息。 |
| FLOW-REG-08、WEB-GRP-01/03/06、SMK-09/10 | 通过 | A 创建 `Web联调三人群-20261017`,Topic `grpW1fvFD1CU8w`;A/B/C 的群列表均显示 3 人,A 群成员页列出 A/B/C,群主为 A。三人各发一条群消息,另外两端实时收到,群消息顺序一致。 |
| FLOW-REG-10、WEB-GRP-09、WEB-GRP-21/22 | 部分通过 | A 将 B 设为管理员,A/B/C 页面均显示 B 的管理员角色;B 可发布群公告,C 可阅读但无编辑入口。B 刷新后公告已读数为 2/3。未测撤销管理员、公告编辑/删除和入群审批。 |
| FLOW-REG-09、WEB-GRP-28、WEB-HOME-07 | 部分通过 | C 页首次用网络模拟显示“无网络”,但旧 WS 仍接收三条消息,不能计作离线补偿。随后断网重载令 C 页真正离开应用;新 web3 标签重新登录 C 后,群会话显示 3 条未读,打开后按 1/3、2/3、3/3 顺序显示且无重复。**同一标签自动重连和 seq 增量补偿未验证**。后续原 web3 群聊标签也恢复可用,刷新后三端均保持登录。 |
| FLOW-REG-11、SMK-06、WEB-FILE-01(小图) | 部分通过 | A 发送仓库公开测试 PNG `docs/ios-pill-128.png`(4,587 字节);B/C 实时收到并可预览原图,A 看到 2 人已读。但图片正文以 `data:` 内联,不能作为 OSS 上传通过的证据。 |
| FLOW-REG-11、WEB-FILE-01(大图/OSS) | **失败,P0 阻塞** | A 两次选择 `docs/note/文件-预览.png`(344,005 字节)发送到同一群,页面均提示 `Failed to fetch`,群内无大图消息。浏览器网络事件显示 `POST https://api.118im.com/v0/file/u/` 发起前,`OPTIONS` 预检返回 204,但响应无 `Access-Control-Allow-Origin`;POST 随后为 `net::ERR_FAILED`。预检 Origin 为 `https://web1.118im.com`,请求头含 `authorization,x-meetingim-apikey`。需修复上传入口 CORS 并覆盖 web1/web2/web3 三个 Origin,再重测大图/PDF、下载和 OSS。 |
| WEB-GRP-15(普通成员) | 部分通过 | A 开启全员禁言后,C 收到群系统提示;C 页面仍允许输入并点发送,但服务端拒绝,消息显示“发送失败,点击重试”。刷新后该消息不在群历史。页面应在已知禁言状态下禁用发送并提示具体原因。 |
| WEB-GRP-15(管理员豁免) | **失败,P0 权限缺陷** | 群设置明确写“仅群主和管理员可以发言”,B 已由 A 提升为管理员且各端均显示“管理员”。全员禁言期间 B 发送文本两次均失败;第二次 WS `pub` 到 `grpW1fvFD1CU8w` 后,服务端 `ctrl.code=403`、`ctrl.text=permission denied`。刷新后消息不在群历史。A 随后关闭全员禁言,设置恢复为 0。需核查群管理员身份与全员禁言的服务端权限判定。 |
| WEB-SEARCH-01(群内关键词搜索) | 通过 | B 在群会话搜索唯一关键词 `离线补偿测试 2/3`,结果命中 B 原消息;点击后关闭搜索层并定位到群历史。 |
| WEB-GRP-15(刷新复测) | **失败,P0 权限缺陷仍在** | A/B/C 三端刷新后,A 的群管理页显示“群管理员 1/10”,B 的成员角色和消息标签均为“管理员”。A 再次开启全员禁言,B 发送唯一文本后出现“发送失败,点击重试”,A/C 未收到。A 随后关闭全员禁言,开关恢复 0。禁言关闭后 B 重新发送另一条文本成功,C 收到,说明失败与全员禁言状态有关,并非 B 普通发言能力故障。 |
| WEB-FILE-01(刷新及跨域复测) | **失败,P0 阻塞仍在** | A 刷新后从 web1 再传 344,005 字节 PNG,仍提示 `Failed to fetch`;B 从 web2 上传同一文件也失败。B 在“群文件”面板上传 4,587 字节 PNG,同样提示 `Failed to fetch`,列表仍为 0。此轮未重新采集预检头,结合此前 CORS 证据可判上传功能仍阻塞,但不能仅凭本轮页面提示单独断定原因。 |
| WEB-HOME-04/06(群会话设置) | 通过 | A 开启群聊置顶、消息免打扰后刷新,两项开关均恢复为 1;之后均关闭并确认恢复 0。 |
| WEB-ME-01(个人资料签名) | 通过 | A 在个人资料页将签名改为 `Web联调签名测试-20261017`,保存后刷新,输入框仍显示相同内容,保存按钮恢复禁用。该测试签名保留在测试账号上。 |
| WEB-HOME-07(在线未读) | 通过 | C 停在无选中会话的消息页时,B 在解除全员禁言后发送群文本,C 群会话未读变为 1;打开群聊后出现该文本,未读标记清零。会话摘要在打开/加载完成前短暂显示上一条系统消息,随后更新为新文本,未计作稳定缺陷。 |
| WEB-MSG-01(多行 Unicode) | 通过 | C 发送含表情、中文及换行的两行群消息;B 实时收到,B/C 刷新后两行内容仍完整且顺序一致。 |
| WEB-GRP-23(群内昵称) | **部分失败,P1 展示缺陷** | C 将“我在本群的昵称”设为 `联调成员C`,刷新后设置页仍显示该值,说明保存成功。但 B 的新群消息发送者按钮仍为 `qa261017c`;C 刷新后的“群成员 3”列表中自己仍为 `qa261017c`。需确认群成员昵称的读取字段与消息发送者展示优先级。 |
| WEB-GRP-21/22(公告编辑和已读) | 通过 | B 将原公告编辑为含“编辑复测 2026-10-17”的版本并发布;B/C 群聊实时出现新的公告消息。C 打开公告详情,看到新内容和已读 2/3;B 的公告详情显示发布者为 B。未测删除和旧版本并发冲突。 |
| FLOW-REG-06、12~15、SMK-11~15 | 待执行 | 三域名登出隔离、PDF 下载、昵称等其他资料字段、负向注册、工作台及跨租户等尚未测试;大图上传失败后不继续假定文件链路可用。 |
本轮页面操作发生在 09:00~09:19(Asia/Shanghai)。首次文件上传失败采集了预检/POST 网络事件,首次管理员禁言失败采集了 WS `403` 控制帧;后续复测主要以页面状态为证。其他用例未系统采集 Request ID 和消息 Seq,因此当前记录不能代替协议与数据库层验收。仅用户名注册无法自助找回密码;短信验证码、邀请码和推荐关系仍需使用受控测试手机号及有效邀请码单独验证。
## 23. 缺陷修复与复测状态
第 22 节记录的是修复前版本的实测结果。以下是本轮发布后的复测。首次复测时 web3 测试账号曾被另一设备登录策略踢出;之后恢复登录并完成了普通成员禁言、昵称和群消息复测。
| 缺陷 | 代码修复 | 本地验证 | 发布后复测 |
| --- | --- | --- | --- |
| 大图及群文件上传预检失败 | IM Server 上传 `OPTIONS` 按上传方法集校验 `POST`;允许来源匹配后明确回显请求头名,不再用 `Access-Control-Allow-Headers: *` 处理带鉴权预检。 | 上传预检单测通过,Go server/media 测试通过。 | **预检通过,群文件上传未通过**:对 `https://web1/2/3.118im.com` 分别请求 `OPTIONS /v0/file/u/`,均返回 204,正确回显对应 Origin,允许 `POST, PUT, HEAD, OPTIONS` 及 `authorization,x-meetingim-apikey,content-type`。三域名各从群文件页选择 344 KB PNG,均发起 `POST https://api.118im.com/v0/file/u/`;web2 的 OPTIONS 为 204,web1 请求使用已缓存预检。各 POST 观察 8~15 秒均无响应事件,页面持续转圈、列表为 0,浏览器无 Console 错误;刷新后未见文件记录。小图 `docs/ios-pill-128.png` 在群聊正文走 `data:` 内联,不能证明 OSS 上传。需排查上传 POST/服务端处理或 OSS 回源链路,并重测下载。 |
| 管理员在全员禁言时无法发言 | 群发言检查读取当前业务成员角色,避免依赖已加载 Topic 的旧权限快照;非活跃成员不允许绕过禁言。 | 群角色测试及 Go store/MySQL 构建测试通过。 | **通过**:A 开启全员禁言,C 普通成员发送失败且 A/B 均未收到;同一状态下 B 管理员发送 `发布后复测-禁言管理员重连后-B-20261017`,A/C 均收到。首次管理员尝试遇到 504;刷新 B 重连后再测成功。解除禁言后 C 的新消息及先前失败消息重试均送达。全员禁言最终已关闭。 |
| 群内昵称未用于成员及发送者显示 | Web 的群成员预览/列表、群消息发送者、引用、历史搜索和 @ 提示优先取 `group_nickname`;IM Server 为群昵称更新广播 `group.member.profile.updated`,Web 收到后定向重取成员数据。 | Web 单测 21/21、生产构建通过;群事件可见性单测通过。 | **通过(跨端成员列表及发送者)**:A 群主修改本人群昵称后,B 管理员无需刷新,群成员列表立即显示新昵称;恢复为账号名后,B 列表也实时恢复。C 登录恢复后,B/A 的成员列表和 C 新发消息均显示 `联调成员C`。 |
补充:本轮实际使用的测试用户为 web1 `qa261017a`(群主)、web2 `qa261017b`(管理员)、web3 `qa261017c`(普通成员)。web3 登录恢复后继续完成普通成员权限复测。web2 首次禁言发言遇到 `CommError: timeout (504)`,刷新重连后管理员豁免发送成功。普通成员禁言失败消息解除禁言后点“重试”成功送达;搜索该消息可返回结果并定位群历史。C→A 私聊及 A→C 回复均实时送达,第三个账号 B 未看到私聊正文。C 通过 `@` 选择管理员 B 后发送群提及,A/B 实时收到并正确呈现提及对象。测试结束时全员禁言为关闭状态。三域名均用 344 KB PNG 测群文件上传,`POST /v0/file/u/` 长时间无响应、状态持续转圈,刷新后列表仍为 0;群聊小图走 `data:` 内联,不应视作 OSS 上传证据。Web 本地开发地址为 `http://127.0.0.1:8094/`,其 API 代理需要本机 IM Server 监听 `127.0.0.1:6060`。
### 本轮追加实测(2026-10-17)
| 用例 | 结果 | 实测记录 |
| --- | --- | --- |
| WEB-FAV-01~03(空态/分类筛选) | 部分通过 | A 的收藏页正常加载“暂无收藏内容”;全部、文本、图片、视频、语音、文件、链接分类均可切换,空态保持。暂无收藏记录,无法验证各类型入库、取消和分页。 |
| WEB-FRIEND-01/10(通讯录/备注) | 通过 | A 通讯录列出好友 B/C;好友资料显示 username、昵称及 `usr...` ID。B 备注临时改为“回归备注-临时”,保存后通讯录和资料标题更新;随后清空并确认恢复“未设置”。 |
| WEB-FRIEND-10(好友昵称同步回滚) | 部分通过(实时列表缓存延迟) | A 临时修改昵称后,B 通讯录即时显示新昵称;A 恢复原昵称后,B 好友资料面板显示恢复后的 `qa261017a`,但通讯录列表短暂残留临时昵称。B 刷新页面并恢复登录后列表更新为 `qa261017a`,跨端实时列表缓存失效仍有延迟。 |
| WEB-FRIEND-11(黑名单空态) | 部分通过 | A 的黑名单页正常显示空态,当前 0 人;未把好友加入黑名单,故阻断拉黑/解除及被拉黑发送权限验证。 |
| WEB-GRP-03/06/07/08(群分类、成员、详情、搜索) | 通过 | 群列表分类计数与角色一致:A“我创建的”1,B“我管理的”1;B 群成员面板显示 3/3,角色为群主/管理员/普通成员,单成员详情可打开。按“联调成员C”搜索只保留 C,清空前未改变成员数据。 |
| WEB-GRP-08(无匹配成员搜索) | 部分通过 | A 搜索不存在成员名后结果列表清空;清空关键词后 3 名成员恢复。页面未提供可访问文本形式的“无匹配结果”提示。 |
| WEB-GRP-16/17/19(治理配置/邀请审批入口) | 部分通过 | B 管理员可查看进群验证、全员禁言、群文件上传权限、成员私聊权限、邀请入群权限、禁言成员和进群申请入口,当前设置均未被修改。没有可用于入群审批的第四个账号,审批通过/拒绝、邀请权限变更未执行。 |
| WEB-ME-01/06、WEB-PRIV-01(资料/二维码/隐私只读) | 部分通过 | A 资料页显示 username、`usr...` ID、昵称、性别、生日、地区和签名;页面未显示手机号字段。个人二维码可生成并展示。隐私当前“资料可见范围”和“最近在线可见范围”均为“仅好友”,添加方式和已读回执开关可见;未切换隐私设置,无法据此验收非好友受限表现。 |
| WEB-SET-01/02/04(设置页入口) | 部分通过 | 通用页提供“清空缓存”;安全页提供修改密码和注销账号入口。未点击清空缓存;密码修改需账号持有人操作,注销是不可恢复动作,均未执行。 |
| WEB-HOME-10/12(系统消息) | 部分通过 | A 系统消息中心可读到两条“好友申请已处理”通知,内容可读且标记为新;未执行全部标记已读或通知跳转校验。 |
| WEB-CALL-10(通话记录空态) | 部分通过 | 通话记录页正常加载并显示“暂无通话记录”;未发起真实音视频通话,无法验证记录字段和声网链路。 |
| WEB-ABUSE-01(群举报入口) | **失败,功能未开放** | A 在群设置点击“投诉举报”,页面提示“该功能暂未开放,如需投诉举报请联系客服。”没有举报表单,无法提交举报或取得 report_id。 |
| WEB-MSG-02(群聊文本/链接消息) | 通过(A/B/C) | B 发送 `链接卡片验收-20261017 https://example.com`,A 收到可点击链接;随后 C 发送 `三人群回归消息-C-20261017`,A/B 均收到文本且显示群昵称“联调成员C”。 |
| WEB-HOME-02(群未读清零复测) | **受控场景通过,曾出现一次异常** | 早先 B 发消息时 A 已处于群聊,正文可见但旧未读 `1` 未即时清除;刷新后该旧徽标消失,未能稳定复现。本轮将 A 留在私聊,由 C 发群消息,A 会话列表出现未读 `1`;A 打开群聊并加载该消息后,徽标清零,C 端显示 2 人已读。需继续观察同会话在线实时更新与多消息连续到达,不据一次旧徽标直接判定服务端游标故障。 |
| WEB-WB-01~02、WEB-ORG-01~04 等工作台场景 | 本轮不处理 | 按用户本轮要求,工作台网页版部分场景不纳入本轮验收。 |
| WEB-TENANT-01~05(跨租户隔离) | 阻塞 | 当前只有 `system` 商户三账号;web1/web2/web3 是不同域名,不是不同租户。需要第二个可登录商户及其测试账号,不能用域名替代租户。 |
| WEB-AUTH-11~16、WEB-PRIV-05、WEB-ABUSE-01~04、WEB-FB-01~05 | 阻塞/未执行 | 邀请码、短信验证码和密码重置需受控手机号/验证码;入群审批需要第四位申请人;举报/反馈会创建后台业务单,需后续按独立测试数据执行。注册异常分支未通过手工数据库方式伪造。 |
本轮仅在测试账号间操作;好友备注已恢复未设置,A 昵称已恢复,群角色/成员/治理开关、A 的隐私设置均保持原值。全功能验收仍未完成:OSS 上传当前仅验证小型 PNG 最终成功,下载链路未通过;缺少第二租户及短信/邀请码测试凭据;通话、举报反馈、消息复杂类型/转发撤回、跨端登出隔离及若干群治理状态转换也尚未实测。工作台网页场景按用户要求排除。未读受控路径本轮通过,旧徽标残留现象暂未稳定复现;B 通讯录昵称在 A 修改后存在实时列表缓存延迟,页面刷新后恢复。
### 本轮定向复测(2026-10-17)
| 用例 | 结果 | 实测记录 |
| --- | --- | --- |
| WEB-FILE-01/02(群文件 OSS 上传) | 部分通过 | B 管理员在三人群“群文件”上传 `docs/ios-pill-128.png`(4.5 KB);上传按钮约 10 秒保持转圈后,列表新增文件记录,上传者、文件名、大小和时间正确。证明群文件上传请求最终完成并生成业务记录,但 UI 仍需明确的上传进度/耗时反馈;单次 UI 记录不能独立证明对象存储 Bucket 内容及审核状态。 |
| WEB-FILE-06(群文件下载) | 部分通过,存在服务端间歇失败 | 前一轮 C 普通成员从群文件行触发下载,6 次读取中 5 次 `200 image/png`(一次完成传输 5,169 字节),1 次 `500 application/json`,错误体为 `ctrl.code=500, text=internal error`。本轮追加一次下载,收到 `200 image/png` 且 `Network.loadingFinished`,传输长度记录为 531 字节;自动化环境仍未暴露系统下载完成事件,不能确认文件落盘/内容。Web 前端本地已将 Blob URL 撤销延后 1 秒,`npm test` 21/21、`npm run build` 通过,尚未发布复验。 |
| WEB-FILE-01(普通成员群文件上传及跨成员可见) | 部分通过,曾间歇 500 | 群文件权限为“所有成员”。C 第一次上传 4.5 KB PNG 收到 HTTP 500,响应 `ctrl.code=500, text=internal error`;第二次重试成功,群文件数由 1 增至 2,新增条目上传者为 `qa261017c`,文件名及大小正确。A、B 各自打开群文件列表后也都看到该条目。说明普通成员在该权限下可以上传,文件索引对其他成员可见,但一次失败、一次成功仍提示上传链路有间歇问题;测试集群日志此前因 Kubernetes API 超时尚未取到。源码检查另发现 `member_upload_files` 之前只控制前端入口、服务端未强制执行;已在本地补服务端授权和单测,但尚未部署,且本次在线配置为“所有成员”,该改动不能解释间歇 500。 |
| WEB-GRP-治理入口与角色边界 | 部分通过 | A 群主管理面板可见进群验证、全员禁言、群文件上传权限、成员私聊权限、邀请入群权限、禁言成员、进群申请及转让群主等控制。普通成员 C 的群资料面板没有“群管理”入口(可访问按钮数 0);A 可见该入口。当前进群验证/全员禁言关闭,群文件权限为“所有成员”,邀请权限为“所有成员”;本轮只读检查,未改变配置。审批状态转换、管理员移除/禁言对象和权限变更后的服务端拒绝仍需第四位申请人及独立成员数据。 |
| WEB-HOME-02(未读清零及按用户隔离) | 通过(受控路径) | A、B 均离开群会话,C 发送 `未读复测-C-20261017-1618` 后,A、B 群会话各自显示未读 `1`。A 打开群聊后 A 的未读清零,B 仍显示 `1`;B 再打开群聊后 B 的未读清零。C 端显示 2 人已读。验证未读数按用户独立维护,读取一方不清除另一方未读。 |
工作台网页场景按用户要求不纳入本轮。OSS 链路仍未完整验收:下载有间歇 `500`,且尚未从真实浏览器验证落盘文件;上传覆盖的仍只有 4.5 KB PNG,较大文件、PDF/视频和审核状态尚未覆盖。Web 本地下载修复已将 Blob URL 撤销延后 1 秒,测试 21/21、生产构建通过,尚未发布到测试环境验证。普通成员上传复测由首次 500 转为重试成功,仍需日志定位间歇错误。
@@ -0,0 +1,150 @@
# IM allrich 双机压测执行记录
日期:2026-09-27
## 1. 测试环境
| 项目 | 值 |
|---|---|
| 企业码 | `allrich` |
| 企业名称 | 大家一起发 |
| HTTP API | `https://api.118im.com` |
| WebSocket | `wss://api.118im.com/v0/channels` |
| API Key | 通过 `IM_API_KEY` 注入,不写入 Git |
| 测试验证码 | `123456`,仅测试环境使用 |
| 测试密码 | 通过 `IM_LOAD_PASSWORD` 注入,不写入 Git |
| 机器 A | 当前开发机,`192.168.1.135` |
| 机器 B | 另一台压测机,运行 `-machine b` |
`config.json`、`.env.test`、`manifest.json` 均被 Git 忽略。manifest 含账号 Token,只能通过受控内网传输。
## 2. 数据准备结果
2026-09-27 首次准备曾按 2000 账号目标执行。当前分阶段压测使用的实际运行文件为:
- `users_per_machine=500`。
- 账号:1000,机器 A/B 各 500。
- 好友关系与 P2P Topic:500 对。
- 测试群:1 个,当前 manifest 的 `group_topic=grp3Ita5tyBIMs`。
正式的 2000 人群测试前,必须将 `users_per_machine` 恢复为 1000,在机器 A 重新执行 `prepare` 和 `verify`,并将新 manifest 同步给机器 B。不能用当前 1000 人清单宣称已完成 2000 人群压测。
准备过程观察到注册和好友同意接口存在少量瞬时 500/404,100 人群邀请批次存在 502、超过 2 分钟及 `IDEMPOTENCY_IN_PROGRESS`。工具使用相同业务幂等键和退避重试后全部恢复;这些属于准备链路现象,不计入正式消息到达率。
## 3. 最新有效轮次
| 项目 | 值 |
|---|---|
| Run ID | `p2p500-r5-20260927-05` |
| 场景 | `p2p` |
| Git 提交 | `ea8732e` |
| 连接 | A/B 各 500,合计 1000/1000 READY |
| 发送 | 10000/10000 获得 ACK |
| 实时到达 | 10000/10000,到达率 100% |
| 历史落库 | 10000/10000,落库率 100% |
| 异常 | 丢失 0、重复 0、乱序 0、断连 0、发送错误 0 |
| ACK 延迟 | P50 约 650ms,P95 1313ms,最大 2075ms |
| 同机 echo 投递 P95 | 2s 直方图桶 |
| 报告门禁 | 正确性通过;延迟门禁未通过 |
本轮中 A/B 时钟未对齐,A 端记录到 4334 个跨机器负延迟样本,因此不使用跨机器单向延迟作为结论;延迟门禁使用发送机本机 ACK 和 echo 口径。
### 3.1 服务端阶段耗时
| 阶段 | 平均耗时 | P95 所在桶 |
|---|---:|---:|
| `relationship_check` | 66.9ms | 250ms |
| `redis_idempotency` | 21.1ms | 100ms |
| `redis_sequence` | 62.7ms | 250ms |
| `kafka_produce` | 11.0ms | 25ms |
| `save` | 116.9ms | 500ms |
`meeting_im` 和 `hotgo` 连接池在该轮起压窗口的等待次数和等待时间增量均为 0,未出现连接池枯竭。
### 3.2 Kafka 和落库
- `im-server.message_persist` 的 consumer lag 峰值为 4663。
- lag 在发压结束后约 2 分钟清零。
- 测试窗口中批量持久化的单批平均耗时约 3.52s,P95 落在 10s 直方图桶。
- agent 在等待 Kafka 追平后回查历史,10000 条 ACK 消息全部找到,没有持久化丢失。
结论是当前系统能在突发后最终消化积压,但前台 ACK 和异步落库延迟仍高,这两项应单独作为后续优化指标。
## 4. 历史机器 B 运行文件
以下为首轮历史运行包,不能继续用于新轮次:
```text
/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
SHA-256: 479f119e7d01e1829c7262cace282c72a5f53ad8e7f5128abc2ade75d046db64
```
机器 B 必须先安全获取该文件,再执行:
```bash
git switch dev
git pull --ff-only origin dev
# 运行包不在 Git 中,必须从机器 A 单独复制
scp youshunlai@192.168.1.135:/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip /tmp/
shasum -a 256 /tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
unzip -o /tmp/allrich-machine-b-runtime-p2p-20260927-01.zip \
-d loadtest/im-pressure
chmod 600 loadtest/im-pressure/.env.test \
loadtest/im-pressure/config.json \
loadtest/im-pressure/manifest.json
set -a
source loadtest/im-pressure/.env.test
set +a
cd loadtest/im-pressure
go build -o im-pressure .
./im-pressure agent \
-config config.json \
-manifest manifest.json \
-machine b \
-coordinator http://192.168.1.135:8090 \
-metrics-listen :9105 \
-output results/p2p-20260927-01/agent-b-summary.json
```
完整 B 机操作和故障处理见 [`loadtest/im-pressure/MACHINE-B.md`](../../loadtest/im-pressure/MACHINE-B.md)。
## 5. 多 Pod 口径说明
当前 A/B 配置均使用 `wss://api.118im.com/v0/channels` 公共入口。该配置验证 LB 实际分配下的多 Pod 能力,但不能保证两台压测机固定落到不同 Pod。若验收要求强制指定不同 Pod,需要提供两个独立 WS 定向入口,分别配置到 `endpoints.a` 和 `endpoints.b`。
## 6. 后续测试顺序
### 6.1 P0:扩容到 2000 个用户
1. 将机器 A 的 `config.json` 修改为 `users_per_machine=1000`。
2. 机器 A 执行 `prepare` 和 `verify`,确认 2000 账号、1000 对好友、1000 个 P2P Topic 和 2000 人群都就绪。
3. 重新生成包含 `.env.test`、`config.json`、`manifest.json` 的 B 机运行包,B 机解压后执行 `verify`。
4. A/B 都执行 NTP 校时,要求偏差小于 50ms,否则继续忽略跨机器单向延迟。
### 6.2 P1:2000 人群聊
使用 `scenario=group`:
- A/B 各建立 1000 个 WS,合计 2000 个在线群成员。
- A/B 各取前 500 个发送者,每人 2 条,合计 2000 条群消息。
- A/B 交错每 100ms 发送 1 条,单轮发送窗口约 100s。
- 期望实时投递为 2000 条消息 x 2000 连接,即 400 万次有效到达。
- 检查群消息 ACK、每个 WS 的到达数、重复、乱序、断连、`im.group.message.persist` lag 和群消息月表落库。
群聊是下一个应执行的场景,因为它直接验证本次多 Pod 改造的 Redis fanout 和群消息 Kafka 持久化。
### 6.3 P2:混合场景
群聊正确性通过后使用 `scenario=mixed`:
- 300 对私聊用户双向各发 5 条,合计 3000 条私聊消息。
- A/B 各 250 个群发送者,每人 2 条,合计 1000 条群消息。
- 同时观察私聊与群聊的到达率、两个 Kafka 持久化 Topic 的 lag,以及 Redis、MySQL 和 3 个 Pod 的资源争用。
### 6.4 P3:单聊完整规模回归
最后在 2000 连接下重跑 `p2p`,即 1000 对用户双向各发 10 条,共 20000 条。延迟门禁仍为 ACK P95 <= 500ms、同机 echo P95 <= 1000ms;若只有正确性通过而延迟未通过,结论必须标记为“可靠性通过,性能未达标”。
@@ -0,0 +1,145 @@
# IM 2000 人群聊压测问题分析
日期:2026-09-27
Run ID:`group2k-r1-20260927-01`
场景:A/B 各 1000 个 WebSocket,各 500 个发送者发 2 条,计划发送 2000 条群消息。
## 1. 结论
本轮失败,且不能仅解读为 WebSocket 丢包。核心问题是 2000 人群每条消息触发 O(群成员数) 的额外用户 `me` Topic 路由,导致 Topic master、集群通道和 MySQL 连接池连锁积压。服务端处理吞吐下降后,发送端先经历 ACK 超时,随后 Topic 队列写满并返回 503。
同时,压测程序对 ACK 超时的口径不完整:超时后服务端仍可能接受、广播并落库,不能把 `ctrl_timeout` 直接当作明确拒绝。
## 2. 客户端结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| WS READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| 收到 2xx ACK | 16 | 19 | 35 |
| 503 | 224 | 272 | 496 |
| ACK 超时 | 732 | 709 | 1441 |
| socket 错误 | 28 | 0 | 28 |
| 断线 | 21 | 0 | 21 |
| ACK 台账内实时缺失 | 735 | 0 | 735 |
| ACK 台账内历史落库缺失 | 0 | 0 | 0 |
| 未在 ACK 台账中的实时投递 | 368270 | 377000 | 745270 |
35 条 ACK 消息理论上应产生 70000 次本轮投递,实际共记录 69265 次,其中 A 端少 735 次。但这不是服务端实际接受的全量;额外 745270 次投递来自“未及时收到 ACK,但服务端已处理”的消息。
## 3. 服务端证据
### 3.1 实际接受和落库
- `im_message_publish_stage_duration_seconds_count{kind="group",stage="save",result="ok"}` 增加 488。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 488。
- 群消息 Kafka 批持久化平均约 46.6ms,488 条全部 batch success。
因此,服务端至少接受并落库 488 条,而不是客户端 ACK 台账中的 35 条。其中至少 453 条属于“已接受但 ACK 未在 20 秒内返回”。
### 3.2 队列溢出
| Pod | 日志 | 次数 |
|---|---|---:|
| `im-server-2` | `s.publish: sub.broadcast channel full` | 496 |
| `im-server-2` | `hub: topic's broadcast queue is full` | 1655 |
| `im-server-1` | `cluster: proxy channel full` | 47 |
群 Topic `grp3Ita5tyBIMs` 的 master 在 `im-server-2`。其 `clientMsg` 队列容量只有 192,proxy 响应队列容量为 128。队列满时会立即返回 503 或丢弃 proxy 响应。
### 3.3 每条群消息的路由放大
`Topic.saveAndBroadcastMessage` 在每条消息中同步执行:
1. `presSubsOffline("msg", ..., offlineOnly=true)` 遍历 `t.perUser` 的 2000 个成员。
2. 为每个成员构建一条用户 `me` Topic presence 消息。
3. `routeUserServerMessage` 同时走 Tinode 原生 Cluster 路由和 Redis user fanout。
4. 最后才向群 Topic 的会话广播 `{data}`。
488 条已接受群消息产生了约 98 万次额外用户路由:
- `realtime_fanout_events_total{stage="publish",target_type="user"}` 增加 979979。
- `realtime_fanout_events_total{stage="receive",target_type="user"}` 增加 894360。
- Redis 接收后真正本地投递只有 666 次,`no_local_session` 为 893694。
- Cluster route 约增加 109 万次。
这证明绝大多数用户 fanout 在当前全员已订阅群 Topic 的场景中完全无效,却阻塞了 Topic master 的串行处理协程。
### 3.4 MySQL 连接等待
3 个 Pod 的 `meeting_im` 连接池在测试窗口中合计:
- 等待次数增加 4011。
- 等待时间增加约 35399 秒。
- DB `exec ok` 增加 3453,平均耗时约 501ms。
连接池每 Pod 最大 24 个连接。这不是本轮第一个堵点,但会进一步拖慢群权限检查、未读数更新和其他业务 SQL。仅提高连接数不会消除 O(成员数) 的放大。
## 4. 压测程序口径问题
1. `ctrl_timeout` 和 socket 错误属于发布结果未知,不是明确拒绝。
2. recorder 能够统计 `unexpected_messages`,但 merge 报告没有汇总该字段,也没有将它加入门禁。
3. 历史回查只检查 ACK 台账,因此只检查了 35 条,无法单独证明其他超时消息的落库情况。
4. 群聊应增加“已 ACK”、“已明确拒绝”、“结果未知”、“未 ACK 但已观测广播”和“最终落库”五种状态。
## 5. 改进顺序
### P0
1. 停止在原生 Cluster 已开启时对同一条 user message 再做 Redis user fanout,避免双通道重复路由。
2. 重构群消息的 unread presence:已订阅该群 Topic 的用户不再生成额外 `me` 通知;未订阅但在线的成员使用节点级批量 fanout,不得在 Topic master 协程中逐人跨 Pod 路由。
3. 为 Topic `clientMsg`、proxy 响应队列和 session send queue 增加 length/capacity/drop Prometheus 指标。
4. 压测 merge 报告增加结果未知及 unacked-observed 口径,并把 `unexpected_messages` 纳入失败门禁。
### P1
1. 检查群消息未读数更新和 Push receipt 是否对 2000 人逐人访问 Redis/MySQL,改为批处理或延迟合并。
2. 为 `CanPost` 的全员禁言和单人禁言结果增加短 TTL 缓存,管理操作时精确失效。
3. P0 完成后先跑 100、500、1000、2000 人群阶梯,每档检查 ACK P95、Topic 队列水位、Cluster route 数、DB pool wait 和 Kafka lag,不直接重跑全量。
## 6. 本轮验收结论
- 连接建立:通过,2000/2000 READY。
- 发布成功率:失败。
- ACK 延迟:失败,已超过 20 秒超时窗口。
- 实时广播:失败,存在队列丢弃和断线。
- Kafka 持久化:已处理的 488 条全部成功,但该结论不代表计划的 2000 条均被接受。
- 总体:失败,必须完成 P0 后按阶梯重测。
## 7. P0 修复实现
本轮分析后的代码修复如下:
1. 群消息的 `{pres what=msg}` 不再由 Topic master 对每个成员逐条跨 Pod 路由。master 将收件人清单合并为一条 `group_unread/users` Redis Pub/Sub 消息;来源 Pod 本地投递一次,其他 Pod 各消费一次。
2. 每个 Pod 只扫描一次本地 WebSocket:已订阅该群 Topic 的连接依靠正常 `{data}` 广播,不重复发送 `me` presence;同一用户未订阅该群的其他设备仍收到 `me` presence,保留多开语义。
3. Redis fanout 未启用或批量发布失败时,自动回退到原来的逐用户 Topic 路由,避免静默漏掉未读通知。
4. 原生 Tinode Cluster 开启时,普通 user message 不再重复发布 Redis user fanout;session control 仍保留 Redis 跨 Pod 广播。
5. `routeSrv` 改为有界非阻塞写入,队列满时记录错误和指标,不再无限阻塞 Topic 处理链路。
6. 新增 `im_internal_queue_enqueue_total`、`im_internal_queue_depth`、`im_internal_queue_capacity`,覆盖 `session_topic_publish`、`hub_route_server`、`topic_client`、`topic_server`、`cluster_topic_proxy`。
修复后的复杂度从“每条消息约 2000 次 Cluster route + 2000 次 Redis publish”降为“一次 Redis publish + 每 Pod 一次本地 session 扫描”。2000 人都订阅群 Topic 时,批量 unread 在本地扫描后不会产生重复 WebSocket 帧。
## 8. 复测观测项
复测继续使用 `group2k-r1` 相同发送模型,并重点检查:
```promql
sum by (queue, result) (rate(im_internal_queue_enqueue_total[1m]))
histogram_quantile(
0.95,
sum by (le, queue) (rate(im_internal_queue_depth_bucket[1m]))
)
sum by (stage, kind, target_type, status) (
increase(realtime_fanout_events_total[5m])
)
```
验收要求:
- `session_topic_publish`、`topic_client`、`topic_server`、`cluster_topic_proxy` 的 `result="full"` 均为 0。
- `group_unread/users` 的 publish 数应接近服务端实际接受的群消息数,不再乘以群成员数。
- 群压测窗口内普通 `message/user` Redis publish 不再随群成员数增长。
- `im_cluster_route_total` 不应再出现每条群消息约 2000 次的线性放大。
- 2000 条计划消息需分别核对 ACK、未知结果、实时观测和最终落库,不能只按 ACK 台账判断落库率。
@@ -0,0 +1,159 @@
# GROUP-2000 R10 双机群聊压测分析与改进
## 1. 测试结论
`group2k-r10-20261004-01` 的 A/B 同步有效,可以作为 2000 人群聊的真实双机容量结论。本轮持久化链路基本正常,但实时扇出已明显超过当前服务容量,整体判定为 **失败**。
- A/B 各建立 1000 个 WS 连接,共 2000/2000 就绪;两机实际开始时间相差约 84ms。
- 2000 条发送尝试中,1984 条收到 202 ACK,16 条因 socket 中断而结果未知,无显式 403/502/503 拒绝。
- 数据库最终找到 1999 条,无重复、无字段不一致;仅 `g-a-0098-02` 未落库。
- 按 1985 条已在服务端确认的消息计算,期望实时投递 3,970,000 次,实际收到 1,979,444 次,到达率 49.86%,实时缺失率 50.14%。
- 1,990,556 次实时缺失全部可从历史消息查回,说明主要问题是实时投递容量,不是 Kafka/MySQL 丢数据。
- 负载期间共 152 次 WS 断连:147 次 connection reset、4 次 timeout、1 次 closed connection。
- ACK P95 为 71.18s,本地实时投递 P95 为 329.68s,已不是普通抖动,而是持续性排队。
JPush 空设备查询放大、MySQL 连接频繁回收和 A/B 起跑错位等 R9 问题在本轮已解决。当前的主要容量瓶颈是大群每条消息的单 Topic 有序处理、跨 Pod multiplex 转发和各 Pod WS 逐连接序列化/写出组合成的实时扇出链路。
## 2. 四层对账
| 层级 | 结果 | 结论 |
| --- | ---: | --- |
| 发送尝试 | 2000 | A/B 各 1000 |
| 即时收到 202 | 1733 | 251 条在超时后迟到 |
| 最终收到 202 | 1984 | 发送成功率 99.2% |
| 发送结果未知 | 16 | 全部为 socket 中断,无业务拒绝 |
| 历史回查 | 1985/1985 | 服务端可确认消息全部可查 |
| 数据库实际落库 | 1999/2000 | 14 条“结果未知”也已落库 |
| 真正未落库 | 1 | A 机 `g-a-0098-02` |
| 实时投递 | 1,979,444/3,970,000 | 到达率 49.86% |
| 重复 / 乱序 | 0 / 0 | 已收到数据的正确性正常 |
16 条结果未知中,14 条 B 机消息已在数据库找到;A 机 `g-a-0102-02` 被实时观测到且已落库;只有 `g-a-0098-02` 在连接断开前未被服务端处理。因此不能把“客户端未收到 ACK”直接等同于“消息未落库”。
## 3. A/B 表现
| 指标 | 机器 A | 机器 B |
| --- | ---: | ---: |
| 就绪连接 | 1000 | 1000 |
| 发送尝试 | 1000 | 1000 |
| 最终观测 ACK | 998 | 986 |
| 结果未知 | 2 | 14 |
| 收到群消息 | 996,563 | 982,881 |
| 断连 | 7 | 145 |
B 机的断连明显更多,但 reset 分布在多个公网源 IP,没有单一坏 IP 的证据。机器差异会影响结果,但 A 机同样出现大量投递缺失和长尾,不能将本轮失败只归因于 B 机网络。
## 4. 指标与时间线
- 负载窗口约为 09:21:43–09:26:32。
- A 机断连集中在 09:23:20–09:25:24;B 机采样到的 reset 集中在 09:24:20–09:25:23。
- 服务端 `/metrics` 在约 09:23:01–09:25:37 多次超过 10s 采集超时;233 次 im-server 采样中 14 次失败,17 次超过 5s。
- Kafka consumer lag 和 oldest age 在收敛阶段均为 0。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 合计 1999,与数据库实际条数一致。
- `im_push_dispatch_queue_enqueue_total{result="ok"}` 为 1999,未观测到 push queue full/error;推送平均处理约 22ms。
- Topic group queue wait 未超过 1s,Cluster P2M/route 计数未观测到 full/error。
- 三个 Pod 的 ACK enqueue/write 指标都在 1s 内。注意该计时从服务端 WS 读循环完成解析后开始,因此客户端几十秒至数分钟的 ACK 延迟,主要发生在服务端 dispatch 之前的读取/调度阶段,或写入 socket 后的网络/客户端接收阶段。
`/metrics` 与 WS 同时出现长时间停顿,说明不只是某个业务队列满,而是 Pod 的 CPU/调度/网络写路径在大规模扇出下被长时间占用。本轮无法通过集群 API 读取 Pod Event 和完整日志,因此不将 CPU throttling、Ingress 和节点网卡中的任何一项单独定为唯一根因。
## 5. 代码链路分析
### 5.1 Topic 主节点
`Topic.broadcastToSessions` 遍历主 Topic 上的本地 session 和远程 Pod multiplex session,为每个目标复制、整理消息并入队。一个 2000 人群的 2000 条消息会生成约 400 万次终端投递。
当 multiplex 队列暂时满时,Topic goroutine 会等待最多 250ms;一个主 Topic 又需要保持消息顺序,所以无法通过对单条消息无界并发来解决。
### 5.2 跨 Pod multiplex
每个远程 Pod 使用一个 multiplex session。它的 drain 为保证 Topic 顺序,对每条 `Cluster.TopicProxy` RPC 同步等待远程返回。这保证了顺序,但对单条 RPC 往返、gob 编解码和远程 proxy Topic 处理的抖动很敏感。
### 5.3 Pod 内 WS 扇出
proxy Topic 收到一条群消息后,仍然遍历本 Pod 上的所有订阅 session,为每个 WS 连接复制消息并入队。每个 WS 只有单个 write loop 串行做序列化和 socket write。当扇出速度超过写出速度,session 队列积压,随后出现连接 reset、客户端重连以及更大的订阅恢复压力。
## 6. 已排除和未排除项
### 已排除为本轮主因
- Kafka 积压或持久化丢失:最终 lag=0,1999 条 batch_ok 与 DB 一致。
- MySQL 批量落库能力:无重复、无字段不一致,只有 1 条在服务端未接收。
- JPush 设备查询放大:1999 次异步入队成功,无队列满,平均耗时低。
- A/B 起跑错位:本轮最终屏障通过,两机实际开始差距小于 100ms。
- 显式限流或内部队列拒绝:没有 429/502/503,未观测到 Cluster、Topic、push 队列 full/error。
### 仍需补齐证据
- 每个 Topic 单条消息的完整处理耗时和扇出目标数。
- multiplex 队列等待、RPC 耗时、远程 proxy 接收到入队完成的分段耗时。
- WS send queue 深度、队列等待 P95/P99、full/purge 和按 Pod 的发生时间。
- Pod CPU usage/throttling、Go scheduler latency、GC pause、goroutine 数、网卡吞吐/丢包/retransmit。
- Ingress/LB 上游 reset、upstream response time、连接数和每 Pod 连接分布。
## 7. 改进方案
### P0:先定位每段长尾
1. 增加 Topic 处理总耗时、单条扇出目标数和 `broadcastToSessions` 耗时直方图,区分 master/proxy。
2. 增加 multiplex 入队等待、队列深度、`Cluster.TopicProxy` RPC 耗时和远程处理耗时。
3. 补齐 WS send queue P95/P99/max、full、purged,并区分 data/ctrl。ACK 必须能从大量 data 帧中被单独观测。
4. 增加 WS 读取到 dispatch 前的时间戳,避免现有 ACK 指标忽略服务端读循环前的停顿。
5. 恢复压测期间的 Pod/Event/CPU 可观测性,指标采集超时不应导致整段监控空白。
### P1:降低实时扇出成本
1. 将 master 到每个 proxy Pod 的多条消息改为有界批量 RPC,批内保持 SeqId 顺序,减少 2000 次同步 RPC 往返、gob 编码和调度开销。
2. 为每个 Topic-Pod 建立单写者有界队列,允许不同 Pod 并行处理,同一 Pod 内严格按 SeqId 投递;不对单 Topic 做无界 goroutine 并发。
3. 研究一次序列化、多 session 复用字节的可行性,避免同一条群消息在每个连接重复做等价 JSON 序列化。用户相关 Topic/权限字段必须先完成安全性分类,不可盲目全局复用。
4. ctrl/ACK 与普通 data 帧分级调度,保证发送者在大群扇出积压时仍能快速获得明确结果。需保持单连接协议语义,不得导致客户端对 data/ctrl 的依赖关系错乱。
5. 对慢消费 WS 定义明确策略:队列达阈值后记录可追踪原因、主动断开并依靠历史补拉,避免一批慢连接长时间拖住整个 Topic。
## 8. 实施和复测顺序
1. 先完成 P0 指标,用相同 2000 人、2000 条参数复现一轮,定量确认 Topic、multiplex RPC、WS queue 和 socket write 各占多少。
2. 实施 multiplex 批量化和有界单写者,先做单元测试、顺序测试、队列满/节点断开/重连测试。
3. 用 500、1000、1500、2000 人阶梯复测,每档至少两轮,不直接只跑最高档。
4. 最后重复 R10 同参数双机测试,必须同时通过实时、历史、DB 和基础设施门禁。
## 9. 下一轮通过门禁
- 2000/2000 WS 就绪,A/B 实际开始差小于 1s。
- 2000 条发送全部获得明确 ACK,无 outcome unknown,无 5xx。
- 数据库精确找到 2000 条,缺失、重复、字段不一致均为 0,Kafka lag 最终归零。
- 实时投递到达率不低于 99.99%,重复和乱序为 0。
- ACK P95 小于 2s,投递 P95 小于 5s;本轮已明确不以 500ms 作为阻断门禁。
- 断连、Topic/multiplex/WS queue full、Pod restart、OOM、liveness failure 均为 0。
- 负载窗口内 `/metrics` 可持续采集,无连续 10s 超时;CPU throttling、GC、网络 retransmit 和 Ingress reset 有完整记录。
## 10. 产物
- 结果目录:`loadtest/im-pressure/results/group2k-r10-20261004-01/`
- 汇总报告:`report.json`
- 数据库核验:`db-verification.json`
- A/B 明细:`agent-a-summary.json`、`agent-b-summary.json`
- coordinator 台账:`coordinator.json`
- 服务指标:`metrics.jsonl`
- 代码版本:`c46209f`
## 11. 本轮已实施改造
R10 分析后已完成以下代码改造,待发布后进行 R11 复测:
- 普通非频道群消息在每个 Pod 仅执行一次 JSON 序列化,同 Pod 的所有 WS 连接复用同一份不可变字节。权限过滤、`SkipSid`、频道匿名、P2P 别名和非 WS 协议仍保持原路径。
- 增加 Topic 完整处理耗时、广播耗时和按 websocket/multiplex/other 分类的扇出目标数。
- 增加 master-to-proxy multiplex 入队深度和排队时间,以及远程 proxy Topic 接收后的队列等待时间。
- 增加 WS 完成读帧后的 JSON 解析和本地 dispatch 耗时。
- 新快路仍在 WS write loop 记录原有 `OutgoingMessageSize` 统计,不会因预序列化丢失原有监控口径。
本次没有直接引入新的批量 `net/rpc` 方法。StatefulSet 滚动发布时会短暂出现新旧 Pod 混合,新 RPC 端点与旧 Pod 不兼容,会产生发布窗口风险。R11 先用新增指标确认 multiplex RPC 在优化后是否仍为主瓶颈;若需要批量化,应采用“先发布双协议接收端,再开启批量发送端”的两阶段发布。
新增指标:
- `im_topic_process_duration_seconds`
- `im_topic_broadcast_duration_seconds`
- `im_topic_broadcast_targets`
- `im_topic_shared_serialization_total`
- `im_cluster_m2p_queue_wait_duration_seconds`
- `im_cluster_m2p_queue_depth`
- `im_cluster_proxy_queue_wait_duration_seconds`
- `im_ws_read_dispatch_duration_seconds`
@@ -0,0 +1,161 @@
# GROUP-2000 R11 双机群聊复测分析
## 1. 结论
`group2k-r11-20261004-01` 判定为 **失败且不能作为稳定容量结论**。
本轮确认了 R10 后的群消息共享序列化优化已经生效,正常运行阶段的实时到达率明显高于 R10;但压测末段发生了约 1 分钟的公网入口/上游整体不可用,2000 个连接几乎同时断开。与此同时,Kafka 群消息持久化链路仅落库 1165 条,662 条服务端已确认或已观测消息未落库,暴露出批消费可靠性缺陷。
必须先修复 Kafka 批消费和查清入口整体中断,再重复相同参数。当前不能把 93.83% 到达率当作 2000 人容量通过,也不能继续直接提高压力。
## 2. 核心结果
| 指标 | R11 结果 | 判定 |
| --- | ---: | --- |
| WS 就绪 | 2000/2000 | 通过 |
| A/B 实际起跑差 | 约 290ms | 通过 |
| 发送尝试 | 2000 | A/B 各 1000 |
| 最终明确 ACK | 1718 | 85.90% |
| 发送结果未知 | 282 | 失败 |
| ACK P95 | 58.37s | 失败 |
| 期望实时投递 | 3,654,000 | 按 1827 条服务端确认/回查消息计算 |
| 实际实时投递 | 3,428,697 | 到达率 93.83% |
| 实时缺失 | 225,303 | 6.17% |
| 数据库核验 | 1165/1827 | 缺失 662 条 |
| 重复 / 乱序 | 0 / 0 | 已收到部分正确 |
| WS 断连 | 2036 | 失败,包含重连后的再次断开 |
R10 的实时到达率为 49.86%,R11 提升到 93.83%,说明共享序列化方向有效;但 R11 同时出现了全局入口故障和持久化缺失,两个结果的运行条件不同,不能据此计算严谨的容量提升比例。
## 3. A/B 分机结果
| 指标 | 机器 A | 机器 B |
| --- | ---: | ---: |
| 就绪连接 | 1000 | 1000 |
| 发送尝试 | 1000 | 1000 |
| 最终观测 ACK | 919 | 799 |
| 结果未知 | 81 | 201 |
| 收到群消息 | 1,816,953 | 1,611,744 |
| 断连 | 1005 | 1031 |
| 主要断连原因 | abnormal close 996 | abnormal close 653、reset 378 |
B 的网络表现仍弱于 A,但 A 也几乎全量断开,因此本轮不能归因于单独一台压测机。
## 4. 故障时间线
时间均为北京时间 2026-10-04:
- 10:35:36:A/B 同步开始,2000 个连接全部就绪。
- 10:35:44 起:公网 `/metrics` 出现高延迟和多次 10s 超时,表明服务在正式断开前已经出现调度或网络拥塞。
- 10:40:06、10:40:12:`/metrics` 返回 HTTP 521。
- 10:40:16 起:A 机对三个直连源 IP 的 443 均收到 `connection refused`,随后登录出现 500、WS 握手出现 503。
- 10:40:17:A 的就绪连接从 1000 降到 8,约 992 个连接集中断开;B 同期发生同类大规模断开。
- 10:40:48–10:41:03:公网入口持续返回 503。
- 10:41:09 后:入口恢复,客户端自动重连;A 约在 10:43:37 恢复到 1000 个连接。
直连源 IP 同时拒绝 443,且公网出现 521/503,这不是 Cloudflare DNS 或压测机代理造成的普通客户端故障。采样期间 Tinode cluster 指标仍为 2/2 live peers,没有证据表明原生 cluster 先失联。
2026-10-04 11:31–11:32 重新探测时,三个源 IP 的 443 均已恢复并可通过 `api.118im.com` Host/SNI 返回健康检查 200。三个 IP 返回的是同一张 `TRAEFIK DEFAULT CERT`,SHA-256 指纹均为 `7C:9E:00:6F:56:D6:C9:87:44:62:7D:26:93:D8:92:51:F8:DA:43:66:61:DB:8B:F9:1F:47:17:C8:93:13:C4:8C`,证书 `notBefore` 为 **2026-10-04 10:41:32**。该时间与 R11 的 521/503 和源站拒绝连接恢复窗口精确重合,是 Traefik/入口组件在故障期发生重启、重建或重新初始化的强证据。
目前可以把故障范围收敛到 Traefik/入口层,但仅凭默认证书时间仍不能区分 Traefik Pod 重启、Deployment 滚动、节点重启、配置重载或入口服务重建。
运维已确认 Kubernetes API `216.236.31.189:6443` 按安全策略不对公网开放,只允许集群网络访问;当前调试机超时属于预期行为,不是本轮故障的一部分。Pod Event、Ingress controller 日志和节点事件必须由运维从控制面节点或集群内采集。在这些证据回传前,仍不把某一个基础设施组件写成唯一根因。
## 5. 共享序列化与实时链路
三个 Pod 的 `im_topic_shared_serialization_total` 在采集窗口分别增加约 5392、6664、6831,证明新快路已实际执行。
按采集窗口增量估算:
| 阶段 | 结果 |
| --- | --- |
| master Topic broadcast P95 | 不超过 5ms |
| proxy-0 Topic broadcast P95 | 不超过 5ms |
| proxy-1 Topic broadcast P95 | 不超过 10ms |
| master 到 peer-0 的 multiplex 排队 P95 | 不超过 100ms |
| master 到 peer-1 的 multiplex 排队 P95 | 不超过 250ms |
| WS read-dispatch P95 | 三个 Pod 均不超过 50ms |
Topic 广播本身已不再呈现分钟级长尾,说明“同一群消息对每个 WS 重复 JSON 序列化”得到明显缓解。当前长尾更多出现在 multiplex 排队、socket/入口可用性以及故障后的重连恢复阶段。WS read-dispatch 仍有少量超过 5s 的样本,三个 Pod 分别约 13、10、18 次,需要保留观察,但不是本轮全量断开的充分解释。
## 6. Kafka 与数据库异常
### 6.1 对账证据
- 1827 条服务端确认或历史回查消息中,数据库精确找到 1165 条,缺失 662 条。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 观测上限停在 1165,此后不再增长。该 counter 没有 `pod` 标签,公网采样又会随机落到不同 Pod,因此不能直接把它当作集群总数;这里只作为持久化停止推进的旁证。
- 缺失消息全部来自第二轮:A 缺 339 条,序号 0085–0423;B 缺 323 条,序号 0085–0414。A 是完整连续区间,B 仅有少量间隙,不符合随机单条 MySQL 写失败的形态。
- 采样结束时 group persist 的 lag 仍显示 0,但 `im_kafka_backlog_oldest_age_seconds` 在一个 Pod 上达到约 1082s。二者互相矛盾,现有 lag 指标不能作为“已消费完成”的依据。
- 压测结束约 30 分钟后再次直连 MySQL 核验,结果仍为 1165/1827,缺失 662、错配 0、重复 0、额外消息 0。缺失没有后台追平,不能解释为普通消费延迟。
### 6.2 已确认的代码风险一:部分批次被丢弃
`fetchKafkaMessageBatch` 先连续调用 `FetchMessage` 组装批次。如果已经抓到若干消息,后续抓取返回非 `DeadlineExceeded` 错误,函数直接返回 `nil, err`,已经抓到的部分消息不会交给 handler,也不会立即提交。
Kafka reader 的内部读取位置已经前移。恢复后若继续抓取并提交更高 offset,之前被丢弃的消息可能随高 offset 一起被越过。这可以直接造成“生产成功、最终 lag 为 0、数据库却缺少一段连续消息”。
### 6.3 已确认的代码风险二:批量幂等 claim 可能活锁
`handleKafkaMessagePersistBatch` 在真正批量落库前逐条执行 `BeginKafkaConsume`。如果批次中途发生 Redis/基础设施错误,前面已经 claim 的消息不会释放;外层重试从批首重新开始,又会遇到 `processing` 状态。不同 claim 的过期时刻略有差异时,重试还可能反复续上前面的 claim,长期无法走到后面的消息。
这能解释 oldest age 长时间增长且持久化计数停止。当前“失败不提交 offset”的修复方向正确,但还没有覆盖“抓取半批失败”和“准备半批失败”两个边界。
### 6.4 lag 指标口径不可靠
当前指标直接读取 `kafka-go Reader.Stats().Lag`,同时在每次批处理结束后把 oldest age 清零。consumer group 模式下,该 lag 不是可靠的已提交 offset 差值;消息被 reader 抓取但尚未持久化时,也可能显示 0。必须改为按 partition 查询 high watermark 与 committed offset,并分别暴露 fetched、processed、committed offset。
## 7. 优先改进项
### P0:Kafka 持久化可靠性
1. `fetchKafkaMessageBatch` 在抓到部分消息后发生可恢复错误时,应返回已抓取批次供 handler 处理;只有一条都没抓到时才返回 fetch error。
2. 批量准备阶段不能留下半批 claim。可先完成无副作用的解码与校验,再逐条 claim;claim 冲突时按消息拆分处理,不能让整个批次从头活锁。
3. offset 只允许在该 partition 对应消息全部持久化成功后提交;增加 fetched/processed/committed offset 和 commit failure 指标。
4. 增加回归测试:半批 fetch 后网络错误、准备第 N 条失败、Redis 短暂不可用、处理成功但 commit 失败、Pod 在 handler 中重启。
5. 修复后先执行 2000 条“只验证 Kafka/MySQL”的持久化专项,要求 DB 2000/2000、lag/offset 对账一致,再恢复大群实时压测。
### P0:入口整体中断
1. 从 Ingress controller、Service endpoint、入口节点和云负载均衡拉取 10:40:00–10:41:10 的日志与事件。
2. 核对是否发生 Ingress reload、endpoint 短暂清空、节点 conntrack/端口耗尽、进程监听中断、liveness 误杀或网络策略更新。
3. 增加入口黑盒探针:Cloudflare 域名、源站 IP、Service、Pod 四层同时采样 TCP connect、TLS handshake、HTTP status 和耗时。
4. 把“源站三个 IP 同时 connection refused”设为基础设施门禁,发生时本轮自动标记无效并保留现场。
### P1:实时链路
1. 保留共享序列化实现;从本轮数据看无需回退。
2. 继续降低 master-to-proxy multiplex 排队,优先考虑兼容发布的有界批量 RPC 或单写者批发送。
3. 补采 Pod CPU throttling、Go scheduler、GC、网卡 retransmit、Ingress upstream reset,解决 `/metrics` 本身在高压阶段失去可观测性的问题。
## 8. 下一轮顺序
1. 修复 Kafka 两个批消费边界并完成单元/故障注入测试。
2. 运行持久化专项,确认 2000/2000 精确落库。
3. 恢复 Kubernetes 与 Ingress 现场采集能力,查清 R11 的入口中断。
4. 使用 R11 完全相同参数执行 R12,要求连续两轮无入口故障、无 outcome unknown、DB 2000/2000。
5. 通过后再判断 2000 人容量;不要在问题未清零前提高并发。
## 9. 产物
- 结果目录:`loadtest/im-pressure/results/group2k-r11-20261004-01/`
- 汇总:`report.json`
- 数据库核验:`db-verification.json`
- A/B 明细:`agent-a-summary.json`、`agent-b-summary.json`
- 协调器台账:`coordinator.json`
- 指标:`metrics.jsonl`
- 被测代码:`b05e8f6 perf(group): reduce websocket fanout serialization`
## 10. 分析后已实施修复
已完成以下 Kafka 持久化修复,待发版后先执行持久化专项,再进行 R12:
1. 批量抓取在取得部分消息后遇到 broker/网络错误时,不再丢弃已抓消息;消费者会先处理并提交这部分消息,同时保留错误日志。
2. 批准备过程中后续消息 claim 失败时,先持久化并完成前面已经 claim 的消息,再将准备错误交给外层重试,避免整批 processing claim 活锁。
3. 新增半批 fetch error、首次 fetch error、部分 claim 后准备失败、刷盘与准备同时失败等回归测试。
验证结果:
- Kafka 定向单元测试通过。
- `go test ./server ./server/store/... ./loadtest/im-pressure` 通过。
- Kafka 定向 `go test -race` 通过。
- `go vet` 仍报告 `server/internal_cultivate_test.go` 中既有的 `sync.Map` 锁值复制问题,与本次改动无关。
@@ -0,0 +1,168 @@
# GROUP-2000 R12 双机群聊复测分析
## 1. 结论
`group2k-r12-20261004-01` 判定为 **失败,2000 人群聊容量仍未通过**。
本轮有一个明确的正向结果:R11 后的 Kafka 部分批次修复已经生效。服务端实际接收并可从投递或历史中确认的 1694 条消息,MySQL 精确找到 1694 条,缺失、错配、重复和额外记录均为 0。
实时链路仍然失败。A/B 虽然均以 1000/1000 条连接起跑,但负载开始约 33 秒后公网 `/metrics` 开始连续超时,随后出现两波大规模 WebSocket reset。最终实时到达率只有 74.739%,ACK P95 为 74.72 秒,包含迟到 ACK 的控制响应 P95 达 407.90 秒。
与 R11 不同,本轮 Pod 没有重启,日志也没有再次出现 CoreDNS 超时。证据表明本轮先发生群 Topic 串行处理积压和入口响应失速,连接被成批 reset 后又触发重连风暴,三个 Pod 的业务库连接池均出现约 9,200-11,350 秒累计等待,最终形成放大回路。
## 2. 核心结果
| 指标 | R12 结果 | 判定 |
| --- | ---: | --- |
| WS 起跑就绪 | 2000/2000 | 通过 |
| A/B 起跑差 | 约 33ms | 通过 |
| 发送尝试 | 2000 | A/B 各 1000 |
| 最终明确 ACK | 1501 | 75.05%,失败 |
| 发送结果未知 | 499 | 失败 |
| 服务端接收/回查确认消息 | 1694 | 另有 306 条未确认进入服务端 |
| ACK P95 | 74.72s | 失败 |
| 含迟到 ACK 的控制响应 P95 | 407.90s | 失败 |
| 期望实时投递 | 3,388,000 | 1694 x 2000 |
| 实际实时投递 | 2,532,160 | 到达率 74.739% |
| 实时缺失 | 855,840 | 丢失率 25.261% |
| MySQL 精确核验 | 1694/1694 | 通过 |
| 重复 / 乱序 | 0 / 0 | 已收到部分正确 |
| WS 断连 | 1257 | 失败 |
| Pod 重启 | 0 | 无应用容器重启 |
`publish_final_accepted=1501` 只统计客户端最终观察到的 ACK。数据库和消息台账共同证明实际有 1694 条进入服务端,因此其中 193 条属于客户端未拿到 ACK、但服务端已经处理的消息。
## 3. A/B 分机结果
| 指标 | 机器 A | 机器 B |
| --- | ---: | ---: |
| 起跑连接 | 1000 | 1000 |
| 发送尝试 | 1000 | 1000 |
| 及时 ACK | 547 | 601 |
| 迟到 ACK | 327 | 26 |
| 结果未知 | 126 | 373 |
| 收到群消息 | 1,389,624 | 1,142,536 |
| 实时缺失 | 304,376 | 551,464 |
| 断连 | 433 | 824 |
| 主要断连原因 | reset 413、timeout 20 | abnormal close 405、reset 419 |
B 在第一波连接重置中受影响更重;第二波则同时影响 A/B,不能归因于单台压测机。
## 4. 故障时间线
时间均为 UTC,北京时间加 8 小时:
- 15:57:31:A/B 同步起跑,2000 个连接全部就绪。
- 15:57:34-15:57:41:公网 `/metrics` 已从亚秒上升到约 2.5 秒。
- 15:58:04:公网 `/metrics` 首次 10 秒超时;此后连续 28 次超时。
- 15:58:24-15:58:59:第一波约 394 条服务端连接集中关闭,主要对应 B;B 汇总为 405 条 abnormal close。
- 16:00:15 起:`im-server-0` 开始出现用户缓存更新队列满,窗口内共 78 次。
- 16:01:50-16:02:10:第二波约 810 条服务端连接集中关闭;A/B 汇总的 reset 合计为 832 条。
- 16:02:06 起:大量重连登录出现 `context deadline exceeded`。
- 16:02:08:`im-server-1` readiness 请求本身超时;期间还出现一次 liveness 超时,但未达到重启阈值。
- 16:02:39-16:02:47:三个 Pod 均产生 readiness 503 事件。
- 16:02:48、16:02:50:`im-server-2`、`im-server-1` 恢复 Ready;`im-server-0` 未形成持续 NotReady。
- 16:05:04:公网 `/metrics` 恢复 200,首次恢复请求仍耗时约 4.6 秒。
- 16:11:55:A/B 汇总完成。
三个 Pod 的 `restartCount` 均为 0。R11 出现过的 `10.43.0.10:53`、`no such host`、DNS connection refused 在本轮窗口内均未出现,因此不能继续把本轮失败归因于 CoreDNS。
## 5. 容量瓶颈
群 Topic master 位于 `im-server-2`。最终指标显示:
| 指标 | 数值 |
| --- | ---: |
| 群发布处理次数 | 1694 |
| Topic publish 处理总耗时 | 116.59s |
| 单条平均处理耗时 | 约 68.8ms |
| Topic 队列等待总耗时 | 2279.92s |
| 单条平均排队 | 约 1.35s |
| 队列等待最大桶 | 30s 内 |
| master 广播总耗时 / 次数 | 21.33s / 4803 |
| master 单次广播平均耗时 | 约 4.4ms |
按 68.8ms 的串行 Topic 处理耗时估算,单群持续处理能力约为 14.5 条/秒,低于本轮约 20 条/秒的目标发送速率。共享序列化后的广播本身仍然只有毫秒级,不是主要瓶颈;耗时主要位于 Topic publish 的保存、Kafka produce、权限检查和推送准备等关键路径。
这也解释了为何在正式断连前 ACK 已经出现 20-90 秒长尾,并且 `/metrics` 先失去响应。
## 6. 断连与入口
Pod 日志中的两波连接关闭均来自入口上游连接;客户端看到的是三个源站 IP 返回 `connection reset by peer`,不是主动退出压测程序:
- 第一波关闭连接的服务端会话寿命平均约 122 秒,集中影响 B。
- 第二波同时影响 A/B;旧连接寿命主要集中在约 330 秒和 660 秒,说明它是同一时刻的批量重置,而非每条连接独立超时。
- 三个源 IP 最终汇聚到同一个入口上游地址的现象仍然存在,入口层缺少副本、连接上限、reload/restart、upstream reset 和节点 conntrack 证据。
应用负载先失速,入口随后批量 reset,因此当前不能只改客户端重连参数来宣告通过。运维仍需补齐 Traefik、入口节点和 Service endpoint 的同窗口证据,判断 reset 是入口进程、节点网络、连接上限还是上游超时策略触发。
## 7. 重连放大与数据库
压测前后业务库连接池增量如下:
| Pod | 新增等待次数 | 新增累计等待 | 平均每次等待 |
| --- | ---: | ---: | ---: |
| im-server-0 | 817 | 9956.29s | 约 12.2s |
| im-server-1 | 770 | 9218.27s | 约 12.0s |
| im-server-2 | 768 | 11348.20s | 约 14.8s |
每个 Pod 的 `meeting_im` 最大连接数为 24。连接集中断开后,压测端自动重连触发登录、用户状态、IP 白名单和认证版本查询,连接池等待迅速升高;登录进一步超时,readiness 中的数据库检查也随之失败,Pod 被入口摘除,形成反馈回路。
不能简单地只提高连接池上限。下一轮前应先定位连接被长时间占用的 SQL/事务,并为登录重连增加服务端限流、排队和抖动;否则只是把压力转移到 MySQL。
## 8. Kafka 与落库
数据库直连核验结果:
| 项目 | 结果 |
| --- | ---: |
| tenant | allrich / tenant_id=8 |
| 期望消息 | 1694 |
| 精确找到 | 1694 |
| 缺失 | 0 |
| 字段错配 | 0 |
| 重复行 | 0 |
| 额外记录 | 0 |
| 分表 | `im_008_group_messages_202610`、`im_008_messages` |
这证明 R11 的 Kafka 部分批次丢弃问题已修复。最后一次公网指标采样中某个 reader 的本地 `im_kafka_consumer_lag` 仍显示 111,但稍后的数据库核验已经完整;再次说明 `kafka-go Reader.Stats().Lag` 不能作为 consumer group 已提交 offset 的最终依据。
## 9. 改进优先级
### P0:群 Topic 关键路径
1. 对 Topic publish 的 save、Kafka produce、权限查询、推送准备分别补充 P95/P99 和并发占用,确认 68.8ms 的主要组成。
2. 将不要求阻塞 ACK 的推送准备、通知类工作移出 Topic 单写者关键路径。
3. 对必须保持顺序的持久化和序列分配设计有界流水线;保持同群顺序和失败可重试,不能重新引入已移除的业务 outbox。
4. 优化后先跑 10、12、15、18、20 msg/s 阶梯,连续两轮确认 Topic 队列不持续增长,再重新判定 20 msg/s 容量。
### P0:入口批量 reset
1. 由运维提取 15:57:30-16:05:10 的 Traefik Pod 日志、事件、restart、reload、upstream reset 和打开连接数。
2. 核对三个公网源 IP 是否最终只有一个 Traefik 实例承载;入口至少配置两个副本、反亲和和 PDB。
3. 同步采集节点 conntrack、TCP retransmit、listen overflow、文件描述符、网卡丢包和 CPU throttling。
4. 下一轮同时探测公网域名、每个源 IP、Service 和三个 Pod,避免 `/metrics` 单点失明。
### P1:重连保护
1. 客户端维持自动重连,但使用指数退避和随机抖动,避免 2000 条连接同步登录。
2. 服务端对登录重建增加有界并发和快速过载响应,不让请求无限占用数据库连接池。
3. 分解登录 SQL 指标,定位连接池长等待对应的查询或事务。
4. readiness 的数据库检查应使用独立的小连接预算或短路缓存,避免业务池耗尽导致全部 Endpoint 同时被摘除;liveness 继续只检查进程存活。
## 10. 下一轮顺序
1. 先做 2000 连接、0 发消息的 15 分钟稳定性测试,排除入口固定连接寿命和 B 网络路径问题。
2. 再做单群阶梯:10、12、15、18、20 msg/s,每档 5 分钟,观察 Topic queue 是否线性增长。
3. 完成入口和数据库现场采集后,重复 R12 原参数两轮。
4. 通过标准:2000/2000 连接全程稳定、发送成功率不低于 99.99%、实时到达率不低于 99.99%、DB 精确落库、无批量 reset、无持续 Topic/DB 队列增长。
## 11. 产物
- 结果目录:`loadtest/im-pressure/results/group2k-r12-20261004-01/`
- 汇总:`report.json`
- 数据库核验:`db-verification.json`
- A/B 明细:`agent-a-summary.json`、`agent-b-summary.json`
- 协调器台账:`coordinator.json`
- 指标:`metrics.jsonl`
- 被测代码:`965f94f fix(kafka): preserve partially fetched message batches`
@@ -0,0 +1,134 @@
# IM 2000 人群聊 r4 复测分析
日期:2026-09-28
Run ID:`group2k-r4-20260928-01`
场景:机器 A/B 各 1000 个 WebSocket、各发送 1000 条,计划共发送 2000 条群消息。
## 1. 结论
本轮不能按合并报告中的 `14.39%` 实时丢失率直接判定服务端群消息丢失。两台压测机结果存在明显单边故障:
- 机器 B 的 1000 个连接全部稳定,1000 次发送全部收到 ACK,1497000 次 ACK 台账内投递全部到达,断线和实时缺失均为 0。
- 机器 A 出现 654 次断线、314 次 socket 错误和 189 次 ACK 超时。A 的 TCP 源地址错误中包含 `broken pipe`、`websocket closed`,是本轮 430832 次实时缺失的主要来源。
- 服务端实际接受并持久化 1601 条消息,其中客户端收到 ACK 1497 条,另外 104 条属于“客户端未收到 ACK,但服务端已接受”。B 恰好多收到 104000 次消息,即 104 条乘以 B 的 1000 个连接,账目完全吻合。
- ACK 台账内 1497 条消息历史回查全部找到,`persistence_lost=0`;Kafka 群消息消费 lag 在采样点均为 0。
- 上一轮的 O(群成员数) Redis fanout 放大已经消除:1601 条服务端消息只产生 1601 次 `group_unread/users` publish,每个远端 Pod 各 receive 1601 次,不再产生约 320 万次逐用户 Redis publish。
- 实时发送与 60 秒收尾阶段没有新增 `topic_server` 或 `cluster_topic_proxy` 满队列。满队列发生在约 5 分钟后的并发历史回查阶段,属于另一项需要优化的问题,不能作为本轮实时丢失的原因。
因此,本轮结论为:**批量群未读 fanout 修复通过;B 侧实时投递通过;数据库持久化通过;A 侧网络稳定性失败;历史回查并发会打满 Topic/Proxy 队列。整体尚不能完成 2000 人群聊验收。**
## 2. 客户端结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| READY 连接 | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| 收到 2xx ACK | 497 | 1000 | 1497 |
| 明确拒绝 | 0 | 0 | 0 |
| ACK 超时 | 189 | 0 | 189 |
| socket 结果未知 | 314 | 0 | 314 |
| 断线 | 654 | 0 | 654 |
| ACK 台账内应收 | 1497000 | 1497000 | 2994000 |
| ACK 台账内实收 | 1066168 | 1497000 | 2563168 |
| ACK 台账内实时缺失 | 430832 | 0 | 430832 |
| ACK 台账外额外投递 | 68668 | 104000 | 172668 |
| 重复 / 乱序 | 0 / 0 | 0 / 0 | 0 / 0 |
| 历史检查 / 找到 | 1497 / 1497 | 0 / 0 | 1497 / 1497 |
合并报告按 ACK 台账计算出的到达率为 `85.61%`,但这只是 A/B 网络条件混合后的观测值,不代表服务端总体投递率。B 单机的 ACK 台账内到达率是 `100%`,A 单机是 `71.22%`。
A 端记录了 160074 个跨机器负延迟样本,说明两机时钟没有达到单向延迟统计要求。本轮不使用跨机器 P95/P99 判断性能,只使用 ACK、到达计数、断线、历史回查和服务端阶段指标判断正确性。
## 3. 未收到 ACK 的 503 条不是服务端拒绝
本轮没有任何明确的 HTTP/Tinode `503` 响应。报告字段 `publish_rejected=503` 实际由以下结果未知项组成:
- `ctrl_timeout=189`
- `socket=314`
服务端持久化计数增加 1601,而 ACK 只有 1497,说明至少 104 条结果未知消息已被服务端成功接受。B 的 `unexpected_messages=104000` 与这 104 条消息对 1000 个 B 连接的广播数量完全一致。
后续报告应把字段继续显示为 `publish_unknown`,不能把它展示为 `rejected`,否则容易误判服务端主动拒绝。
## 4. 服务端与中间件
### 4.1 Kafka 与数据库落库
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 1601。
- `im.group.message.persist` consumer lag 在采样点均为 0。
- 1497 条 ACK 台账消息通过历史接口全部找到,持久化缺失为 0。
- 服务端群消息 `save` 总耗时增加约 26.27s,平均约 16.4ms/条。
- Kafka produce 总耗时增加约 19.15s,平均约 12.0ms/条。
这证明已接受消息能够及时进入 Kafka 并完成落库。104 条结果未知消息也计入 1601 条 batch success,但当前历史校验只以 ACK 台账为准,下一版应对“已观测但未 ACK”的消息也做 message ID 回查。
### 4.2 Redis fanout 修复验证
1601 条服务端接受消息对应:
- master Pod `group_unread/users publish ok` 增加 1601。
- 两个远端 Pod 的 `group_unread/users receive ok` 各增加 1601。
- 普通 `message/user` 在原生 Cluster 开启时继续使用 `skip_native_cluster`,没有恢复逐成员 Redis 发布。
这符合“一条群消息一次 Redis publish、每个 Pod 一次本地扫描”的设计,上一轮每条消息约 2000 次 Redis publish 的放大已经消失。
### 4.3 队列满发生在历史回查阶段
按时间序列拆分:
- `16:25:05Z` 开始发送。
- `16:28:36Z` 最后一台压测机结束发送,之后进入收尾和历史等待。
- 截至 `16:34:01Z`,`topic_server/full` 仍停留在测试前基线 1349,`cluster_topic_proxy/full` 仍为 0。
- `16:34:25Z` 起,历史回查并发开始使 `topic_server/full` 增加。
- 最终历史阶段新增 `topic_server/full=1355`、`cluster_topic_proxy/full=55`。
因此这些队列丢弃不是实时发送阶段 430832 次缺失的原因。历史回查一次并发拉取大量消息,经 Topic/Proxy 回送时会形成独立洪峰,应限制每机历史校验并发或分页节流,并单独验收历史吞吐。
## 5. A 机器故障判断
A 与 B 使用相同代码、相同公共入口和相同发送模型,但只有 A 发生 654 次断线。A 的错误包含:
- `websocket closed`
- `write: broken pipe`
- `ctrl timeout`
- 到多个公网入口 IP 的 TCP 读写超时
这种强烈单边性不符合服务端全局过载的表现。当前最可能的故障域依次是 A 本机网络/NAT/安全软件、A 到公网 LB 的链路、A 的文件描述符或本地调度压力。由于 Kubernetes Metrics API 被 RBAC 拒绝,本轮没有取得 Pod CPU/内存数据,不能用资源指标进一步排除 Pod 饱和。
## 6. 下一轮执行顺序
1. 先做 A/B 角色互换:B 仍发 1000 条,A 暂时只保持 1000 个连接不发送。若 A 仍大量断线,可确认与服务端发送负载无关。
2. 在 A 机同步采集 `ping`、TCP retransmit、连接跟踪、文件描述符、CPU、内存和进程调度数据;压测前检查 `ulimit -n`。
3. 将历史回查改为分页和限并发,或先关闭自动历史回查,仅验证实时阶段;实时阶段结束后再启动独立历史校验。
4. 修正报告字段,把超时/socket 明确归类为 `publish_unknown`,并把 104 条 unacked-observed 消息纳入历史校验。
5. 补充 `backend-debug` 对 `metrics.k8s.io/pods` 的只读权限,否则下一轮仍无法采集 Pod CPU/内存。
6. A 链路稳定后,使用相同 2000 连接、2000 条消息模型重新验收。门禁要求两侧断线为 0、明确拒绝为 0、实时到达率 100%、持久化丢失为 0,并分别报告实时阶段和历史阶段的队列指标。
## 7. 结果文件
本轮原始结果位于:
```text
loadtest/im-pressure/results/group2k-r4-20260928-01/
agent-a-summary.json
agent-b-summary.json
coordinator.json
report.json
pre-metrics.log
pod-metrics.log
post-metrics.log
```
## 8. 已实施改造
本分析完成后,压测工具已实施以下改造:
1. agent 分开输出 `publish_rejected` 和 `publish_outcome_unknown`;前者只包含服务端明确拒绝,后者包含 ACK 超时和 socket 失败。
2. A/B 在 drain 后向 coordinator 提交“实时已观测但未 ACK”的消息台账,coordinator 去重合并后将其加入历史落库核验。
3. 报告新增 `history_unacked_observed_checked` 和 `history_unacked_observed_found`,可直接确认结果未知消息是否最终落库。
4. r5 复测后,历史回查进一步调整为 1 个 worker、每页 20 条、全局每 500ms 最多发起一次查询,避免核验程序自身制造 Topic/Proxy 洪峰。
5. 新增 `backend-debug-metrics-rbac.yaml`,为测试环境的 `argocd/backend-debug` ServiceAccount 提供 `metrics.k8s.io/pods` 的 namespace 只读权限。
上述改造只解决统计口径、历史查询压力和资源指标权限。机器 A 的公网连接异常仍需通过下一轮主机与网络观测定位。
@@ -0,0 +1,92 @@
# IM 2000 人群聊 r5 复测分析
日期:2026-09-28
Run ID:`group2k-r5-20260928-01`
## 1. 结论
本轮再次证明服务端对稳定客户端的群消息可靠性没有丢失,失败集中在机器 A 的本地代理/TUN 链路:
- B:1000 次发送全部 ACK,1426000 次 ACK 台账内投递全部到达,断线、重复、乱序和实时缺失均为 0。
- A:574 次发布结果未知、657 次统计窗口内断线、464596 次 ACK 台账内实时缺失。
- A 的 broken pipe 全部写向 `198.18.0.81:443`;本机 DNS 也将 `api.118im.com` 解析为 `198.18.0.81`,路由经 `utun19827 -> 172.19.0.1`。`198.18.0.0/15` 是代理/TUN 常用 Fake-IP 网段,不是 API 的真实公网地址。
- 机器 A 压测进程 CPU 平均占用不高、内存峰值约 150MB,B 在同一服务端负载下完全稳定,不支持“服务端全局过载”解释。
- 新增观测台账生效:574 条结果未知中,241 条被实时观测并加入历史核验,241 条全部找到。
- 历史总核验 1667/1667,持久化丢失为 0。
- 历史回查仍产生 1197 次 `topic broadcast queue full`,但全部发生在历史阶段,不在实时发送阶段。
总体结论:**群消息落库通过,B 侧实时可靠性通过;A 侧 TUN/Fake-IP 链路失败;历史查询限速仍需继续降低。**
## 2. 汇总结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| ACK | 426 | 1000 | 1426 |
| 明确拒绝 | 0 | 0 | 0 |
| ACK 超时 | 444 | 0 | 444 |
| socket 结果未知 | 130 | 0 | 130 |
| 断线 | 657 | 0 | 657 |
| ACK 台账内应收 | 1426000 | 1426000 | 2852000 |
| ACK 台账内实收 | 961404 | 1426000 | 2387404 |
| ACK 台账内实时缺失 | 464596 | 0 | 464596 |
| ACK 台账外额外投递 | 185738 | 241000 | 426738 |
| 历史核验 | 1667/1667 | 0/0 | 1667/1667 |
| 未 ACK 观测消息核验 | 241/241 | 0/0 | 241/241 |
服务端实际处理消息至少为 `1426 + 241 = 1667` 条。其余 333 条结果未知既没有 ACK,也没有任何实时观测证据,不能证明服务端接受。
## 3. A 机器故障证据
### 3.1 网络路径
测试结束后的本机检查结果:
```text
api.118im.com -> 198.18.0.81
route: 198.18.0.81 -> gateway 172.19.0.1, interface utun19827
broken pipe remote: 198.18.0.81:443
```
A 的错误构成为:
- `ctrl_timeout=444`
- `websocket_closed=103`
- `broken_pipe=27`
- 其他 1
连接从 `17:05:12 UTC` 开始下降,随后从 1000 持续降至约 333;B 同期保持 1000 连接。下一轮前必须关闭该代理/TUN,或至少将 `api.118im.com` 设置为 DIRECT,并确认 DNS 不再返回 `198.18.0.0/15` Fake-IP。
### 3.2 本机资源
- 进程 RSS 峰值约 150MB。
- 压测期间累计 CPU 增量约 75 秒,对约 625 秒窗口而言不构成 CPU 饱和。
- open FDs 在连接大量断开后仍约为 1011。`WSClient.readLoop` 在读失败返回时没有立即关闭底层连接,虽然进程结束时会统一关闭,但应修复运行期 FD 滞留。
## 4. 服务端和历史核验
实时发送和 drain 阶段没有新增以下错误:
- `sub.broadcast channel full`
- `cluster: proxy channel full`
- `topic's broadcast queue is full`
- Redis fanout publish failure
历史核验约在 `17:13:15 UTC` 开始。`im-server-2` 从 `17:13:22` 到 `17:14:14` 记录 1197 次群 Topic broadcast queue full。当前 1 worker、每页 50 条、100ms 间隔仍会让单次历史回放快速灌入容量为 64 的 Topic 队列。
尽管重试后 1667 条全部找到,历史核验不应持续制造队列丢弃。建议调整为:
- `history_page_size=20`
- `history_query_interval=500ms`
- `history_workers=1`
- 历史超时保持 3 分钟;1667 条约需 42 秒完成首轮查询。
## 5. 下一步
1. 修复 `WSClient.readLoop` 退出时未关闭底层连接的问题。
2. 将历史分页调整为 20 条、查询间隔 500ms。
3. 在 A 机关闭 TUN/代理,或配置 `api.118im.com` DIRECT;起压前检查 DNS 和路由不再进入 `198.18.0.0/15`、`utun*`。
4. 测试环境尚未应用 metrics RBAC,`kubectl auth can-i list pods.metrics.k8s.io` 仍返回 `no`,因此本轮没有 Pod CPU/内存数据。需要将 `backend-debug-metrics-rbac.yaml` 加入实际 ArgoCD overlay。
5. 完成上述三项后再运行同规模 r6。若 A/B 都稳定,再根据 B 本轮 ACK P95 约 1.14s、同机投递 P95 位于 2s 桶的结果继续做服务端延迟优化。
@@ -0,0 +1,93 @@
# IM 2000 人群聊 r6 复测分析
日期:2026-09-28
Run ID:`group2k-r6-20260928-01`
## 1. 结论
r6 比 r5 有改善,但仍未通过。A 去掉 Fake-IP 后 ACK 数从 426 增加到 758,统计窗口断线从 657 降到 213;然而 `api.118im.com` 的真实公网 IP 仍经 `utun4`,A 在正式开始前就发生 28 次连接抖动,正式开始后继续成批断线。后续排查确认该 `utun4` 由 OpenVPN Connect 的全局路由持有,而不是 LightXtreme。B 也出现 2 次断线和 1 次发布结果未知,不再是完全零错误。
历史落库仍然可靠:ACK 台账和未 ACK 观测台账合计核验 1870/1870,持久化丢失为 0。FD 修复已验证生效,A 的存活连接从 1000 降至 787 时,open FDs 从约 1013 同步降至约 799,不再滞留断开的 socket。
历史限速和退出行为仍会污染服务端队列指标:历史查询阶段约产生 436 次 Topic 队列满;A 结束时一次性关闭大量群订阅连接,又集中产生约 598 次队列满。
总体结论:**落库正确性通过;FD 修复通过;实时可靠性失败;A 的 OpenVPN/TUN 链路仍未绕过;历史回放与集中断连仍需改进。**
## 2. 汇总结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| ACK | 758 | 999 | 1757 |
| ACK 超时 | 122 | 0 | 122 |
| socket/连接结果未知 | 120 | 1 | 121 |
| 实际明确拒绝 | 0 | 0 | 0 |
| 断线 | 213 | 2 | 215 |
| ACK 台账内应收 | 1757000 | 1757000 | 3514000 |
| ACK 台账内实收 | 1468873 | 1756514 | 3225387 |
| ACK 台账内实时缺失 | 288127 | 486 | 288613 |
| ACK 台账外额外投递 | 94965 | 112984 | 207949 |
| 历史核验 | 1870/1870 | 0/0 | 1870/1870 |
| 未 ACK 观测消息核验 | 113/113 | 0/0 | 113/113 |
服务端实际处理消息至少为 `1757 + 113 = 1870` 条。其余 130 条结果未知没有 ACK 或实时观测证据,不能证明服务端接受。
## 3. 报告口径修正
合并报告当前显示 91 次明确拒绝和 152 次结果未知,这是错误分类:
- A 的 91 次 `use of closed network connection` 被归类为 `other`。
- 该错误是连接关闭后的写失败,应该归类为 `socket` 结果未知。
- 修正后本轮明确拒绝为 0,结果未知为 243。
此外,群历史只由 A 执行。A 已证明全部 1757 条 ACK 消息落库,但 B 的 `realtime_lost_but_persisted` 无法读取 A 的历史结果,因此 B 的 486 次实时缺失没有计入“实时缺失但已落库”。合并报告需要共享全局历史核验结果后再计算该字段。
## 4. A 网络与 FD
本轮 DNS 返回真实地址:
```text
64.118.158.131
216.236.31.189
216.236.31.191
```
但到真实地址的系统路由仍为:
```text
gateway: 172.27.240.1
interface: utun4
```
这说明 Fake-IP 已关闭,但流量仍进入 VPN/TUN。A 在 barrier 提交前持续掉线重连,正式开始后约 50 秒开始掉线,最终只剩约 787 个连接。后续确认 OpenVPN Connect 下发了 `0/1` 和 `128.0/1` 两条全局路由;LightXtreme 的 DIRECT 规则无法绕过另一套 OpenVPN TUN。r7 前已为 `api.118im.com` 当前三个 IP 添加经 `192.168.1.1/en1` 的 `/32` 主机路由,保留 OpenVPN 内网访问的同时让压测 HTTP/WS 直连。
FD 时间序列证明 read loop 修复有效:
- READY 1000 时 open FDs 约 1013。
- READY 901 时 open FDs 约 913。
- READY 787 时 open FDs 约 799。
连接每减少一个,FD 基本同步减少一个。
## 5. Topic 队列满
实时发送和 drain 阶段仍没有 Topic/Proxy 满队列。`im-server-2` 的 1034 次 `topic's broadcast queue is full` 分为:
- `00:35:59–00:36:11 UTC`:约 436 次,发生在历史回查阶段。
- `00:37:19 UTC`:约 598 次,发生在 A agent 完成后批量关闭连接阶段。
20 条/500ms 虽有改善,但 Topic 历史数据仍以突发方式回送。建议:
1. 历史每页进一步降至 10 条,查询间隔保持 500ms。
2. 每页请求后等待本页目标消息全部收到或超时,再发送下一页,而不是只按固定时间节流。
3. agent 退出时按 10–20ms 间隔分批关闭连接,避免 1000 个群订阅同时离线。
## 6. 后续修复
1. 已将 `use of closed network connection` 分类为 socket 结果未知。
2. 已在 coordinator 共享两机历史命中 ID,使 B 也能正确计算实时缺失但已落库。
3. 历史回查已改为每页 10 条:同一 Topic 每页完成确认后再请求下一页,不同 Topic 最多 12 路并行。
4. agent 已改为在 10 秒窗口内渐进关闭 WebSocket。
5. r7 开始前重新解析 `api.118im.com`,确认每个 IP 都通过 `/32` 主机路由走 `en1`;OpenVPN 重连、DNS 变更或机器重启后必须重新核验。
@@ -0,0 +1,73 @@
# GROUP-2000 R9 双机群聊压测分析与改进
## 1. 结论
`group2k-r9-20261003-01` 不能作为 2000 人群聊容量结论。消息最终落库完整,但实时阶段同时发生机器 B 迟到、Pod 重启和集群 DNS 拒绝查询,导致大量连接断开和跨机器投递缺失。
- 发送尝试 2000 条,数据库精确找到 1774 条服务端已确认消息,缺失、重复、字段不一致均为 0。
- 实时期望投递 3,548,000 次,实际收到 1,384,872 次,到达率约 39.03%。
- 机器 B 比 coordinator 的计划起跑时间晚约 7 分 55 秒;B 未收到任何 A 消息,本轮并非有效的同步双机负载。
- `im-server-1` 在负载中退出,容器退出码 137;现有权限无法读取 Event 和 Pod metrics,尚不能把原因唯一归为 OOM、liveness 或节点压力。
- 三个 Pod 合计约 1715 次依赖域名解析失败,日志包含对集群 DNS `10.43.0.10:53` 的 `connection refused`。
- JPush 设备查询产生 792 次数据库错误;用户更新队列满 1132 次,属于依赖抖动和连接重建后的次生压力。
Kafka 持久化链路本轮表现正确:各 Pod 批量持久化计数合计 1774,与数据库精确核验一致,之前的失败消息保留修复已生效。
## 2. 根因链路
### 2.1 JPush 查询按群消息放大
JPush 使用 registration ID 模式时,每条群消息都会按全部收件人重新查询 `im_device_push_tokens`。2000 人群和 2000 条消息会形成约 400 万个用户查询输入;测试账号没有推送设备,旧实现仍重复查询空结果。JPush 回执又以无限 goroutine 并发处理,使查询、对象分配和内存压力同时放大。
### 2.2 MySQL 连接每 60 秒回收
旧配置的 `conn_max_lifetime=60` 会在持续压测期间频繁重建连接。每次建连都重新解析 `mysql.118im.com`;当集群 DNS 短时拒绝请求时,业务查询、JPush 查询和 Kafka 持久化会一起失败或等待。
### 2.3 双机只有计划时间,没有最终到达屏障
旧协议在双方 READY 后下发统一时间,但 agent 到达该时间后直接发送。机器 B 晚启动时,机器 A 无法知道对方没有真正进入发送阶段,因此形成两段错开的单机流量并生成无效的跨机器统计。
## 3. 已完成改进
### 3.1 JPush
- 设备查询结果按租户、provider、用户缓存 30 秒,包含“无设备”结果。
- 并发首次查询被合并,同一批群成员只访问一次数据库。
- 查询失败后退避 1 秒,已有缓存可在 5 分钟陈旧窗口内继续使用。
- 缓存定期清理,避免用户集合长期增长。
- JPush 回执限制为 8 个工作协程,取消无限 goroutine,队列负责形成背压。
该改动不删除在线多端静默推送语义。新注册或更新的设备最迟约 30 秒进入推送查询结果。
### 3.2 MySQL 与 DNS
- 容器默认 `conn_max_lifetime` 从 60 秒提高到 1800 秒,并支持 `MYSQL_CONN_MAX_LIFETIME_SECONDS` 覆盖。
- 测试环境 GitOps 显式设置 1800 秒,降低持续负载中的连接重建和 DNS 查询频率。
- 保留现有 `ndots:1`、`timeout:2`、`attempts:2`。
这会显著减少应用制造的 DNS 压力,但不能替代集群 DNS 修复。若复测仍出现 `10.43.0.10:53 connection refused`,需要集群管理员检查 CoreDNS 副本、CPU、转发上游、conntrack,并评估与当前 K3s 网络模式匹配的 NodeLocal DNSCache;不能在未知网络模式下直接套用 DaemonSet。
### 3.3 压测同步
- coordinator 增加最终起跑屏障。
- A/B 必须在计划时间前后 3 秒内到达,双方都到达后才开始发送。
- 任一机器迟到时,两边收到同一失败原因并终止本轮。
- coordinator 输出保存双方实际到达时间和 `start_error`。
### 3.4 诊断权限
`backend-debug` 只读权限增加 Event 和 `metrics.k8s.io/pods`,用于下一轮区分 OOM、探针失败、驱逐和节点资源压力。若集群未安装 metrics-server,Pod metrics 仍不可用,但 Event 读取不受影响。
## 4. 复测门禁
下一轮使用新 `run_id`,A/B 必须更新到包含起跑屏障的同一提交,并在应用和 GitOps 配置都发布后执行。
1. coordinator 输出中 A/B `started` 时间相差不超过 3 秒,`start_error` 为空。
2. 2000 条发送全部得到明确 ACK;数据库按 `client_mid` 精确匹配,缺失、重复、字段不一致均为 0。
3. 实时期望 4,000,000 次投递全部到达,重复、乱序和断连均为 0。
4. JPush 设备查询不再随群消息数线性增长,日志中 `jpush: db error` 为 0。
5. Pod 不重启,Event 中无 OOM、Unhealthy、Evicted;负载窗口内持续采集 CPU、内存和重启次数。
6. `10.43.0.10:53`、MySQL、Redis、Kafka 域名解析错误均为 0。
7. Kafka lag 最终归零,数据库最终消息数与 ACK 台账一致。
若业务正确性通过但 DNS 仍有拒绝请求,本轮仍判基础设施门禁失败,不继续用增加应用重试掩盖 DNS 容量问题。
@@ -0,0 +1,191 @@
# IM 单机 60% 容量压力测试方案
日期:2026-09-29
本方案首轮执行结果见 [IM-单机60%容量压力测试结果-20260929.md](./IM-单机60%容量压力测试结果-20260929.md)。
## 1. 目标与口径
本方案用一台物理压测机完成单聊、群聊和混合场景,容量按双机正式方案全局规模的 60% 计算。
单机不等于只运行一个 agent。为保留现有好友对和投递契约,同一台物理机上同时运行逻辑 agent A 和 agent B,每个 agent 承载 600 个连接,合计 1200 个 WebSocket。Coordinator 仍使用 `expected-agents=2`。
| 项目 | 双机正式方案 | 单机 60% 方案 |
|---|---:|---:|
| 物理压测机 | 2 | 1 |
| 逻辑 agent | 2 | 2 |
| 每个 agent 连接 | 1000 | 600 |
| WebSocket 总连接 | 2000 | 1200 |
| 好友对 | 1000 | 600 |
| 群成员 | 2000 | 1200 |
## 2. 场景容量
### 2.1 单聊
- 600 对好友,A/B 各 600 人。
- 每个用户发送 10 条,每秒 1 条。
- 总发送尝试 12000 条,是双机 20000 条的 60%。
- 每条 ACK 消息应在配对用户的 WebSocket 到达一次。
### 2.2 群聊
- 1200 个在线群成员。
- A/B 各取前 300 个用户作为发送者,合计 600 个发送者。
- 每人发送 2 条,全局每 100ms 一条,合计 1200 条群消息,耗时约 120 秒。
- 全部发送被 ACK 时,期望实时投递 `1200 x 1200 = 1,440,000` 次。
### 2.3 单聊和群聊混合
- 前 180 对好友互发,每人 5 条,共 1800 条单聊。
- A/B 各 150 个群发送者,每人 2 条,共 600 条群消息。
- 群消息期望实时投递 `600 x 1200 = 720,000` 次。
- 单聊和群聊同时开始,不分两轮单独执行。
工具按 `users_per_machine` 等比计算发送者数量。`users_per_machine=1000` 时保持原双机参数,设为 600 时自动使用上述单机参数。
## 3. 文件隔离
单机方案不复用双机的运行文件,避免把 1200 人结果和 2000 人结果混在一起。
```text
loadtest/im-pressure/config.single-machine.json
loadtest/im-pressure/manifest.single-machine.json
loadtest/im-pressure/results/<RUN_ID>/
```
从模板生成本地配置:
```bash
cp loadtest/im-pressure/config.single-machine.example.json \
loadtest/im-pressure/config.single-machine.json
chmod 600 loadtest/im-pressure/config.single-machine.json
```
修改租户、HTTP/WS 地址和注册手机前缀。A/B endpoint 可以填同一个公网入口;若需确认跨 Pod 投递,应分别填写两个定向入口。
## 4. 一次性数据准备
```bash
source loadtest/im-pressure/.env.test
go build -o loadtest/im-pressure/im-pressure ./loadtest/im-pressure
ulimit -n 100000
./loadtest/im-pressure/im-pressure prepare \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json
```
`verify` 必须输出 1200 个账号、600 对好友和 1200 个群成员。单机 manifest 可以复用已注册的 `lta0001..0600` 和 `ltb0001..0600` 账号,但必须使用独立群 Topic。
## 5. 正式运行
以群聊为例:
```bash
export RUN_ID='single60-group-r1-YYYYMMDD-01'
export SCENARIO='group'
mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
```
终端 1,启动 coordinator:
```bash
./loadtest/im-pressure/im-pressure coordinator \
-listen :8090 \
-run-id "${RUN_ID}" \
-scenario "${SCENARIO}" \
-expected-agents 2 \
-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json"
```
终端 2,启动逻辑 agent A:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json \
-machine a \
-coordinator http://127.0.0.1:8090 \
-metrics-listen :9105 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json"
```
终端 3,启动逻辑 agent B:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json \
-machine b \
-coordinator http://127.0.0.1:8090 \
-metrics-listen :9106 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
两个 agent 都报告 `600/600 READY` 并稳定通过 30 秒屏障后才会开始。任一 agent 未满连接时 coordinator 会撤销开始时间。
固定 drain 结束后,agent 还会等待入站投递连续静默后再生成实时 summary。默认 `delivery_quiet_period=10s`、`delivery_quiet_timeout=3m`;接收计数增长或回调队列不为空都会重置静默窗口,避免公网晚到帧被误判为实时丢失。
## 6. 指标采集
终端 4 在起压前至少 2 分钟启动:
```bash
./loadtest/im-pressure/im-pressure collect \
-duration 20m -interval 5s \
-coordinator http://127.0.0.1:8090 \
-coordinator-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-url agent-a=http://127.0.0.1:9105/metrics \
-url agent-b=http://127.0.0.1:9106/metrics \
-url node=http://127.0.0.1:9100/metrics \
-output "loadtest/im-pressure/results/${RUN_ID}/metrics.jsonl"
```
同时采集 Redis、Kafka、MySQL、Pod CPU/内存/网络、Kafka consumer lag、MySQL 连接池等指标。如本机没有 `node-exporter :9100`,删除 `node` URL,但不影响 agent 指标采集。
发版后必须在服务端 `/metrics` 确认 `im_ws_heartbeat_seconds{setting="ping_period"}=15`、`im_ws_heartbeat_seconds{setting="pong_wait"}=90`,并采集 `im_ws_heartbeat_total{event="pong_received"}` 和 `im_ws_disconnect_total{reason="timeout"}` 的分 Pod 增量。
混合场景还必须采集 `im_ctrl_ack_delivery_duration_seconds{stage="enqueue"}` 和 `im_ctrl_ack_delivery_duration_seconds{stage="write"}`。agent 端同时采集 `im_load_agent_publish_ctrl_observed_total`、`im_load_agent_publish_ctrl_over_20s_total`、`im_load_agent_publish_ctrl_late_total` 和 `im_load_agent_publish_ctrl_unresolved`,用于区分及时 ACK、超过 20 秒的 ACK、超过命令超时后晚到的 ACK 与最终未到 ACK。
## 7. 报告和独立落库核验
```bash
./loadtest/im-pressure/im-pressure merge \
-a "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json" \
-b "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json" \
-output "loadtest/im-pressure/results/${RUN_ID}/report.json"
export MYSQL_DSN='readonly:password@tcp(mysql-host:3306)/meeting_im?parseTime=true'
export TENANT='006' # 必须与 config.single-machine.json 中的 tenant 一致
./loadtest/im-pressure/im-pressure verify-db \
-coordinator "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-tenant "${TENANT}" \
-dsn-env MYSQL_DSN \
-registry-db hotgo \
-output "loadtest/im-pressure/results/${RUN_ID}/db-verification.json"
```
## 8. 验收门禁
- READY 必须是 `1200/1200`,统计窗口内断连为 0。
- publish 明确拒绝和结果未知均为 0,成功率至少 99.99%。
- `publish_ctrl_unresolved` 必须为 0;`publish_ctrl_over_20s` 和 `publish_ctrl_late` 作为定位指标单独报告,不与消息丢失混为一项。
- ACK P95 和投递 P95 保留记录用于趋势对比,不作为当前公网单机容量测试的阻断门禁。
- 实时缺失、重复、乱序、未知本轮消息均为 0。
- ACK 台账和无 ACK 但已观测台账的历史回查命中率必须 100%,独立 MySQL 核验丢失、错配、重复均为 0。
- 实时发送、历史回查和渐进关闭三个阶段分别统计 Topic/Proxy/Session 队列满,不能只看整轮总数。
## 9. 单机特有风险
1. 两个 agent 共用一台机器的 CPU、内存、FD、NAT 和出口带宽,测到的是“单机压测端 + 服务端”综合上限,不能代替双机最终验收。
2. 正式前做 120/600/1200 连接三档阶梯,观察 agent CPU、`go_goroutines`、FD、接收速率和 Ping/Pong 断连,确认压测端本身没有先饱和。当前工具已将投递观测按 `client_mid` 分片、接收账号顺序状态独立加锁,使用原子延迟直方图,并将数据统计回调从 WebSocket socket 读循环解耦;仍需用指标证明本机有余量。
3. 两个 agent 必须使用不同指标端口,不能同时绑定 `:9105`。
4. 压测域名如被 VPN/TUN 接管,起压前必须确认所有当前 IP 的路由都指向物理网卡。
5. 单机 60% 通过后只能进入双机 100% 验收,不能直接得出 2000 连接容量通过的结论。
断连时同时查看 agent summary 中的 `disconnect_reasons` 和 `disconnect_samples`。前者按 timeout、abnormal close、connection reset 等原因分类计数,后者最多保留 100 条带账号与时间的原始错误样本,用于区分压测端读循环阻塞、网络中断和服务端主动关闭。
@@ -0,0 +1,949 @@
# IM 单机 60% 容量压力测试结果
日期:2026-09-29
## 1. 结论
本轮使用一台物理压测机同时运行逻辑 agent A/B,每侧 600 个 WebSocket,合计 1200 连接。60% 容量的正确性和性能总验收未通过。
- 单聊实时和持久化正确性通过;ACK P95 和投递 P95 仅记录,不再作为当前公网单机压测的阻断条件。
- 群聊在 1200 条群消息、144 万设计投递量下出现 ACK 超时、连接重置和分钟级投递延迟。
- 混合场景明显越过容量拐点,737 个连接断开,仅 44.83% 发送得到 ACK。
- 独立 MySQL 核验确认:三个场景的所有 ACK 消息均精确落库,0 错配、0 重复;所有“无 ACK 但 WS 已观测”消息也均已落库。
- 压测工具本轮峰值 RSS 不超过 127 MB,每个 agent 峰值 FD 614,未显示本机内存或 FD 先饱和。
- 混合实时阶段的群 Topic 所在 `im-server-0` 出现 broadcast queue full,服务端 Topic 处理能力是已证实的瓶颈之一。
## 2. 环境与口径
- 租户:`allrich`
- HTTP/WS 入口:`api.118im.com`
- 物理压测机:1 台
- 逻辑 agent:A/B 各2个中的一个,每侧 600 连接
- 账号:1200,好友对:600,群成员:1200
- API 域名当前 3 个 IP 均经 `en1` 和局域网网关直连,未走 VPN/TUN
- IM Server:3 Pod,无重启;每 Pod request `1 CPU/512Mi`,limit `2 CPU/1Gi`
- 独立群与 manifest:`loadtest/im-pressure/manifest.single-machine.json`
准备阶段最终验证为 `accounts=1200 pairs=600 group_members=1200`。群批量邀请曾出现入口 502 和幂等任务长时间处理中,工具通过按实际成员补齐缺失用户完成恢复。
## 3. 结果汇总
| 场景 | Run ID | ACK | 实时投递 | 断连 | ACK P95 | 投递 P95 | 独立 DB 核验 | 结果 |
|---|---|---:|---:|---:|---:|---:|---:|---|
| 单聊 | `s60-p2p-r2-0929` | 12000/12000 | 12000/12000 | 0 | 1341.555ms | 2000ms | 12000 ACK 全部精确命中 | 正确性通过 |
| 群聊 | `s60-group-r1-0929` | 1145/1200 | 1350508/1374000 ACK 台账 | 47 | 11207.859ms | 94621.882ms | 1145 ACK + 39 无 ACK 落库 | 未通过 |
| 混合 | `s60-mixed-r1-0929` | 1076/2400 | 234829/263657 ACK 台账 | 737 | 17491.634ms | 101226.392ms | 1076 ACK + 1017 无 ACK 落库 | 未通过 |
表中群聊和混合的“期望投递”只按有 ACK 的 ledger 计算。发送结果未知的消息不能在没有 seq 时直接加入期望投递分母,因此该分母小于设计投递量。
## 4. 分场景记录
### 4.1 单聊
R1 的历史查询将 `limit=10` 错误地按目标消息数计算,没有计入 P2P Topic 中双方交错的 seq,造成 4619 条假缺失。修复后 R2 结果:
- 12000 条全部 ACK,无结果未知。
- 12000 次对端投递全部到达,无重复、乱序、断连。
- 12000 条历史回查全部命中,持久化缺失 0。
- ACK P95 1341.555ms,同机投递 P95 落在 2000ms 桶;两项作为公网链路观测数据,不影响正确性验收。
### 4.2 群聊
- 发送 1200 条,1145 条 ACK,43 条 ACK 超时,12 条 socket 结果未知,无明确拒绝。
- 47 个连接断开:22 个 abnormal close,25 个 connection reset by peer。
- ACK 台账下实时缺失 23492 次,这些消息最终均可从历史查到。
- 55 条结果未知中,39 条被接收端实时观测,且全部被独立 SQL 确认落库;另 16 条既无 ACK、无 WS 观测,也无数据库记录。
- 断连发生时间为 `03:40:10Z` 至 `03:41:15Z`,与群实时发送阶段重合。
### 4.3 混合
- 设计发送为 1800 条单聊和 600 条群聊,合计 2400 条。
- 1076 条 ACK,1324 条结果未知,其中 1036 条 ACK 超时、288 条 socket 失败,无明确拒绝。
- 737 个连接断开:69 个 abnormal close,668 个 connection reset by peer。
- ACK 台账下实时缺失 28828 次,其中 28633 次对应消息最终已查到持久化记录。
- 历史验证 ledger 含 2002 条,命中 1331 条,缺失 671 条全部为 P2P。独立 SQL 确认所有 1076 条 ACK 消息和全部 926 条 WS 已观测的无 ACK 消息均已落库,因此这 671 条是 verifier 连接掉线和 3 分钟总预算造成的验证不完整,不是数据库丢失。
- 1324 条结果未知中,1017 条实际落库:926 条被 WS 观测,另有 91 条未被 WS 观测但已落库。剩余 307 条无 ACK、无 WS 观测、无数据库记录。
### 4.4 独立 MySQL 核验
核验租户 ID 为 8,消息表为 `im_008_messages` 和 `im_008_group_messages_202609`。
| 场景 | ACK expected | 精确命中 | ACK 缺失 | 错配 | 重复 | 无 ACK 但落库 |
|---|---:|---:|---:|---:|---:|---:|
| 单聊 | 12000 | 12000 | 0 | 0 | 0 | 0 |
| 群聊 | 1145 | 1145 | 0 | 0 | 0 | 39 |
| 混合 | 1076 | 1076 | 0 | 0 | 0 | 1017 |
`verify-db` 对无 ACK 但落库的行按 unexpected 报告,所以群聊和混合命令会以非零状态结束;这不代表数据错误,而是发送结果未知但服务端已持久化的重要诊断证据。
## 5. 服务端观测
- `im-server-0/1/2` 全部 Running,本轮重启数均为 0。
- 混合实时阶段 `03:53:00Z..03:53:02Z`,`im-server-0` 记录 55 次 `topic's broadcast queue is full`。
- 混合渐进关闭末尾 `04:03:45Z`,`im-server-0` 再记录 375 次同类队列满。当前 60 秒 close ramp 仍未完全避免关闭风暴。
- 同一观测窗口的 `03:45:13Z`,`im-server-2` 在 `serveIMFriendRequestAction` (`server/im_http.go:6256`) 出现一次 nil pointer panic。该端点不属于群实时发送路径,不将其归因为本轮断连,但需单独修复。
- 当前 kubeconfig 无权读取 metrics-server,未能取得负载窗口的 Pod CPU/内存历史值。
## 6. 压测工具修复
1. 新增单机 60% 配置和执行方案,A/B 各 600 连接。
2. 群聊和混合发送者按 `users_per_machine` 比例缩放,1000 用户的原参数保持不变。
3. 投递观测按 `client_mid` 分为 64 片,每个接收账号独立维护 seq,延迟直方图改为原子计数,移除 WS 读循环中的全局锁争用。
4. summary/report 新增 `disconnect_reasons` 和最多 100 条 `disconnect_samples`。
5. 群创建后立即 checkpoint manifest;批量邀请失败后重读实际成员,仅邀请缺失用户。
6. P2P 历史查询 limit 按 seq 区间扩展,上限 200,修复双方消息交错造成的假缺失。
## 7. 已实施改进
1. 好友申请接受和自动接受路径增加 store 空结果保护。底层异常返回 `nil, nil` 时记录租户、申请、操作人和状态并返回 HTTP 500,不再触发进程 panic。
2. Hub 向已满的 Topic client queue 投递失败时,立即向原发送连接返回 `503 service unavailable`,并记录 topic、tenant、队列长度和容量。客户端可据此重试,压测也不再把这类请求归为等待超时的未知结果。
3. 群消息权限检查改为复用群设置缓存,并为个人禁言增加 Redis 正向和负向缓存;新增禁言和解除禁言会同步更新缓存,到期禁言会自动淘汰。此改动减少群 Topic 串行处理路径上的 MySQL 查询。
4. `message_publish_stage_duration_seconds` 新增 `group/permission_check` 和 `*/realtime_fanout` 阶段,下轮可区分权限查询、持久化和实时扩散耗时。
5. A/B 两个逻辑 agent 按账号顺序交错关闭连接。`close_ramp=60s` 时每个 agent 的本地关闭过程约 120 秒,A/B 相差半个时隙,整体约每 100ms 关闭一个连接,避免两侧同时下线形成双倍尖峰。
6. 已增加空结果防 panic、Topic queue 满返回 503、双 agent 关闭时隙的自动化测试。
## 8. 复测顺序
1. 发版后先跑单机群聊 60%,验收实时发送阶段和关闭阶段 `topic_client` enqueue failed 均为 0;若仍过载,发送端必须收到 503,不能再出现对应的纯 ACK timeout。
2. 对比 `permission_check`、`save`、`realtime_fanout` 三阶段的 P50/P95/P99,确认群权限缓存是否消除了数据库检查瓶颈。
3. 群聊通过后再跑单机混合 60%,要求 ACK、历史和独立 SQL 三方账本一致,连接断开数为 0。
4. 补齐 Prometheus/metrics-server 权限并同步保存 Pod CPU、内存、网络、Kafka lag、Redis、MySQL 指标;在此之前不进行双机 100% 最终验收。
## 9. 发版后群聊 R2
Run ID:`s60-group-r2-0929`。
本轮开始前 3 个 Pod 均为 Running、0 重启,账号和群成员核验为 `accounts=1200 pairs=600 group_members=1200`。测试完整运行到 A/B 历史验证完成,并执行了错峰关闭和独立 MySQL 核验。
| 指标 | 结果 |
|---|---:|
| 发送尝试 / ACK | 1200 / 871 |
| 结果未知 | 329(222 timeout、107 socket) |
| 断连 | 360(315 reset、44 abnormal close、1 timeout) |
| ACK P95 | 16630.441ms |
| 投递 P95 | 101823.156ms |
| ACK 台账期望 / 实时收到 | 1045200 / 889777 |
| 实时缺失 | 155423,最终全部查到持久化 |
| 历史回查 | 1080 / 1080 |
| DB:ACK 精确命中 | 871 / 871 |
| DB:无 ACK 但落库 | 209 |
| DB:缺失 / 错配 / 重复 | 0 / 0 / 0 |
329 条结果未知中,209 条已被 WS 观测且独立 SQL 确认落库,剩余 120 条数据库中不存在。断连样本主要是公网对端 `connection reset by peer`,且样本连接均指向 `64.118.158.131:443`;本机到域名全部当前 IP 均使用 `en1` 静态直连,没有经过 VPN/TUN。
服务端指标显示群 Topic 所在 `im-server-0` 的 1080 条 `save` 全部低于 100ms,Kafka consumer lag 为 0;但 `topic_server` 队列累计出现 393 次 full。压测客户端 CPU、RSS 不构成瓶颈。
本轮不能作为提交 `75bb448` 的有效回归:Pod 中运行的二进制不包含该提交新增的 `permission_check`、`realtime_fanout` 阶段字符串和好友申请空结果保护日志,Prometheus 也没有相应时序,说明实际部署镜像不是该提交构建结果。必须正确部署 `75bb448` 或后续提交后重跑。
此外,本轮发现 agent 只在正式开始前维护连接,统计窗口内断线后不会恢复,导致历史核验依赖剩余连接。后续已改为持续维护到 A/B 历史验证均完成,并将重连并发限制为每 agent 16,避免恢复风暴;每次计划发送前重新取得当前连接。
## 10. 产物
- 方案:`docs/test/IM-单机60%容量压力测试方案.md`
- 单聊 R2:`loadtest/im-pressure/results/s60-p2p-r2-0929/`
- 群聊 R1:`loadtest/im-pressure/results/s60-group-r1-0929/`
- 混合 R1:`loadtest/im-pressure/results/s60-mixed-r1-0929/`
- 群聊 R2:`loadtest/im-pressure/results/s60-group-r2-0929/`
结果目录包含 coordinator、A/B summary、合并 report 和 agent 指标 JSONL。
## 12. 精确成员规模诊断复测
为区分“在线连接数”和“群成员总数”的影响,新增 300、600 人独立群复测。每个测试群在起压前均按用户 ID 集合核验,要求实际成员与目标账号完全相等。
| 场景 | Run ID | 在线/群成员 | ACK | 实时投递 | 历史 | DB 精确命中 | ACK P95 | 投递 P95 |
|---|---|---:|---:|---:|---:|---:|---:|---:|
| 原 1200 人群、仅 300 人在线 | `s300-group-r1b-0929` | 300/1200 | 45/300 | 13500/13500(按 ACK 台账) | 206/206 | 45 ACK + 210 结果未知全部落库 | 18994ms | 89489ms |
| 精确 300 人群 | `s300m-group-r1-0930` | 300/300 | 300/300 | 90000/90000 | 300/300 | 300/300 | 235.177ms | 500ms 桶内 |
| 精确 600 人群 | `s600m-group-r1-0930` | 600/600 | 600/600 | 360000/360000 | 600/600 | 600/600 | 235.177ms | 500ms 桶内 |
| 精确 900 人群 | `s900m-group-r1-0930` | 900/900 | 890/900 | 800555/801000(按 ACK 台账) | 899/899 | 890 ACK + 9 结果未知落库 | >10s,最大19.272s | >10s,最大55.279s |
三轮均未出现 WS 发送队列满、WS 写失败或 Kafka lag。精确 300、600 人群在实时阶段也没有新增 `session_topic_publish`、`topic_client`、`topic_server` 满队列,说明 WS 写链路在 600 在线规模内不是瓶颈。
原 1200 人群仅 300 人在线时有约 900 个离线成员。群消息处理会在 Topic 串行路径中为每个离线成员逐个调用 Redis presence 查询;上一条消息 ACK 后的推送准备持续占用 Topic goroutine,使后续消息长时间等待,最终表现为 ACK 超时和分钟级投递延迟。精确成员群消除这部分离线成员后,同等 300 在线规模恢复正常,验证了该路径是主要根因。
600 人实时发送通过后,240 秒错峰下线仍使群主 Pod 的 `topic_server full` 从 36 增至 128,净增 92;其他两类内部队列无增量。该问题来自最后会话离线时的 presence 扩散,与消息保存、Kafka 消费和 WS 实时投递无关,仍需在服务端合并或限流离线状态广播。
精确 900 人群出现容量拐点:900 次发送中 890 次 ACK,9 次 ACK 超时、1 次 socket 失败;7 条连接在实时阶段超时断开并自动恢复。按 ACK 台账计算的 445 次实时缺口全部可从历史补回,890 条 ACK 全部精确落库;9 条结果未知实际落库,1 条 socket 失败未落库。因此本轮没有持久化丢失,但实时正确性和延迟门禁未通过。
900 人实时阶段三个内部队列的 full 计数均无增量,Kafka lag 为 0。成功处理的 899 条消息中,`permission_check` P95 不超过 10ms、`save` P95 不超过 50ms、`kafka_produce` P95 不超过 25ms、`realtime_fanout` P95 不超过 5ms;抽样覆盖后半段 575468 次 WS 投递,发送队列等待 P95 不超过 10ms、socket write P95 不超过 0.5ms,且没有发送队列满或 text write 失败。
现有 `realtime_fanout` 指标在 `pushForData` 之前结束。`pushForData` 位于 Topic 串行处理路径中,每条群消息遍历全部成员、构造推送收件人并调用 `sendPush`;即使成员全部在线,900 人规模下这段未计时工作仍可让后续发布在 `topic_client` 中排队。队列未达到容量上限并不代表没有排队,这与 ACK 延迟逐步增长到 19 秒、投递延迟增长到 55 秒的现象一致。下一步应先补充 `push_prepare`、`push_dispatch` 和 Topic client queue wait 指标,再将群推送准备移出 Topic 串行热路径或改为异步批处理。
900 人在 240 秒错峰下线后,群主 Pod 的 `topic_server full` 从 0 增至 379,说明 presence 离线扩散问题随群规模明显放大。压测机两个 agent 的峰值 CPU 分别约 39.5% 和 38.5% 单核,峰值 RSS 各约 76MB、FD 各 460,不是本轮瓶颈。
新增产物:
- 300 人精确群:`loadtest/im-pressure/results/s300-group-members-setup-0929/`
- 300 人复测:`loadtest/im-pressure/results/s300m-group-r1-0930/`
- 600 人精确群:`loadtest/im-pressure/results/s600-group-members-setup-0930/`
- 600 人复测:`loadtest/im-pressure/results/s600m-group-r1-0930/`
- 900 人精确群:`loadtest/im-pressure/results/s900-group-members-setup-0930/`
- 900 人复测:`loadtest/im-pressure/results/s900m-group-r1-0930/`
## 13. 容量拐点修复(待发版复测)
针对精确 900 人群暴露的 Topic 串行积压和批量下线 presence 风暴,已完成以下代码改造:
1. `pushForData` 在 Topic goroutine 内只复制成员推送快照,不再逐成员同步查询 Redis,也不再执行跨 Pod 收件人拆分和 user updater 投递。
2. 新增总容量 4096 的有界异步推送队列和 4 个固定分片 worker。同一 `tenant + topic` 固定进入同一分片,保证同一群的推送和未读更新顺序;不同群可并行处理。
3. 离线成员 presence 使用一次 Redis pipeline 批量读取,单条消息的 Redis 网络往返由成员数 N 次降为 1 次。
4. 群离线 presence 遇到 Topic server queue 满时不再直接丢弃。服务端按 `tenant + topic + source user` 合并为最新状态,并在 Topic 队列恢复后重投;待处理集合上限 16384。
5. 新增 Topic client queue wait、push prepare、push dispatch queue、push dispatch duration、push recipient count、deferred presence 当前深度等指标。
新增重点指标:
- `im_topic_queue_wait_duration_seconds`
- `im_message_publish_stage_duration_seconds{stage="push_prepare"}`
- `im_push_dispatch_queue_enqueue_total`
- `im_push_dispatch_queue_depth`
- `im_push_dispatch_queue_wait_duration_seconds`
- `im_push_dispatch_duration_seconds`
- `im_push_recipient_count`
- `im_internal_queue_current{queue="topic_presence_pending"}`
- `im_internal_queue_enqueue_total{queue="topic_presence_deferred"}`
发版后先复测精确 600 人群,确认基线无回归;再复测精确 900 人群。900 人验收要求:900/900 ACK、810000/810000 实时投递、0 断连、0 持久化缺失,`topic_client/topic_server/push_dispatch` 无 full,Topic queue wait P95 不超过 500ms,ACK P95 不超过 500ms,投递 P95 不超过 1s。渐进下线结束后 deferred presence 必须回落到 0,且不得出现 `topic_server full`。
## 14. 异步推送发版后精确 600 人回归
Run ID:`s600m-pushfix-r1-0930`。测试使用精确 600 人群 `grpJZjWbOek928`,A/B 各建立 300 条连接;两端均完整运行至 5 分钟落库沉淀、历史回查和 240 秒交错关闭结束,没有提前终止连接。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 600 / 600 |
| 发送尝试 / 收到 ACK | 600 / 523 |
| 发送结果未知 | 77(71 timeout、6 socket) |
| 服务端实际接收并落库 | 593 / 600 |
| ACK P95 | 17522.536ms |
| ACK 台账实时投递 | 308745 / 313800 |
| 实时到达率 | 98.3891% |
| 实时缺口 | 5055,全部可从历史补回 |
| 历史回查 | 593 / 593 |
| 无 ACK 但历史可查 | 70 / 70 |
| MySQL 精确命中 | 593 / 593 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 断连 | 34(21 reset、13 abnormal close) |
| 重启 | 0 |
异步推送改造本身没有形成服务端队列瓶颈。群主 Pod 处理的 600 次发布 Topic queue wait 全部不超过 50ms;593 次异步推送均成功入队,`push_dispatch` 没有 full,队列等待 P95 不超过 0.5ms,dispatch P95 不超过 10ms、最大不超过 25ms,deferred presence 最终为 0。Kafka lag 仍为 0。
本轮 77 条 ACK 未知中,70 条已被对端 WS 观察并精确落库,剩余 7 条 socket 失败未进入服务端。由此可确认本轮失败发生在客户端连接和 ACK 返回链路,不是消息持久化、Kafka 消费、Topic 排队或异步推送丢弃。
测试期间 3 个 IM Pod 持续收到培养服务文件确认请求,并在 `store.Files.LinkAttachments` 中对 `cvp...` 业务保留键调用 `types.GetTopicCat`,约每 5 秒产生一轮 HTTP panic 和完整堆栈。该接口允许业务保留键,不能按 Tinode Topic 强制分类;持续 panic 日志和并发异常请求污染了本轮环境,因此本轮不能作为异步推送改造的有效 600 人基线。
已完成两项修复:
1. 附件关联仅对 `usr...` 用户引用执行用户 Topic 转换,`cvp...` 等业务保留键直接按普通关联键落库,消除确定性 panic。
2. `verify-db` 将双机共同观察到的无 ACK 消息与 ACK ledger 合并去重。本轮修正后的独立 SQL 结果为 `expected=593, found_exact=593`,不再把 70 条已落库的 ACK 超时消息误报为 unexpected。
下一轮必须在上述修复发版后重新执行相同的精确 600 人场景。验收前先确认 3 个 Pod 不再出现 `invalid topic type for name 'cvp...'`;随后要求 600/600 ACK、360000/360000 实时投递、MySQL 600/600 精确命中、0 断连,并保持 Topic、push dispatch、Kafka 和数据库指标无积压。
新增产物:
- `loadtest/im-pressure/results/s600m-pushfix-r1-0930/report.json`
- `loadtest/im-pressure/results/s600m-pushfix-r1-0930/db-verification.json`
- `loadtest/im-pressure/results/s600m-pushfix-r1-0930/server-metrics/`
## 15. Panic 修复后精确 600 人连续复测
部署提交 `06e08a6` 后,3 个 Pod 使用同一镜像摘要,均为 Ready、零重启。连续观测和三轮压测期间不再出现 `invalid topic type for name 'cvp...'` 或对应 HTTP panic,确认培养文件关联修复生效。
为区分代码回归与公网瞬态,使用同一配置和精确 600 人群连续执行 R2、R3、R4。每轮 A/B 各 300 连接,保持到 5 分钟沉淀、历史验证和 240 秒交错关闭全部完成。
| 指标 | R2 | R3 | R4 |
|---|---:|---:|---:|
| ACK | 600 / 600 | 600 / 600 | 600 / 600 |
| 实时投递 | 359400 / 360000 | 360000 / 360000 | 360000 / 360000 |
| 断连 | 1 次 timeout | 0 | 0 |
| 历史回查 | 600 / 600 | 600 / 600 | 600 / 600 |
| MySQL 精确命中 | 600 / 600 | 600 / 600 | 600 / 600 |
| MySQL 缺失 / 错配 / 重复 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 |
| ACK P95 | 314.530ms | 1555.109ms | 6228.311ms |
| 投递 P95 | 500ms 桶内 | 2000ms 桶内 | 10000ms 桶内 |
R2 唯一异常是 `lta0259` 到 `216.236.31.189:443` 的一次客户端读超时。该连接在发送阶段开始后自动恢复,因此该账号错过 600 条实时消息;600 条均可从历史补回,服务端和 MySQL 没有丢失。R3、R4 均达到消息正确性全通过:零断连、零实时缺失、零重复、零乱序、零持久化缺失。
R3、R4 的延迟门禁没有通过,但服务端分段指标未出现同步恶化:
- Topic queue wait P95 不超过 0.5ms。
- 消息保存 P95 不超过 25ms。
- push dispatch P95 不超过 10ms,队列无 full,完成后深度为 0。
- WS send queue wait P95 约 3ms,socket write 超过 99% 不超过 0.5ms。
- deferred presence 当前深度为 0。
- 600 条历史和数据库记录全部精确命中,Kafka 未出现消费积压。
ACK 延迟按 3 个拨号 IP 分组后同步上升,不集中于单一入口。R2 三个 IP 的 ACK P95 为 302–372ms,R3 为 1536–1643ms,R4 为 5999–6559ms,说明延迟发生在服务端 socket write 之后的共享公网、负载均衡或压测机接收链路。
压测结束后的网络基线同样异常:
| 入口 IP | TCP connect 平均 | HTTPS TTFB 平均 | ICMP 丢包 | ICMP RTT |
|---|---:|---:|---:|---:|
| `216.236.31.189` | 179ms | 623ms | 10% | 平均 256ms,最大 511ms |
| `216.236.31.191` | 178ms | 543ms | 10% | 平均 154ms |
| `64.118.158.131` | 178ms | 588ms | 0% | 平均 168ms |
三条静态路由均经 `en1` 和网关 `192.168.1.1` 直连,没有经过 VPN/TUN。当前公网 RTT、丢包和 HTTPS TTFB 已高于或接近 500ms ACK 门禁,TCP 重传会进一步放大 WebSocket 队头等待。因此本组结果可证明精确 600 人下服务端消息正确性和持久化能力通过,但不能证明公网端到端延迟 SLA 通过。
后续在进入精确 900 人测试前,应先满足以下任一条件:
1. 使用与测试集群同地域、低丢包的压测机,并保留当前 500ms/1s 延迟门禁。
2. 取得 Ingress/LB 的 upstream、出站带宽、连接 reset 和重传指标,将公网延迟与服务端处理延迟分开验收。
3. 双机正式压测时分别记录本机到 3 个入口的 RTT、丢包和 HTTPS TTFB;网络基线不合格时只验收正确性,不输出容量延迟结论。
新增产物:
- `loadtest/im-pressure/results/s600m-pushfix-r2-0930/`
- `loadtest/im-pressure/results/s600m-pushfix-r3-0930/`
- `loadtest/im-pressure/results/s600m-pushfix-r4-0930/`
## 16. 异步推送发版后精确 900 人复测
Run ID:`s900m-pushfix-r2-0930`,精确群成员 900 人,A/B 各 450 条 WebSocket 连接。两端均完整运行至 5 分钟沉淀、历史回查和 240 秒渐进关闭结束,没有提前终止。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 收到 ACK | 900 / 590 |
| 发送结果未知 | 310(186 条已被对端 WS 观察,124 条未入服) |
| 服务端实际接收并落库 | 776 / 900 |
| ACK P95 | 17075.685ms |
| ACK 台账实时投递 | 381424 / 531000 |
| ACK 台账实时到达率 | 71.8313% |
| 实时缺口 | 149576,全部可从历史补回 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 776 / 776 |
| MySQL 精确命中 | 776 / 776 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 断连 | 410(322 reset、88 abnormal close) |
| Pod 重启 | 0 |
本轮消息正确性结论很明确:590 条有 ACK,186 条虽然 ACK 超时或 socket 失败,但已被其他 WS 客户端观察到;这 776 条全部能从历史查到,且 MySQL 精确落库 776 条。服务端已接收消息的持久化丢失率为 0,无重复、无字段错配。余下 124 条在连接断开期间未进入服务端,不属于服务端落库后丢失。
服务端在 776 条实际入服消息上没有出现容量积压:
- Topic queue wait P95 不超过 10ms,95.7% 不超过 10ms。
- push dispatch 776/776 成功入队,无 full;队列等待 P95 不超过 5ms,dispatch P95 不超过 25ms。
- WS send queue 等待 P95 不超过 5ms,socket text write P95 不超过 0.5ms;1177345 次成功写入,16 次写入失败均是对端已关闭连接。
- 3 个 Pod 的 Kafka consumer lag 和 oldest backlog age 始终为 0,无 Topic、push dispatch 或 WS send queue full。
- 渐进关闭后 push dispatch 和 WS send queue 深度均为 0,`topic_presence_pending` 回落为 0。
压测期间客户端记录 410 次非预期断连,服务端指标同期新增 412 次 WS timeout,三个 Pod 分别为 135、133、144 次,并非单一 Pod 故障。客户端 reset 样本同样均匀分布在三个公网 IP。压测 agent A/B 峰值 CPU 约为 20.8%/22.0% 单核,峰值 RSS 约为 108/101MiB,FD 均为 460,本机进程没有出现 CPU、内存或 FD 容量耗尽。结合本章前已观测到的高 RTT、ICMP 丢包和 HTTPS TTFB,本轮只能判定“当前端到端公网路径下 900 长连接门禁失败”,具体故障点可能在压测机网络、NAT/ISP、Ingress/LB 或其间 TCP 链路,不能据此判定 IM 服务端在 900 人处理容量上失败。
下一次 900 人容量验收必须改为集群同地域压测机,或先取得 Ingress/LB 的 reset、TCP 重传、出站带宽和 upstream 时延指标。网络基线不合格时,继续从当前机器反复施压只会重复得到链路上限,无法给出服务端容量上限。
新增产物:
- `loadtest/im-pressure/results/s900m-pushfix-r2-0930/report.json`
- `loadtest/im-pressure/results/s900m-pushfix-r2-0930/db-verification.json`
- `loadtest/im-pressure/results/s900m-pushfix-r2-0930/server-metrics/`
## 17. 900 连接心跳容错与压测端解耦(待发版复测)
针对 R2 在约一个心跳超时窗口后出现的 410 次断连,已完成三项改造:
1. WebSocket Ping 周期和 Pong 等待时间从硬编码中拆出,支持 `ws_ping_period_seconds` 和 `ws_pong_wait_seconds` 配置。默认为 15 秒 Ping、90 秒 Pong 容错,不再复用 45 秒业务 idle timeout。
2. 新增 `im_ws_heartbeat_seconds` 和 `im_ws_heartbeat_total{event="pong_received"}`,用于核对每个 Pod 的有效心跳配置和 Pong 接收数;测试环境 ArgoCD patch 显式设置 15/90 秒。
3. 压测客户端为每条连接新增有序数据回调队列。Socket 读循环不再同步执行消息去重、顺序和延迟统计,避免统计锁竞争阻塞 ACK、Ping 和 Pong 读取。
发版后仍使用同一精确 900 人群复测。起压前先确认三个 Pod 的 heartbeat gauge 均为 15/90;验收时对比 Pong 增量、timeout 增量、agent 断连和入服/落库数。若仍然同时批量 timeout,下一步必须取得 Ingress/LB TCP 指标或改用同地域压测机,不再继续放大应用心跳窗口。
## 18. 90 秒心跳发版后精确 900 人复测
Run ID:`s900m-heart-r1-0930`。三个 Pod 发版后均为 Ready、零重启,指标确认有效心跳配置均为 `ping_period=15`、`pong_wait=90`。使用与上一轮相同的精确 900 人群,A/B 各 450 条连接,完整运行至历史回查和渐进关闭结束。
| 指标 | 本轮 | 改造前 R2 |
|---|---:|---:|
| 发送尝试 | 900 | 900 |
| 收到 ACK | 854 | 590 |
| ACK 超时 / socket 错误 | 45 / 0 | 310 条结果未知,其中 124 条未入服 |
| 明确失败 | 1 条 `500 internal error` | 0 |
| 服务端实际接收并落库 | 899 / 900 | 776 / 900 |
| 断连 | 11 | 410 |
| ACK P95 | 11475.912ms | 17075.685ms |
| ACK 台账实时投递 | 765336 / 768600 | 381424 / 531000 |
| ACK 台账实时到达率 | 99.5753% | 71.8313% |
| 重复 / 乱序 | 0 / 0 | 0 / 0 |
| 历史回查 | 899 / 899 | 776 / 776 |
| MySQL 精确命中 | 899 / 899 | 776 / 776 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
心跳容错和压测端读循环解耦有明显效果:断连从 410 次降到 11 次,降幅约 97.3%;未入服数从 124 降到 1。负载期三 Pod 累计成功发送约 14554 次 Ping,已观察到约 14472 次 Pong。11 次断连仍在 90 秒窗口附近发生,客户端全部自动重连。当前心跳仅在 Pong 回调时续期;下一步应将有效的客户端 Ping 和业务入站帧同样视为连接活动,而不是继续扩大 Pong 窗口。
45 条 ACK 超时消息全部被对端 WS 观察,且 899 条实际入服消息全部历史可查、MySQL 精确命中,证明已入服消息仍为零持久化丢失。唯一未入服消息已定位到 `10:07:58` 群主 Pod 的 MySQL `invalid connection`:同时出现群禁言策略查询失败和消息保存失败,客户端收到 `500 internal error`。需要对失效 MySQL 连接进行识别、丢弃并安全重试。
服务端分段指标中,899 条成功消息的 permission check P95 不超过 25ms、save P95 不超过 50ms、Kafka produce P95 不超过 25ms、realtime fanout P95 不超过 5ms、push prepare 和 push queue wait P95 不超过 10ms、push dispatch P95 不超过 25ms,无任何内部队列 full,Kafka lag 为 0。但 Topic queue wait P95 为 5s,最后 124 条在连接 timeout 与 presence 处理同期等待 0.1–10s,使 ACK P95 仍未过门禁。
压测 agent 峰值 CPU 约为 28.8%/22.8% 单核,RSS 约 121MiB,FD 各 460,进程资源未饱和。但异步数据回调队列尚未暴露当前深度和等待时间,因此本轮 98 秒投递 P95 包含网络、socket 读取和压测统计队列三段时间,不能单独作为服务端投递 SLA。下一轮前需增加压测回调队列深度/等待指标,并减少 recorder 在同一 `client_mid` 上的锁竞争。
新增产物:
- `loadtest/im-pressure/results/s900m-heartbeat-r1-0930/report.json`
- `loadtest/im-pressure/results/s900m-heartbeat-r1-0930/db-verification.json`
- `loadtest/im-pressure/results/s900m-heartbeat-r1-0930/server-metrics/`
## 19. 900 人复测后第二轮修复
针对第 18 节剩余的 11 次 timeout、1 条 MySQL `invalid connection` 和压测端回调排队不可观测问题,已完成以下修复:
1. 服务端收到有效客户端 Ping 时立即续期读截止时间并回 Pong;成功读取业务帧时也续期读截止时间。连接活跃判定不再只依赖服务端 Ping 对应的 Pong,新增 `im_ws_heartbeat_total{event="ping_received"}` 观测客户端心跳。
2. MySQL 只读查询遇到 `ErrInvalidConn` 时向 `database/sql` 返回 `ErrBadConn`,使连接池丢弃坏连接并在新连接上重试。个人消息仅在存在 `client_msg_id` 唯一键保护时重试一次;群消息由 `message_id` 和 `client_msg_id` 唯一键保护,整个保存操作可安全重试一次。新增 `im_db_invalid_connection_retry_total{operation,outcome}`。
3. recorder 的群消息接收位图改为原子 CAS。同一 `client_mid` 只在首次创建 observation 时使用 shard 锁,后续数百个收件人不再串行争用同一把写锁。
4. agent 新增回调队列当前深度、处理数、峰值、入队/完成数、累计等待和最大等待指标,并将快照写入 `summary.json` 的 `callback_queue`。生成实时到达率前会等待排队及正在处理的回调全部完成,避免统计尚未落入 recorder 时产生假丢失。
下一轮仍使用精确 900 人群复测。除原有正确性门禁外,重点检查:
- `im_ws_heartbeat_total{event="ping_received"}` 有持续增量,服务端 timeout 和客户端断连进一步下降。
- `im_db_invalid_connection_retry_total` 若出现,结果应为 `discard` 或 `recovered`,客户端不再收到对应 500。
- `callback_queue.current` 和 `callback_queue.processing` 在结算时均为 0;结合 `maximum`、`wait_seconds_total / completed` 和 `wait_seconds_max` 区分压测端排队与服务端投递延迟。
## 20. 第二轮修复后精确 900 人复测
Run ID:`s900m-activity-r1-0930`,部署提交 `36129e3`。复用精确 900 人群 `grpQ5_hPdAz3cE`,A/B 各 450 条 WebSocket 连接。两个 agent 均通过 30 秒满连接屏障,并一直运行至实时投递、5 分钟沉淀、历史回查、MySQL 校验和 120 秒渐进关闭全部完成。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 收到 ACK | 900 / 899 |
| 结果未知 | 1 条 ACK 超时,socket 错误 0 |
| 服务端实际接收并落库 | 900 / 900 |
| 实际实时投递 | 810000 / 810000 |
| 实时到达率 | 100% |
| 重复 / 乱序 | 0 / 0 |
| 压测期非预期断连 | 0 |
| 服务端 WS timeout | 0 |
| 历史回查 | 900 / 900 |
| MySQL 精确命中 | 900 / 900 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| ACK P95 | 4748.423ms |
| 投递 P95 | 10000ms 桶内 |
唯一 ACK 超时为 `s900m-activity-r1-0930-g-b-0121-01`。该消息虽然 20 秒内没有收到 ctrl ACK,但 A/B 均实时观察到,服务端分配 seq `3714`,历史可查且 MySQL 精确命中;因此是 ACK 返回链路超时,不是消息未入服或丢失。报告中的 `unexpected_messages=900` 正是该条没有进入 ACK ledger、但被 900 个连接实际收到的消息。
本轮连接活跃改造生效。三 Pod 均持续记录 `ping_received` 和 `pong_received`,负载阶段没有 WS timeout;两个 agent 全程 450/450 在线且断连为 0。与上一轮 11 次断连相比,客户端 Ping 和业务帧续期后已消除本轮心跳误断。
压测端回调队列指标证明 recorder 不再构成秒级延迟:
| agent | 入队 / 完成 | 峰值等待数 | 最大等待 | 平均等待 | 结算时等待 / 处理中 |
|---|---:|---:|---:|---:|---:|
| A | 405000 / 405000 | 106 | 11.353ms | 约 0.022ms | 0 / 0 |
| B | 405000 / 405000 | 98 | 5.075ms | 约 0.020ms | 0 / 0 |
服务端内部处理也没有出现容量积压:
- Topic queue wait P95 不超过 50ms,全部 900 条不超过 250ms。
- permission check P95 不超过 25ms,消息保存 P95 不超过 50ms,realtime fanout P95 不超过 5ms,push prepare P95 不超过 10ms。
- WS send queue wait P95 不超过 25ms,socket text write P95 不超过 0.5ms。
- push dispatch queue 无 full,Kafka consumer lag 和 oldest backlog age 均为 0。
- 没有 `invalid connection`、消息保存失败、内部队列 full、panic 或 Pod 重启;关闭后 push 队列和 `topic_presence_pending` 均回落为 0。
因此本轮消息正确性和持久化门禁已经通过,但端到端延迟门禁仍未通过。测试结束后的三入口公网 RTT 为 153–189ms,HTTPS TTFB 为 0.50–1.04s;三条路由均经 `en1` 和网关 `192.168.1.1` 直连。服务端从 Topic 排队到 socket write 的 P95 远低于端到端 ACK/投递 P95,压测回调队列最大等待也只有 11ms,剩余延迟主要位于服务端 socket 写入之后的 Ingress/LB、公网 TCP、NAT 或压测机网络路径。后续服务端容量验收应在集群同地域压测机执行,并同时采集 Ingress/LB 出站带宽、TCP 重传、reset 和 upstream 指标。
新增产物:
- `loadtest/im-pressure/results/s900m-activity-r1-0930/report.json`
- `loadtest/im-pressure/results/s900m-activity-r1-0930/db-verification.json`
- `loadtest/im-pressure/results/s900m-activity-r1-0930/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-activity-r1-0930/server-metrics/`
## 21. 精确 900 人混合场景测试
混合场景使用 900 条 WebSocket、135 对单聊用户和 224 个群发送者。单聊双方各发 5 条,共 1350 条;群发送者各发 2 条,共 448 条;总发送尝试 1798 条。每个 agent 理论应观察 201600 次群投递和 1350 次单聊发送方回显/接收方投递,共 202950 次唯一回调。
### 21.1 R1:发现结算窗口过早
Run ID:`s900m-mixed-r1-1001`。
- 1798 条消息全部进入服务端,历史 1798/1798,MySQL 1798/1798,缺失、错配、重复和额外记录均为 0。
- 收到 ACK 1411 条,387 条 ctrl ACK 超时;ACK P95 为 15756.928ms。
- A/B 负载期均为 450 条连接且断连为 0。
- 固定 60 秒 drain 结束时,A 的 summary 记录 202867/202950,误报 83 次实时缺口;summary 生成后指标继续增长至 202950/202950,说明这些帧是公网链路中的晚到帧,并未丢失。
- B 为 202950/202950。回调队列最大等待分别约 98ms/92ms,结算时等待和处理中均为 0。
- 发现 1 次单聊顺序回退:接收方 A-27 在同一 P2P Topic 上先收到 seq 245、后收到 seq 244。
本轮证明只等待“回调队列瞬时为空”不够:帧可能仍位于服务端 socket、Ingress/LB、TCP 或客户端 socket 缓冲区。压测程序已增加 `delivery_quiet_period` 和 `delivery_quiet_timeout`。固定 drain 后,接收计数和回调队列必须连续静默 10 秒才允许生成实时 summary;任意晚到帧都会重置静默窗口,最长等待 3 分钟。
同时修正实时统计口径:无 ACK 但已被任一 WebSocket 观察到的消息会与 ACK ledger 合并后参与全部收件人的实时到达率和“实时缺失但已落库”计算,不再被记为 `unexpected_messages`。该口径从后续轮次开始生效;R1/R2 原始报告仍保留当时输出,以上结论已按全量唯一回调和历史/MySQL 结果校正。
### 21.2 R2:静默结算修复验证
Run ID:`s900m-mixed-r2-1001`。本轮使用修复后的压测程序,静默等待在 A/B 均正常执行并完成后才生成 summary。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1760 |
| ACK 超时 / socket 错误 | 37 / 1 |
| 压测期断连 | 14,全部为 timeout,A/B 各 7 |
| A 唯一实时回调 | 202535 / 202950 |
| B 唯一实时回调 | 202787 / 202950 |
| 全量唯一实时回调 | 405322 / 405900,约 99.8576% |
| ACK 台账实时缺口 | 566,全部已落库 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| ACK P95 | 14317.159ms |
| 投递 P95 | 46622.373ms |
R2 的 A/B 各 7 条连接在负载期超时断开,随后全部自动恢复至 450/450。实时缺口与断连窗口一致,静默窗口结束后接收计数不再增长,因此不是 R1 那种过早结算。所有实时缺口均可由历史补回,MySQL 独立校验证明服务端持久化零丢失。
两个 agent 的回调队列最大等待约 10ms,结算时 current/processing 均为 0,压测 recorder 不是本轮瓶颈。服务端 1350 条 P2P 消息全部处理完成,P2P Topic queue P95 位于 250–500ms,relationship check P95 位于 100–250ms;Kafka lag 为 0,未出现 MySQL invalid connection、内部队列 full 或 Pod 重启。关闭后 push 队列和 deferred presence 均回落为 0。
混合场景在当前公网压测机上未通过实时和延迟门禁,但持久化门禁通过。下一步不应直接扩大到 1200 人,应先在集群同地域压测机重复 900 人混合场景;若同地域仍出现乱序或实时缺口,再分别处理跨 Pod P2P 顺序和 WS 投递路径。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r1-1001/`
- `loadtest/im-pressure/results/s900m-mixed-r2-1001/`
### 21.3 R3:零断连、零实时丢失,但仍有 ctrl ACK 超时
Run ID:`s900m-mixed-r3-1001`。本轮沿用 900 条 WebSocket、135 对单聊用户和 224 个群发送者,共发送 1350 条单聊和 448 条群消息。端到端 P95 延迟按最新口径仅作观测,不参与通过判定。
| 指标 | R3 | R2 |
|---|---:|---:|
| 就绪连接 | 900 / 900 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1751 | 1798 / 1760 |
| ctrl ACK 超时 / socket 错误 | 47 / 0 | 37 / 1 |
| 压测期非预期断连 | 0 | 14 |
| 两端唯一 WS 回调 | 405900 / 405900 | 405322 / 405900 |
| 实时缺口 | 0 | 578 次底层回调,ACK 台账折算缺口 566 |
| 重复 / 乱序 | 0 / 0 | 0 / 0 |
| 历史回查 | 1798 / 1798 | 1798 / 1798 |
| 无 ACK 但已观察消息的历史命中 | 47 / 47 | 38 / 38 |
| MySQL 精确命中 | 1798 / 1798 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
| ACK P95(仅观测) | 14020.142ms | 14317.159ms |
| 投递 P95(仅观测) | 52784.346ms | 46622.373ms |
R3 的消息正确性明显好于 R2:A/B 各收到 202950/202950 个唯一回调,包括每端 201600 次群投递、675 次单聊对端投递和 675 次发送方回显。固定 drain 后又经过连续 10 秒静默窗口,计数未再变化,本轮没有过早结算。
47 条 ctrl ACK 超时中,单聊 26 条、群聊 21 条,A/B 分别为 22/25 条。它们全部被 WS 实时观察,47/47 历史可查,并被 MySQL 独立校验纳入 1798/1798 精确命中,所以不是消息未入服或持久化丢失。但客户端在 20 秒内未收到 ctrl ACK,仍是独立的发送协议问题,不属于已取消的 500ms 延迟门禁。因此本轮应判定为:
- 实时到达、重复、顺序、断连、历史和落库正确性通过。
- 混合场景总验收仍未通过,唯一阻断为 47 条 `command_timeout=20s` 的 ctrl ACK 超时。
- ACK P95 和投递 P95 保留在报告中,不影响上述判定。
服务端负载期没有 Topic/WS 发送队列 full,WS send queue 结算时均为 0,所有 Kafka consumer lag 为 0,三个 Pod 均为 Ready 且零重启。连接渐进关闭后,三 Pod 连接数从 518/373/550 回落至 7/2/7,与起压前 8/3/6 基本一致。Pod 2 增加了 1 次 `Cluster.Route` 和 1 次 `Cluster.UserCacheUpdate` 瞬时 error,均不足 1ms,最近日志无对应错误,且未影响本轮投递与落库。
压测端回调队列在结算时均为 0;A/B 队列峰值为 355/174,最大等待仅为 37.054ms/25.036ms,不是 20 秒 ACK 超时或 52 秒投递 P95 的原因。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/server-metrics/`
### 21.4 R3 ctrl ACK 超时原因分析
#### 21.4.1 代码路径
服务端的消息发布顺序为:Topic 队列出队、权限/关系校验、持久化、生成 `NoErrAccepted` ctrl ACK 并调用 `queueOut`,然后才做当前消息的 Topic 广播、跨 Pod 实时 fanout 和离线推送准备。因此,当前消息的大规模 fanout 不会阻塞同一条消息的 ACK 生成。
但所有 ctrl 和 data 最终共用用户的同一个 WebSocket/TCP 有序字节流。ACK 可以在当前消息的 data 之前入队,但无法超过已经写入 socket 或正在 Ingress/LB/公网 TCP 中传输的早先 data 帧。混合场景中发送者同时也是 900 人群的收件人,因此每条发送连接在等待自己 ACK 时,前面可能已有数百条群 data 帧。
#### 21.4.2 服务端分段证据
按起压前与负载结束 Prometheus 快照计算本轮增量:
| 阶段 | 增量样本 | P95 |
|---|---:|---:|
| P2P Topic 队列等待 | 1350 | 100ms 桶内 |
| P2P 关系校验 | 1350 | 100ms 桶内 |
| P2P 保存 | 1350 | 50ms 桶内 |
| 群 Topic 队列等待 | 448 | 25ms 桶内 |
| 群权限校验 | 448 | 25ms 桶内 |
| 群保存 | 448 | 50ms 桶内 |
| Cluster TopicMaster RPC | 2495 | 3至5ms 桶内 |
| Cluster proxy 队列深度 | 7988 | P95=1 |
| WS 发送队列深度 | 817924 | P95=0 |
| WS 发送队列等待 | 817924 | 10ms 桶内 |
| WS text frame 写入 | 817946 | 0.5ms 桶内 |
本轮三 Pod 共成功写出约 81.8 万个文本帧、193.6MB。服务端应用层队列、保存、Cluster RPC 和 socket write 均远低于 20 秒;压测时间窗内无 queue full、write timeout、数据库错误、Pod 重启或 panic。因此没有证据支持“业务处理或服务端应用队列阻塞 20 秒”。
#### 21.4.3 超时分布证据
- 47 条超时全部发生在起压后 20至40 秒,正好是每条连接已经接收约 200至400 条群广播帧的区间。
- 40/47 是同一用户的第 2 条消息,5 条是第 3 条,只有 2 条是第 1 条;超时不是随机散布在整个测试周期。
- 三个入口 IP 分别有 16/16/15 条超时,排除单一 Ingress IP 热点。
- 压测端回调队列最大等待只有 37ms,数据回调已与 socket 读循环解耦;超时不是 recorder 锁竞争或回调队列堵塞。
- 测试后三个入口的当前 ICMP RTT 约为 167至197ms,HTTPS TTFB 约为 0.49至0.61 秒;路由均经 `en1` 直连,未经 VPN/TUN。
综合上述证据,当前根因判断为:**高置信度的 WebSocket/TCP 单流队头阻塞,延迟主要发生在服务端完成 socket write 之后的 Ingress/LB、公网 TCP、NAT 和压测机接收路径。**这个结论可以解释“服务端已快速保存且客户端最终收到全部 data,但部分 ctrl 在 20 秒窗口内未被读到”。
尚未完全证明的环节是:当前压测程序在 ctrl 超时后删除 wait entry,晚到 ctrl 会被静默忽略;服务端 WS 写指标也没有区分 ctrl 和 data。因此现有数据无法逐条证明 47 个 ACK 全部在 20 秒后到达,只能证明对应消息全部已被服务端接受、投递和持久化。
#### 21.4.4 处理建议
1. 压测端增加 late ctrl 台账:ctrl 超时后保留 ID 和发送时间 10 分钟,记录晚到 ACK 数、最终延迟和完全未到数。
2. 服务端将 WS 出站指标按 `ctrl/data/pres/info` 分类,新增从客户端请求时间到 ctrl ACK 入队、实际 socket write 完成的直方图;跨 Pod 时再记录 master-to-proxy ctrl 延迟。
3. 下一轮保持完全相同的 900 人混合负载,将 `command_timeout` 改为 90 秒,但单独统计超过 20 秒的 ACK。若 47 类消息都在 90 秒内到达,且服务端 ctrl write 延迟仍在毫秒级,则队头阻塞结论得到直接验证。
4. 当前业务不对端到端延迟设阻断门禁,因此不建议现在改造独立控制连接或破坏 WebSocket 有序性。应先补齐上述观测,并将“消息已实时观测且历史/MySQL 命中”与“20 秒内收到 ctrl ACK”分开报告。
5. 另有一项与 ACK 超时无关的独立缺陷:负载结束后 Pod 2 记录 `gob: type not registered for interface: []map[string]interface {}`,影响另一个群 Topic 的 Cluster Route。当前 cluster gob 已注册 `[]any` 和 `map[string]any`,但未注册 `[]map[string]any`,应单独补注册和编解码回归测试。
#### 21.4.5 观测改造完成情况与 R4 口径
上述观测缺口已补齐:
- 压测端会统计 `publish_ctrl_observed`、`publish_ctrl_over_20s`、`publish_ctrl_late`、`publish_ctrl_unresolved` 和 `publish_ctrl_p95_ms`,并保留最多 1000 条超过 20 秒或晚到的样本。
- agent Prometheus 新增 `im_load_agent_publish_ctrl_observed_total`、`im_load_agent_publish_ctrl_over_20s_total`、`im_load_agent_publish_ctrl_late_total` 和 `im_load_agent_publish_ctrl_unresolved`。
- 服务端 Prometheus 新增 `im_ctrl_ack_delivery_duration_seconds{stage="enqueue|write"}`,按 Pod 记录从请求进入到 ACK 进入 WS 发送队列、以及完成 socket write 的时间。
- Cluster gob 已注册 `[]map[string]any`,并用真实系统消息结构补了编解码回归测试。
R4 保持 R3 的 900 连接混合负载,仅将 `command_timeout` 从 `20s` 改为 `90s`。验证时必须同时满足:`publish_ctrl_unresolved=0`、消息实时与历史/MySQL 零丢失,并对比服务端 `enqueue`/`write` P95 与客户端 ctrl P95。如服务端两阶段仍为毫秒级,但 `publish_ctrl_over_20s>0`,则可将超过 20 秒的部分定位在 socket write 之后的网络/TCP 接收路径。
## 22. 精确 900 人全量单聊测试
Run ID:`s900m-p2p-r1-1001`。900 条 WebSocket 连接组成 450 对好友,A/B 各承担 450 个用户。每个用户以 1 秒间隔发送 10 条消息,共尝试发送 9000 条。两个 agent 均完整运行至实时静默判定、5 分钟沉淀、历史回查、MySQL 校验和 120 秒渐进关闭结束,没有提前终止。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 9000 / 9000 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 对端实时投递 | 9000 / 9000 |
| 发送方回显 | 9000 / 9000 |
| WS 唯一回调总数 | 18000 / 18000 |
| 实时到达率 / 丢失率 | 100% / 0% |
| 重复 / 乱序 / 非预期消息 | 0 / 0 / 0 |
| 压测期非预期断连 | 0 |
| 历史回查 | 9000 / 9000 |
| MySQL 精确命中 | 9000 / 9000 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| ACK P95 / 最大值 | 854.385ms / 1324.17ms |
| 实时投递 P95 / 最大值 | 1000ms 桶内 / 1324.255ms |
| 服务端 Pod 重启 | 0 |
报告的 `expected_deliveries=9000` 只统计对端投递,不包含发送方自身回显。A/B 各收到 4500 次对端投递和 4500 次本地回显,因此底层唯一 WS 数据回调合计为 18000 次,不是重复消息。
本轮单聊正确性和持久化门禁完整通过:9000 条发送全部收到 ACK,所有收发双方均实时收到应有消息,历史接口与 `im_008_messages` 都精确命中 9000 条。没有发生 ACK 超时、socket 错误、重复、乱序、实时丢失或持久化丢失。
压测端的回调处理没有构成瓶颈:
| agent | 回调入队 / 完成 | 队列峰值 | 最大等待 | 结算时等待 / 处理中 |
|---|---:|---:|---:|---:|
| A | 9000 / 9000 | 7 | 16.317ms | 0 / 0 |
| B | 9000 / 9000 | 9 | 13.004ms | 0 / 0 |
服务端运行状态稳定:
- 三个 Pod 均为 Ready、0 重启,单 Pod 资源配置为 `requests=1 CPU/512Mi`、`limits=2 CPU/1Gi`。
- 负载结束时 WS 连接分布为 527/373/544;压测连接渐进关闭后回落至 1/0/2,剩余为环境常驻连接,未再出现压测 agent FD 不释放。
- 三个 Pod 的 WS send queue 结算时均为 0;Kafka 的消息持久化、群消息持久化、管理命令、配置事件、缓存失效、死信和工作台 consumer lag 均为 0。
- 关闭过程中的少量 `ping closed` 累计指标来自客户端主动渐退,本轮 agent 记录的负载期非预期断连为 0。
按最新验收口径,ACK P95 和投递 P95 仅用于趋势对比,不作为当前公网单机容量测试的阻断条件。重新合并后本轮 `passed=true`:单聊消息正确性、实时到达、顺序性、连接稳定性和落库均通过。ACK P95 854.385ms 和投递 P95 1000ms 仍保留在报告中,但不影响本轮通过结论。
全量单聊已通过,无需为 500ms 延迟门禁单独重复 R2。下一步可进入更高风险的群聊或混合场景复测,仍以零丢失、零重复、零乱序、零断连和全量落库为通过条件。
新增产物:
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/report.json`
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/server-metrics/`
## 23. 900 人混合 R4 长 ACK 观测复测
Run ID:`s900m-mixed-r4-1001`。负载与 R3 相同:900 条 WebSocket、135 对单聊用户和 224 个群发送者,共发送 1350 条单聊和 448 条群消息。本轮仅将 `command_timeout` 改为 90 秒,并启用 ctrl ACK 晚到台账和服务端 `enqueue/write` 分段指标。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒的 ACK | 118 |
| 90 秒超时 / 晚到 / 最终未到 | 0 / 0 / 0 |
| ACK P95 / 最大值 | 23425.942ms / 68730.192ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时到达率 / 丢失率 | 100% / 0% |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
118 条慢 ACK 的精确延迟范围为 20.317 至 68.731 秒,中位数约 27.062 秒,慢样本 P95 约 39.465 秒。两个 agent 分别有 56 和 62 条超过 20 秒的 ACK,但全部在 90 秒内返回。R4 直接证明同负载下存在“20 秒后才到、但未丢失”的 ACK,强力支持 R3 的 47 条超时属于同类晚 ACK,而非服务端拒绝或消息丢失。
服务端三个 Pod 的 ctrl ACK 指标共有 1801 个自 Pod 启动后的样本,与本轮 1798 条发送只相差 3 条环境流量:
| Pod | enqueue 平均 | write 平均 | enqueue/write P95 |
|---|---:|---:|---:|
| im-server-0 | 49.672ms | 50.059ms | 均在 250ms 桶内 |
| im-server-1 | 51.612ms | 52.176ms | 均在 250ms 桶内 |
| im-server-2 | 48.051ms | 48.358ms | 均在 250ms 桶内 |
ACK 从 WS 队列入队到 socket write 完成只增加约 0.3至0.6ms,而客户端有 118 条超过 20 秒。因此可确认长延迟发生在服务端 socket write 完成之后的 Ingress/LB、公网 TCP/NAT 或压测机接收路径,不在 Topic、持久化、Cluster RPC 或 WS 应用发送队列内。
本轮底层共处理 405900 个匹配的 WS data 回调,A/B 的回调队列峰值为 120/149,最大等待仅 9.603/9.199ms,结算时全部排空。Kafka 各 consumer lag 均为 0,未出现 Topic、Cluster proxy 或 WS send queue full;渐进关闭后只有 4 次预期的 ping closed 写错误。
报告中 `ack_p95_ms=23425.942` 是基于 1798 条 ACK 原始时间戳的精确值。本轮二进制的 ctrl 直方图上界仅到 10 秒,使 `publish_ctrl_p95_ms` 在 `+Inf` 桶中退化为最大值 68731.141ms,该字段本轮不作为 P95 使用。直方图已增加 20、30、45、60、90 和 120 秒桶及回归测试,下轮会输出正确的长延迟分位桶。
服务端日志还发现两次与本轮 Topic 无关的 Cluster Route 失败:`gob: type not registered for interface: types.KVMap`。`types.KVMap` 是命名 map 类型,不会被 `map[string]any` 的 gob 注册覆盖。已补充 `types.KVMap` 和 `[]types.KVMap` 注册,并扩展 Cluster gob 回归测试;该修复需要再发版。
本轮 `report.json` 最终 `passed=true`。混合场景的消息正确性、实时到达、顺序、连接稳定性、历史回查和 MySQL 落库均通过;长 ACK 已被定位为 socket write 之后的端到端队头阻塞,不再阻断当前容量验收。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/im-server-{0,1,2}.log`
## 24. 900 人混合 R5 顺序复测
Run ID:`s900m-mixed-r5-1001`。本轮在 R4 相同的 900 条 WebSocket、135 对单聊用户和 224 个群发送者负载下,验证长延迟直方图、Cluster gob 类型修复及消息顺序。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 90 秒命令窗口内 ACK | 1798 / 1797 |
| 晚到 2xx / 最终接受 | 1 / 1798 |
| 明确拒绝 / 最终未知 | 0 / 0 |
| 超过 20 秒的 ACK | 375 |
| ctrl ACK P95 桶 / 精确 ACK P95 | 45s / 41746.854ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 / 丢失 | 404550 / 404550,0 丢失 |
| 重复 / 乱序 / 断连 | 0 / 1 / 0 |
| 历史回查 | 1798 / 1798 |
| 无及时 ACK 消息历史命中 | 1 / 1 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
唯一超时消息 `s900m-mixed-r5-1001-g-a-0136-02` 在 95.028 秒收到 202,因此属于晚到成功,不是拒绝或丢失。压测报告已区分 `publish_accepted`(命令等待窗口内)与 `publish_final_accepted`(包含晚到 2xx);用新口径重算后发送成功率为 100%,报告只因真实乱序而失败。长延迟直方图的 P95 正确落在 45 秒桶,不再退化为最大值。
本轮唯一乱序发生在 A 机 97 号连接的同一单聊 Topic:客户端先收到 seq=322,后收到 seq=321。两条消息由会话两端在几乎同一时刻发送,服务端均实时投递并精确落库,所以问题不在 MySQL、Kafka 或历史回查,而在 Topic master 到 proxy Pod 的实时转发顺序。
代码检查确认,同一个 multiplex session 每次入队都会调度 `clusterWriteLoop`;此前没有排他状态,多个 worker 可以同时消费同一个 `send` channel。随后使用 fire-and-forget RPC 时,先取出的 seq=321 与后取出的 seq=322 会在远端并发执行,导致 seq=322 先进入 proxy Topic。该竞态与本轮现象完全一致。
修复内容:
1. multiplex session 增加原子调度所有权,同一 session 同时只允许一个 Cluster 写任务排空;释放所有权时再次检查队列,覆盖入队与退出之间的竞态。
2. Master 到 Proxy 的同一 Topic 响应改为逐条等待 RPC 完成,保证远端处理完 seq=N 后才发送 seq=N+1;不同 Topic 仍由 worker pool 并行处理。
3. 新增本地 RPC 回归测试,故意延迟 seq=1 的处理并重复调度同一 session,验证接收顺序始终为 1、2;该测试在 `go test -race` 下通过。
4. 压测工具新增晚到 2xx 最终接受计数,已解决“最终已 202 但报告仍算发送失败”的重复判错。
服务端三个 Pod 共记录 1798 条 ctrl ACK,分布为 574/611/613;enqueue/write 平均约 60至66ms,P95 均不超过 250ms。未出现 Topic、Cluster proxy、WS send queue full、Kafka 错误、WS text write 错误或 Pod 重启。A/B 回调队列峰值为 253/290,最大等待 10.703/5.929ms,结算时均已排空。完整 Pod 日志中未再出现 `types.KVMap` 或其他 gob 注册错误,证明 R4 后的 Cluster gob 修复已生效。
本轮 MySQL 校验覆盖 `im_008_group_messages_202610` 与 `im_008_messages`,1798 条业务消息全部精确命中。结论是持久化、实时到达、长 ACK 统计和 gob 修复均通过,当前唯一阻断项为已完成代码修复但尚待发版复验的跨 Pod 顺序竞态。
下一步发版后执行完全相同的 900 人混合 R6。R6 通过条件为:最终接受 1798/1798、实时投递 404550/404550、重复/乱序/断连均为 0、历史和 MySQL 均为 1798/1798;ACK 延迟继续观测但不作为阻断门禁。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/im-server-{0,1,2}.log`
## 25. 900 人混合 R6/R7 顺序修复发版复验
### 25.1 R6:顺序修复通过,公网瞬时断连导致整轮失败
Run ID:`s900m-mixed-r6-1001`。本轮首次验证同步 Master-to-Proxy 顺序投递版本,跨 Pod 乱序已从 R5 的 1 次降为 0,但压测机在 15:42:13 至 15:42:18 UTC 同时对两个入口 IP 发生 TCP 写超时。
| 指标 | R6 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1785 |
| socket 结果未知 | 13 |
| 压测期断连 | 11,均为 write timeout |
| 实时投递 | 401361 / 404542 |
| 实时缺口 | 3181,全部历史可查 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 1790 / 1790 |
| MySQL 精确命中 | 1790 / 1790 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
11 次断连分布在 `216.236.31.189` 和 `216.236.31.191` 两个入口,并集中于约 5 秒窗口;三个服务 Pod 均 Ready、零重启,负载期无 Topic/Cluster/WS 队列满、Kafka 错误或 gob 错误。13 条 socket 结果未知中有 5 条被对端观察并精确落库,另外 8 条未落库且未形成额外数据,符合请求在进入服务端前失败的特征。
R6 的顺序修复目标已通过,但整轮因断连、发送未知和实时缺口判定失败。三条目标 IP 路由复查均为 `en1` 直连,未经过 VPN/TUN。为排除一次性公网抖动,随即执行相同参数的 R7。
### 25.2 R7:消息业务门禁全部通过
Run ID:`s900m-mixed-r7-1001`。运行前对三个入口 IP 的 HTTPS 预检均返回 200,TLS 总耗时约 1.0 至 1.1 秒。
| 指标 | R7 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 最终接受 | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒 / 晚到 / 最终未到 ACK | 74 / 0 / 0 |
| 精确 ACK P95 | 16795.372ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 | 404550 / 404550 |
| WS 匹配回调总数(含单聊回显) | 405900 / 405900 |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
R7 的 `report.json` 为 `passed=true`。R5 暴露的同 Topic 跨 Pod 乱序在 R6 和 R7 连续两轮均为 0,证明单 session 排他 drain 与同步 RPC 顺序修复有效。压测端 A/B 回调队列峰值为 156/150,最大等待 13.281/25.038ms,结算时均为 0。
按 R7 起压前和负载结束快照计算增量:服务端新增 1798 条 ctrl ACK,enqueue/write 平均约 82.43/82.89ms,P95 均在 500ms 桶内;`Cluster.TopicProxy` 新增 7943 次调用,平均约 2.27ms、P95 在 10ms 桶内。负载期无新增 RPC error、内部队列失败、Kafka lag、queue full、gob 错误或 Pod 重启,说明同步 RPC 没有形成负载期瓶颈。关闭后服务端 active WS 回落为 0,压测进程和 FD 均完全释放。
### 25.3 关闭阶段 multiplex 队列竞态
R6 和 R7 均在最后一批连接关闭后记录 1 次 `s.queueOut: session's send queue full 8:grpQ5_hPdAz3cE-im-server-0`。该事件发生在业务发送、实时静默、历史回查和 summary 生成之后,没有影响上述消息结果,但违反“渐进关闭阶段也不得队列满”的严格运维门禁。
根因是 proxy stop/detach 后 multiplex worker 已退出并从 SessionStore 删除,而 Topic 的异步 unsubscribe 尚未完成,短时间内仍可能通过旧指针调用 `queueOut`。旧 session 未标记 terminating,消息进入无人消费的 send channel 并最终填满。修复已在本地完成:Cluster session 清理开始时先原子标记 terminating,并排空残留 channel;后续对旧指针的写入会被直接丢弃,不再进入失去 drain owner 的队列。新增关闭竞态测试和顺序测试均在普通模式连续 20 次、race 模式连续 10 次通过。
因此当前结论分为两层:
- 900 人混合消息业务门禁已经通过,跨 Pod 顺序修复有效。
- 完整运维门禁还需发布关闭竞态补丁后做一次 R8,重点验证业务指标继续全绿且渐进关闭阶段 `queue full=0`。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r6-1001/`
- `loadtest/im-pressure/results/s900m-mixed-r7-1001/`
## 26. 900 人混合 R8 关闭竞态补丁复验
Run ID:`s900m-mixed-r8-1002`。本轮使用与 R7 完全相同的 900 条 WebSocket、135 对单聊用户和 224 个群发送者负载,重点验证关闭竞态补丁发布后,业务负载期和 120 秒渐进关闭全过程不再出现 `send queue full`。
| 指标 | R8 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 计划发送 / 实际尝试 | 1798 / 1798 |
| 最终接受 / socket 结果未知 | 1796 / 2 |
| 明确拒绝 / ACK 超过 20 秒 | 0 / 0 |
| 精确 ACK P95 / 最大值 | 1041.578ms / 2055.915ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 已接受群消息应投 / 实投 | 401400 / 400954 |
| 已接受消息实时总投递 | 402750 / 402304 |
| 实时缺口 | 446,全部可持久化回查 |
| 重复 / 乱序 | 0 / 0 |
| 压测期非预期断连 | 1,公网读超时后自动重连 |
| 历史回查 | 1796 / 1796 |
| MySQL 精确命中 | 1796 / 1796 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 负载期 / 关闭期 queue full | 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
### 26.1 严格业务门禁结论
本轮 `report.json` 为 `passed=false`。失败由 A 机第 231 个连接的一次公网读超时引起:
```text
read tcp 192.168.1.135:57717->216.236.31.191:443: read: operation timed out
```
断连发生于 `2026-10-02T06:31:53.391465Z`,客户端约 1.5 秒后报告连接恢复。该用户的两条群发送 `s900m-mixed-r8-1002-g-a-0231-01/02` 分别得到 `websocket closed` 和 `websocket connection unavailable`,未进入服务端,MySQL 中也没有对应额外行。因此实际被服务端接受的业务消息为 1796 条,不能把这两条计为服务端持久化丢失。
同一连接在群消息阶段漏收 446 条已接受群消息,形成 446 次实时投递缺口;这些消息全部可通过历史回查获得。其余连接的群投递、全部 1350 次单聊对端投递、重复和顺序检查均正确。按严格验收标准,本轮仍因 `disconnects=1`、`publish_outcome_unknown=2` 和实时缺口判定失败,不能替代 R7 的业务全绿结论。
服务端三个 Pod 在超时窗口均保持 Ready、零重启、Kafka lag 为 0,也没有 Topic、Cluster、WS 队列满、RPC error、gob 或持久化错误。证据更符合压测机到单个公网入口的瞬时网络读超时,不支持服务端容量拐点或队列阻塞判断。
### 26.2 关闭竞态补丁结论
关闭竞态补丁的专项验收通过。两个 agent 均完成实时静默、5 分钟沉淀、历史回查、summary 生成和 120 秒渐进关闭,并以退出码 0 结束;没有提前停止进程。从负载开始到全部压测连接关闭后的完整 Pod 日志中:
- `s.queueOut: session's send queue full` 为 0;
- Cluster P2M `queue_full` 增量为 0;
- WS send queue `queue_full` 增量为 0,三个 Pod 结算队列深度均为 0;
- 未出现旧 multiplex session 无消费者却继续积压消息的现象;
- 三个 Pod 均保持 Ready、0 重启,Cluster peer 均为 2/2。
这证明“清理 Cluster session 前先标记 terminating 并 purge channel”的修复已经消除 R6/R7 关闭末尾出现的孤儿发送队列积压。关闭期日志仅有 1 次客户端渐退附近的 `close 1006 unexpected EOF`,没有引发队列堆积或 Pod 状态异常。
### 26.3 服务端与压测端观测
按起压前和负载结束快照计算增量:
| 指标 | 结果 |
|---|---:|
| `Cluster.TopicProxy` | 8227 次,平均 2.285ms,P95 在 10ms 桶内 |
| ctrl ACK enqueue | 1798 次,平均 62.11ms,P95 在 250ms 桶内 |
| ctrl ACK write | 1798 次,平均 62.43ms,P95 在 250ms 桶内 |
| Cluster P2M queue full | 0 |
| WS send queue full | 0 |
| Kafka consumer lag | 全部为 0 |
A/B 回调队列峰值为 63/47,最大等待为 9.865/10.031ms,结算时等待和处理中均为 0。客户端回调处理没有形成瓶颈。MySQL 校验覆盖 `im_008_group_messages_202610` 与 `im_008_messages`,1796 条服务端已接受消息全部精确命中。
本轮结论应分开使用:**关闭竞态修复已通过专项复验;R8 整体业务门禁因一次公网断连失败。**如需获得“修复版本下业务与关闭运维门禁同时全绿”的单轮证据,应保持参数不变再执行 R9;无需因本轮结果继续修改服务端队列代码。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/im-server-{0,1,2}.log`
## 27. 900 人混合 R9 全量复验
Run ID:`s900m-mixed-r9-1002`。本轮保持 R8 的配置、账号、消息量和验收口径不变。起压前三个公网入口均返回 200,HTTPS 总耗时约 0.48 至 0.56 秒;三个 Pod 使用相同镜像,均为 Ready、0 重启。
| 指标 | R9 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 最终接受 | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒的 ACK | 10 |
| 精确 ACK P95 | 11417.014ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 | 404550 / 404550 |
| WS 匹配回调总数(含单聊回显) | 405900 / 405900 |
| 实时到达率 / 丢失率 | 100% / 0% |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 负载期 queue full | 0 |
| 渐进关闭期 queue full | 1 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
### 27.1 业务门禁通过
本轮 `report.json` 为 `passed=true`。1798 条发送全部得到最终 ACK,404550 次对端投递全部实时到达;没有发送未知、实时丢失、重复、乱序或非预期断连。A/B 各收到 202950 次匹配回调,其中包含 675 次单聊发送方回显,因此底层回调总数为 405900。
历史接口和 MySQL 均精确命中 1798/1798。数据库校验覆盖 `im_008_group_messages_202610` 与 `im_008_messages`,缺失、错配、重复和额外行均为 0。Kafka consumer lag 全部为 0,三个 Pod 全程 Ready、0 重启。
A/B 回调队列峰值为 218/210,结算时均完全排空。按起压前和负载结束快照计算,服务端 `Cluster.TopicProxy` 新增 7857 次调用,平均 1.752ms、P95 在 5ms 桶内;ctrl ACK enqueue/write 各 1798 次,平均 72.513/72.812ms,P95 均在 500ms 桶内。负载期没有 Topic、Cluster、WS 队列满、RPC 失败、gob 或持久化错误。
因此 R9 已取得修复版本下完整的消息业务全绿证据,并再次验证跨 Pod 顺序修复有效。10 条超过 20 秒的 ACK 均在 90 秒命令窗口内返回,继续按既定口径只做趋势观测。
### 27.2 关闭期仍有一个低概率队列满
在最后一批连接退出时,Pod `im-server-1` 于 `2026-10-02T07:23:16Z` 记录:
```text
s.queueOut: session's send queue full 8:grpQ5_hPdAz3cE-im-server-2
topic[grpQ5_hPdAz3cE]: connection stuck, detaching - 8:grpQ5_hPdAz3cE-im-server-2
cluster: session proxy closed 8:grpQ5_hPdAz3cE-im-server-2
```
该事件发生在 summary 已生成、1798 条历史核验完成之后,没有影响消息正确性和持久化。结算时三个 Pod 的 WS send queue 与 Cluster P2M queue 深度均为 0,Kafka lag 为 0。
R9 证据修正了 R6/R7 时的部分根因判断。此次队列满发生时,`grp...-im-server-2` multiplex session 尚未终止;日志顺序是先队列满、Topic 判定连接卡住并 detach,最后 session 才关闭。因此 R8 发布的 `terminating + purgeChannels` 补丁只能防止“已进入终止流程的旧 session 被继续写入”,不能处理仍存活 multiplex session 在大量连接关闭时的瞬时响应突发。
当前 multiplex `send` channel 容量为 `sendQueueLimit + 32 = 160`。Cluster 写任务采用排他调度和同步 Master-to-Proxy RPC;正常负载期吞吐稳定,但关闭阶段多个 unsubscribe/presence 响应可在 drain worker 获得调度前快速填满 160 个槽位。R8 未复现、R9 复现 1 次,符合低概率调度竞态,而不是持续消费能力不足:R9 关闭阶段 `Cluster.TopicProxy` 仍完成 3195 次调用,平均约 0.9ms、P95 在 3ms 桶内。
后续修复不应只继续增加 terminating 判断。建议为 MULTIPLEX 出站队列增加一个很短的有界背压重试:首次非阻塞入队失败时确保 drain 已调度,并等待最多约 250ms 获取队列槽位;超时后才维持现有的 `queue full -> detach` 保护。这样可以吸收调度抖动和关闭突发,同时避免永久阻塞 Topic goroutine。需要增加“队列已满但 worker 很快释放槽位”与“worker 永久不消费时按时失败”两类回归测试,发版后再执行 R10 专项复验。
上述修复已在 R9 后完成:MULTIPLEX 出站队列首次满时会确保 Cluster drain 已调度,并在 250ms 有界窗口内等待一个槽位;只有持续无消费超过窗口才沿用原来的失败和 detach 保护。新增的 `clusterQueueMu` 将 terminating 检查与入队、以及终止标记与 channel 清理串行化,防止等待期间 session 恰好终止后消息重新进入已清空队列。普通 WebSocket、gRPC 和长轮询队列仍保持原有非阻塞语义,不受此次改动影响。
回归测试覆盖短暂队列满后恢复、持续不消费按时失败、等待期间 session 终止不残留消息,以及原有跨 Pod 顺序与终止拒绝写入场景。针对性测试普通模式连续 20 次、race 模式连续 10 次通过,`go test ./server ./loadtest/im-pressure` 全部通过。该修复尚待发版并执行 R10 复验。
本轮最终结论:**消息业务门禁通过;完整运维门禁仍因关闭期 1 次 multiplex queue full 未通过。**
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/im-server-{0,1,2}.log`
@@ -0,0 +1,515 @@
# IM 单聊压测 Pod 日志根因分析与改进方案
日期:2026-09-27
## 1. 结论
`p2p-20260927-01` 暴露了两个相互独立的 P0 故障:
1. **Tinode 原生 Cluster 转发能力在突发流量下耗尽**,造成 8133 次 publish 拒绝或超时。
2. **Kafka 异步落库写入 Snowflake ID 时与 MySQL 32 位 `INT` 字段不兼容**,造成客户端已 ACK 的消息没有落入 MySQL。
两者都不是 Pod 崩溃、Pod 重启或集群节点断开导致。实时投递接近成功不代表持久化成功,本轮必须同时判定 Cluster 投递和 MySQL 落库不通过。
直接故障链路:
1. 客户端连接所在 Pod 不是 P2P Topic 的 master 时,请求进入 proxy-to-master 转发。
2. 当前每个远端节点只有 1 个同步 RPC 发送协程,队列容量只有 64。
3. 队列满后仅等待 20ms,随即返回 `cluster: load exceeded`。
4. Topic proxy 将该错误转换为 WebSocket `502 cluster unreachable`。
5. 反向投递进入 `hub.routeSrv` 时,4096 容量队列也出现满载;当前 RPC 调用方忽略 `rejected` 结果,存在静默丢弃路径。
因此,本轮 `40.665%` 的发送失败率是 Cluster 内部排队与串行 RPC 的结果。Redis/Kafka 没有参与这条 Topic proxy-to-master 请求路径,不是发送失败的故障点;但 Kafka 消费到 MySQL 的异步持久化链路另有独立故障。
## 2. 压测结果对照
| 指标 | 结果 |
|---|---:|
| WS Ready | 2000/2000 |
| Publish 尝试 | 20000 |
| 服务端 ACK | 11867 |
| 拒绝或超时 | 8133 |
| ACK 成功率 | 59.335% |
| ACK 后应到达 | 11867 |
| ACK 后实际到达 | 11859 |
| 实时丢失 | 8 |
| ACK 后到达率 | 99.9326% |
| 重复/乱序/断连 | 0 / 2 / 1 |
| ACK 平均/最大延迟 | 3.079s / 18.715s |
| 投递平均/最大延迟 | 4.178s / 21.741s |
8 条丢失全部集中在 `B -> A` 用户序号 0959,与 A 机记录到的 1 次 WS 断连同时出现。
### 2.1 MySQL 直接核验
2026-09-27 通过 Pod 当前的只读数据库连接,对租户 `allrich`(`tenant_id=8`)进行了两层核验:
| 核验项 | 结果 |
|---|---:|
| 客户端收到 ACK | 11867 |
| `im_008_messages` 按本次 `client_msg_id` 前缀命中 | 0 |
| `im_008_messages` 按 `content.run_id=p2p-20260927-01` 命中 | 0 |
| `im_messages` 按 `content.run_id=p2p-20260927-01` 命中 | 0 |
| ACK 消息精确匹配 `client_mid + topic + seq` | 0/11867 |
| 本轮已生成 Kafka 死信(核验时点) | 27 |
| 死信错误 | `Error 1264: Out of range value for column 'id'` |
结论:修复前,本轮 ACK 消息的 **MySQL 落库率为 0%**。27 是首次核验时已完成重试并转入死信的数量,不是最终总数;当时其余消息仍位于 Kafka backlog 或处理中。
### 2.2 修复执行与数据恢复结果
2026-09-27 已在本地和测试环境执行 `docs/sql/IM-消息表Snowflake-ID-BIGINT修复SQL.sql` 对应迁移:
- 本地 `meeting_im.im_messages.id` 已由 `INT` 改为 `BIGINT NOT NULL AUTO_INCREMENT`。
- 测试环境 `meeting_im.im_messages` 以及 `im_001_messages` 至 `im_009_messages` 共 10 张表已全部改为 `BIGINT NOT NULL AUTO_INCREMENT`,后置校验异常数为 0。
- 修复后 Kafka backlog 自动恢复落库 11835 条;本轮已有 32 条消息进入死信,通过系统 `kafka.dead_letter.replay` 命令完成重放。
使用原始 ACK ledger 对恢复后的数据库逐条校验:
| 核验项 | 最终结果 |
|---|---:|
| ACK ledger | 11867 |
| 数据库本轮消息行 | 11867 |
| `client_mid + topic + seq` 精确匹配 | 11867 |
| 缺失 | 0 |
| 错配 | 0 |
| 重复 | 0 |
| 拒绝/超时请求意外落库 | 0/8133 |
因此本轮已确认 ACK 消息最终 100% 落库。这里的最终结果是执行字段迁移、等待 Kafka backlog 排空并重放死信后的恢复结果,不代表故障发生时的异步持久化链路正常。
## 3. Kubernetes 现状
| 项目 | 实际值 |
|---|---|
| Namespace | `im-server-system` |
| Workload | `StatefulSet/im-server` |
| 副本 | 3/3 Ready |
| Pod | `im-server-0/1/2` |
| 节点分布 | 3 个 Pod 在 3 个不同 K8s Node |
| 重启 | 全部为 0 |
| 镜像 | 3 Pod 使用相同 digest |
| CPU request/limit | `100m / 500m` |
| Memory request/limit | `128Mi / 256Mi` |
| Cluster 端口 | 12000 |
Pod 没有 OOM/Kill,内存 cgroup 没有 `oom` 或 `oom_kill`。未观察到集群 TCP 断开、`node not connected`、panic 或 fatal。
## 4. Pod 日志证据
压测发送窗口:2026-09-26 18:02:45Z 至 18:03:24Z(北京时间 02:02:45 至 02:03:24)。
`im-server-0` 的可用完整窗口日志中:
| 日志 | 次数 |
|---|---:|
| `route broadcast request from proxy to master failed - cluster: load exceeded` | 2259 |
| `cluster Route: server busy` | 3889 |
| `cluster: node ... not connected` | 0 |
| cluster RPC connection error | 0 |
| panic/fatal | 0 |
`im-server-2` 当前保留的日志从 18:03:19Z 才开始,仅最后约 5 秒已有 496 次 `cluster Route: server busy`。`im-server-1` 当前日志从 18:15:47Z 开始,本次发送窗口已被轮转覆盖。
因此 2259 和 4385(3889+496)都是 **可见日志下界**,不是整个集群的全量。这也是日志量与客户端 8133 次拒绝/超时不完全相等的主要原因之一。
错误从发送开始后立即出现,`im-server-0` 前 10 秒每秒约有 180-226 条 Cluster 过载日志,说明它不是某个后台定时任务引起,而是与本轮 publish 突发直接相关。
## 5. 代码级根因
### 5.1 proxy-to-master 队列过小
`server/cluster.go` 将 `clusterProxyToMasterBuffer` 固定为 64,`clusterP2MTimeout` 固定为 20ms。3 节点时 `len(c.nodes)==2`,扩容逻辑不生效,所以每个远端 peer 实际只有 64 个缓冲位。
队列满后,`proxyToMasterAsync` 在 20ms 后返回 `cluster: load exceeded`。`topic_proxy.go` 随即给客户端返回 502。日志与代码路径一致。
### 5.2 每个 peer 只有一个同步 RPC worker
`p2mSenderLoop` 只启动一次,循环内调用同步 `endpoint.Call`。该 peer 上任何一个较慢的 TopicMaster RPC 都会阻塞后续所有 Topic。在 1000 对用户同时 publish 时,64 队列很快被填满。
### 5.3 接收侧有静默丢弃路径
`Cluster.Route` 在 `globals.hub.routeSrv` 满时设置 `rejected=true`并打印 `cluster Route: server busy`。但 `ClusterNode.route` 只检查 RPC error,传入的 bool 命名为 `unused`,没有检查被远端拒绝的结果。
这意味着发送方可能认为 RPC 成功,但接收方已经因队列满而丢弃消息。本轮 8 条 ACK 后实时丢失与此风险方向一致,但因全量日志已部分轮转,无法将每一条 Client MID 与具体 server-busy 日志逐条对齐。
### 5.4 资源限制会放大排队
每个 Pod 的 CPU limit 只有 500m,同时承担 1000 量级 WS、Topic goroutine、net/rpc、MySQL 持久化和实时投递。当前 cgroup 累计存在 CPU throttling,但没有压测时间窗的 Prometheus 历史样本,所以只能将 CPU 限制定义为高概率放大因素,不将其单独认定为根因。
### 5.5 Kafka 异步落库主键类型不兼容
当前代码在 Kafka 入队前通过 `store.Store.GetUid()` 生成 Snowflake 消息 ID。消费者批量落库时,`TenantMessageSaveBatch` 显式把该 ID 写入 `im_008_messages.id`。
线上实际字段类型为:
| 表 | `id` 类型 |
|---|---|
| `im_messages` | `INT` |
| `im_008_messages` | `INT` |
Snowflake ID 超过有符号 32 位 `INT` 上限,批量插入立即报 1264。代码降级为逐条处理时仍调用同一个批量写入方法,因此不会绕过该错误;达到重试上限后消息进入 `im_kafka_dead_letters`。与此同时,WebSocket 202/ACK 在 Kafka publish 成功后就返回,因而客户端会观察到“ACK 成功、实时广播成功、历史和数据库没有消息”。
这是数据库 DDL 与当前异步持久化代码契约不一致。仓库初始化 SQL 也仍把 `im_messages.id` 定义为 `INT`,因此新环境同样会复现。
## 6. 次要问题
### 6.1 403 permission denied
两台 agent 保留的前 1000 条错误中,A 有 11 条、B 有 24 条 403。多个相同用户序号在 A/B 两侧同时出现,例如 0082、0175、0304、0401、0773。这表明少量 P2P Topic 的发布权限或好友/Topic 初始化状态不一致,与 Cluster 过载是独立问题。
### 6.2 历史回查程序串行阻塞
压测程序对 1000 个 Topic 串行调用 history GET,每次控制应答最长等 20 秒,导致收尾耗时达 13-19 分钟。原始 `history_found=0` 本身不能证明 MySQL 丢数据;本报告新增的 MySQL 直查与 Kafka 死信证据已经独立确认落库失败。
### 6.3 日志保留不足
压测后不到 20 分钟,`im-server-1` 和 `im-server-2` 的关键窗口已部分或全部被容器日志轮转覆盖。没有集中日志时,无法完整重建三 Pod 时间线。
## 7. 改进方案
### P0:修复 Cluster 转发实现
1. 将 cluster buffer、enqueue timeout、worker 数改为可配置项,不再使用 64/20ms 硬编码。
2. 每个 peer 使用有界并发 RPC worker pool。为保证同一 Topic 的顺序,应按 `tenant_id + topic` 做固定分片,同一 Topic 始终进入同一 worker。
3. 首轮建议值:每 peer 16 个 worker、总 buffer 4096、enqueue timeout 200-500ms。该数值必须通过阶梯压测定标,不应作为无上限扩容。
4. 修复 `ClusterNode.route`:检查远端 `rejected` 并返回明确 overload error,禁止静默成功。
5. 对已持久化的 ServerComMessage 建立有界重试(例如 3 次指数退避)。超过重试上限要记录可检索的 tenant/topic/seq/target pod,并由客户端重连后历史同步补齐。
6. 将 overload 与 node unreachable 分开:队列满返回 503/`cluster overloaded`,只有节点断连才返回 502/`cluster unreachable`。
### P0:恢复 Kafka 消息持久化
1. 暂停新的压力流量,避免继续扩大 Kafka backlog 和死信数量。
2. 审核并执行 `docs/sql/IM-消息表Snowflake-ID-BIGINT修复SQL.sql`,把基础表及所有 `im_NNN_messages.id` 改为 `BIGINT NOT NULL AUTO_INCREMENT`。保留 `AUTO_INCREMENT` 是为了兼容 Kafka 关闭时不显式传 ID 的同步写入路径。
3. 变更后先发 1 条带唯一 `client_mid` 的探针消息,确认 Kafka 消费、MySQL 行、Topic seq 和历史查询一致。
4. 再通过现有死信管理接口重放本轮 `message.persist.p2p` 死信;Kafka 中尚未提交的 backlog 会由消费者继续处理。不要直接删除死信或重置 offset。
5. 以本轮 11867 个 ACK ledger 做最终精确核对,要求 `client_mid + topic + seq` 为 11867/11867,重复为 0。
6. 增加启动前 schema contract 检查:Kafka 持久化启用时,若私聊消息基础表或任何租户分表的 `id` 不是 BIGINT,Pod readiness 必须失败并输出明确的表名和字段类型。
7. 增加 `kafka_message_persist_total{result=batch_ok|single_ok|retry|dead}`、消费 lag、死信累计量和最老 backlog age 告警。客户端 ACK 语义应明确为“Kafka 已接收(pending)”,服务端另行提供最终持久化状态;若产品要求 ACK 等于已落库,则必须改为同步确认或增加持久化回执。
### P0:调整部署资源与入口
1. 测试环境首轮将每 Pod request 调整为 `cpu: 1, memory: 512Mi`,limit 调整为 `cpu: 2, memory: 1Gi`;运行阶梯压测后再根据 P95 调整。
2. 核对客户端 Service/Ingress:`sessionAffinity: None`,WebSocket upstream 不使用 `ip_hash`,建议 `least_conn` 或等价的长连接均衡策略。
3. 在压测开始前记录每 Pod `ws_connections_active`,单 Pod 连接数不得超过平均值的 1.3 倍。
4. readiness 增加 `LiveClusterNodes == TotalClusterNodes-1` 和 cluster queue 长时间高水位检查,但不应因瞬时高水位造成 Pod 反复摘除。
### P1:增加可观测性
新增以下 Prometheus 指标,必须带 `pod` 和 `peer` 维度,Topic 只记 hash:
- `im_cluster_p2m_queue_length/capacity`
- `im_cluster_p2m_enqueue_total{result=ok|timeout}`
- `im_cluster_rpc_inflight`
- `im_cluster_rpc_duration_seconds`
- `im_cluster_route_total{result=ok|hub_full|peer_rejected|disconnected}`
- `im_cluster_proxy_response_total{result=ok|channel_full|unknown_topic}`
- `im_ws_connections_active{pod}`
- `im_message_persist_duration_seconds`
告警建议:
- queue 使用率连续 30s 超过 70%:预警;超过 90%:严重。
- `enqueue timeout` 或 `hub_full` 1 分钟内大于 0:严重。
- `LiveClusterNodes` 低于期望值:严重。
- WS 连接不均衡比超过 1.3:预警。
日志应接入 Loki/ELK 等集中系统,至少保留 7 天。限速相同 overload 日志,用计数指标补全被限速的数量,避免大量重复日志反过来抢占 CPU 和 IO。
### P1:修复压测程序
1. history 回查改为 32-64 有界并发,总收尾超时不超过 3 分钟。
2. 分开统计 `publish_502`、`publish_503`、`publish_403`、`publish_ctrl_timeout`、`socket_error`,不再只保留前 1000 条文本错误。
3. 对所有 1000 对用户做发布权限预检,修复 403 后再开始正式计时。
4. 在两台压测机启动前、发送中、收尾后自动抓取 Pod/Redis/Kafka/MySQL 指标快照。
5. 历史落库验证增加 MySQL 直查作为独立通道,以 `tenant_id + topic + client_mid/seq` 分批校验。
## 8. 复测顺序与通过门槛
1. 200 对、500 对、1000 对分三档,每档至少跑 3 轮。
2. 每档同时执行“均匀爬坡”和“同时突发”两种模式。
3. 最终 1000 对场景通过门槛:
- publish ACK 成功率不低于 99.99%。
- ACK 后对端到达率为 100%,重复为 0。
- Cluster enqueue timeout、hub full、peer rejected 均为 0。
- ACK P95 < 500ms,对端投递 P95 < 1s。
- MySQL 落库校验 100%。
- Kafka consumer lag 在发送停止后 60 秒内归零,死信新增为 0。
- 单 Pod CPU P95 < 70%,内存 P95 < limit 的 70%,无持续 CPU throttling。
## 9. 本次调查限制
- 调试 ServiceAccount 可读 Pod/StatefulSet/log,但不可读 Service/Endpoint 和 metrics-server 历史数据。
- `im-server-1/2` 的压测窗口日志已部分轮转,日志次数只能作为下界。
- 未获得当时的 CPU、内存、网络、MySQL、Redis、Kafka 时序监控,不对这些组件的历史利用率做无证据推断。
- Kafka backlog 的精确条数无法通过当前调试 ServiceAccount 直接读取;死信 27 条只代表核验时已经完成失败重试的部分。
即使存在上述限制,现有证据已足以分别确认 Cluster 转发过载和 Kafka 持久化主键溢出。修复并完成死信/backlog 恢复前,本轮数据不能视为已持久化。
## 10. P0/P1 实施状态
2026-09-27 已完成代码与发布配置改造:
- Cluster 使用按 `tenant_id + topic` 固定分片的 16 worker、每 peer 总队列 4096、入队超时 300ms;远端 `hub_full` 有 3 次指数退避重试,过载返回 503,节点断连保持 502。
- TopicMaster 的客户端 publish 接收队列满时会通过 multiplex session 回 503,不再只记录日志后等待客户端超时;同类过载日志每秒限速,完整次数由指标保留。
- Kafka 启用时,启动阶段校验基础私聊表及所有活动租户私聊分表的 `id` 必须为 `BIGINT NOT NULL AUTO_INCREMENT`,不满足时进程不会进入 Ready。
- 增加 Cluster 队列/RPC/路由、WebSocket 连接、Kafka 持久化结果/lag/backlog age/死信及持久化耗时指标和 PrometheusRule 告警。
- `/healthz` 检查全部 Cluster peer,并仅在队列超过 90% 持续 30 秒时摘除 readiness;新增 `/livez` 供 liveness 使用,避免 Cluster 短暂异常触发 Pod 重启。
- 测试环境发布流水线应用 `request=1C/512Mi`、`limit=2C/1Gi`,设置 Cluster 参数、Service `sessionAffinity=None`,并在存在 `Ingress/im-server` 时设置 NGINX `least_conn`。
- 压测程序增加全量 P2P 预检、48 worker 历史回查、3 分钟总超时、publish 错误分类、指标快照阶段标记、P95/ACK 成功率门禁及 MySQL 独立精确核验。
当前调试 kubeconfig 对 StatefulSet/Service/Ingress 没有 patch 权限,因此现存 Pod 资源尚未被本地会话直接修改;以上部署项会在新提交触发有权限的 Gitea deploy runner 后生效。生效后必须按第 8 节执行 200/500/1000 对阶梯复测,不能用单元测试替代容量验收。
## 11. 200 对阶梯复测结果
Run ID:`p2p-200-20260927-01`,A/B 两台压测机各模拟 200 个用户,共 200 对、400 条 WebSocket 连接。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 | 4000 |
| 服务端 ACK | 3890 |
| 403 拒绝 | 110 |
| 502 / 503 / ctrl timeout / socket error | 0 / 0 / 0 / 0 |
| ACK 后应到达 / 实际到达 | 3890 / 3890 |
| ACK 后到达率 | 100% |
| 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
| Kafka P2P `batch_ok` | 3890 |
| Kafka consumer lag / backlog age | 0 / 0 |
| MySQL 精确匹配 | 3890/3890 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
本轮没有再出现 `cluster: load exceeded`、`server busy`、502 或 503,说明 Cluster P0 改造在 200 对档位已生效。Kafka 消费指标和 MySQL 对 ACK ledger 的独立精确查询均确认 3890 条消息已全部落库。
压测报告当时的 `history_found=2213`、`persistence_lost=1677` 不是真实落库丢失。旧实现首轮查询后仅等待 2 秒,再进行两次 2 秒重试,并未持续到配置的 3 分钟截止时间。已改为在 `history_timeout` 内持续重试,并按 Topic 合并缺失区间查询,避免过早判定和大量单条请求。
### 11.1 403 根因与修复
110 次 403 稳定集中在用户序号 `0018`、`0043`、`0065`、`0082`、`0175`、`0183` 的 6 个 P2P Topic,与 Cluster 转发无关。`EnsureP2P` 已将 MySQL 中双方订阅修复为 `JRWPA`,但已加载 Topic 的 `perUser` 仍保留旧权限,因此预检通过后 WebSocket publish 仍被内存态拒绝。
已增加两层修复:
1. HTTP `EnsureP2P` 成功后向 Topic master 发布内部权限刷新事件,原生 Cluster 由 `routeSrv` 路由,非原生多 Pod 模式由 Redis realtime fanout 路由。
2. P2P publish 发现内存态无写权限时,先从 MySQL 重载该用户订阅再判定,作为刷新事件延迟或丢失时的自愈保障。数据库仍无写权限时依然返回 403。
### 11.2 当前未达标项
- ACK P95 和到达 P95 均落在 2 秒直方图上界,未达 500ms / 1s 门槛。
- ArgoCD 已将 StatefulSet 资源回滚为 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍使用 `/healthz`。压测期间存在 CPU throttling 累计,部署配置必须在 GitOps 声明源中固化,否则应用后仍会被回滚。
- 403 修复发布后应重跑 200 对;只有 ACK 成功率、实时到达率、MySQL 精确落库均达标,才进入 500 对档位。
## 12. 200 对第三轮复测结果
Run ID:`p2p-200-20260927-03`。压测前修复了压测工具复用固定 `Idempotency-Key` 而跳过好友关系校验的问题,并重新核对、修复 200 对测试账号。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 / ACK | 4000 / 4000 |
| Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 |
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
| 实时到达率 | 100% |
| 历史查询 | 4000/4000 |
| MySQL 精确匹配 | 4000/4000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| 丢失 / 重复 / 断连 | 0 / 0 / 0 |
| 乱序 | 1 |
| Cluster 队列采样最大值 | 0 |
| Kafka 峰值 lag / 最旧 backlog age / 最终 lag | 3028 / 92.624s / 0 |
结论:跨 Pod 发送、ACK 后实时投递、Kafka 最终消费和 MySQL 落库的数据正确性已通过 200 对验证。结果报告仍为不通过,原因是 B 端记录 1 次单连接 Topic seq 回退,以及 ACK P95 和投递 P95 均落入 1-2s 区间。
资源采样显示,三个 Pod 峰值内存低于 94MiB,但 Pod 0/1/2 在本轮窗口内的 CPU throttled 增量约为 7.62s / 0.78s / 8.51s。实际 StatefulSet 仍是 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍是 `/healthz`。因此延迟和 Kafka 追平速度尚不能作为扩容到 500 对的验收基线。
已增加 `deploy/argocd/im-server-test/statefulset-performance-patch.yaml`,要求 ArgoCD 声明源固化 `request 1C/512Mi`、`limit 2C/1Gi`和 `/livez` liveness。部署流水线改为等待 ArgoCD 回写后严格校验,如果再被回滚则直接失败。压测工具保留乱序为失败门禁,并新增最多 100 条 `receiver/topic_hash/previous_seq/current_seq/client_mid` 明细,用于下轮精确定位。
## 13. GitOps 资源生效后的第四轮复测
Run ID:`p2p-200-20260927-04`。ArgoCD Application `im-server-test` 的真实配置仓库已固化 `request 1C/512Mi`、`limit 2C/1Gi` 和 `/livez`,三个 Pod 完成滚动后均为 Ready、零重启。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 / ACK | 4000 / 4000 |
| Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 |
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
| 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
| 历史查询 | 4000/4000 |
| MySQL 精确匹配 | 4000/4000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| 精确 ACK P50 / P95 / P99 / Max | 368.189 / 566.683 / 725.654 / 1600.975 ms |
| 投递 P95 直方图上界 | 1000 ms |
| Cluster 队列采样最大值 | 0 |
| Kafka message persist 峰值 lag / 归零耗时 | 2946 / 发送结束后 113s |
| Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 usec |
| Pod 0/1/2 峰值内存 | 87.86 / 80.80 / 85.18 MiB |
结论:消息正确性全部通过,第三轮出现的 1 条 seq 回退没有复现。资源扩容完全消除了本轮 CPU throttling,证明旧的 500m CPU limit 是延迟放大因素之一;精确 ACK P95 从第三轮的 1006.288ms 降至 566.683ms,下降约 43.7%,但仍比 500ms 门槛高约 13.3%,因此报告只因 ACK 延迟门禁判定失败。
Kafka `im-server.message_persist` lag 在发送后峰值为 2946,并在发送结束后约 113 秒归零,超过 60 秒目标。历史 API 和 MySQL 最终均确认 4000 条完整落库,说明这是落库延迟而不是消息丢失。进入 500 对测试前,应优先检查 message persist consumer 的批量写入、worker/partition 并行度和 MySQL 写入耗时。
连接集中关闭期间,三个 Pod 分别记录 10、14、22 次 `failed to close login audit ... Deadlock found`,共 46 次。该问题未影响消息表,但会导致登录审计结束状态不完整。登录审计关闭更新应增加 MySQL 1213/1205 有界重试,并检查更新条件和索引以缩小锁范围。
## 14. 持久化并行与审计死锁修复后的第五轮复测
Run ID:`p2p-200-20260927-05`。本轮已将 `im.message.persist` 和 `im.group.message.persist` 固化为 3 分区、3 副本;同一 Topic 继续作为 Kafka key,保证会话内顺序。服务端同时按 batch 内最大 Seq 合并 Topic 和发送者已读位点更新,并为登录审计关闭增加复合索引和 MySQL 死锁重试。
| 指标 | 结果 |
|---|---:|
| WS Ready | 400/400 |
| Publish 尝试 / ACK | 4000 / 4000 |
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
| 丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
| 历史查询 / MySQL 精确匹配 | 4000/4000 / 4000/4000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| 总体精确 ACK P50 / P95 / P99 / Max | 375.314 / 547.516 / 670.317 / 2845.347 ms |
| A 机精确 ACK P95 | 408.155 ms |
| B 机精确 ACK P95 | 594.533 ms |
| Pod 0/1/2 Kafka 峰值 lag | 676 / 251 / 143 |
| 最慢 Kafka 分区归零时间 | 发送结束后约 52.5s |
| Cluster 队列峰值 / CPU throttled 增量 | 0 / 三 Pod 均为 0 |
| 登录审计关闭死锁 | 三 Pod 均为 0 |
| Kafka batch failure / Pod 重启 | 0 / 0 |
结论:三分区已由三个 Pod 实际并行消费,单 Pod 峰值 lag 从第四轮的 2946 降至 676,最慢消费者在 60 秒门槛内归零。消息正确性、最终落库、会话内顺序、CPU 限流和登录审计死锁均通过。
报告仍因 ACK P95 门禁显示失败。直方图只能给出 1000ms 上界,按 4000 条 ACK 原始时间戳和 nearest-rank 口径计算的实际 P95 为 547.516ms。A 机已经低于 500ms,而 B 机为 594.533ms,且 B 机存在 2.845s 最大长尾;两台机器使用相同公共入口和相同服务端版本,因此继续修改服务端前,应先采集 B 机到 `api.118im.com` 的 DNS、TCP、TLS、WebSocket RTT 与时钟偏差,并使用定向 Pod 入口做 A/B 对照,区分公网链路长尾和服务端处理耗时。
## 15. 500 对第一轮容量测试
Run ID:`p2p-500-20260927-01`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。正式发压前补齐了第 201–500 对中缺失的 9 对好友权限,两端预检和 Ready 均通过。
| 指标 | 结果 |
|---|---:|
| WS Ready | 1000/1000 |
| Publish 尝试 / ACK | 10000 / 9946 |
| Publish 失败 | 54(44 条服务端 500/other,10 条 socket error) |
| Publish 成功率 | 99.46% |
| ACK 后应到达 / 实际到达 | 9946 / 9936 |
| 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 |
| 实时到达率 | 99.8995% |
| 总体精确 ACK P95 / Max | 2429.954 / 3636.045 ms |
| 压测期历史回查 | 8580/9946 |
| Kafka 完全排空后 MySQL 精确匹配 | 9946/9946 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
| Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 |
| Kafka 归零时间 | 发送结束后约 4 分钟 |
| Pod 0/1/2 峰值内存 | 148.1 / 130.5 / 122.4 MiB |
| Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 |
| Pod 重启 | 0 |
本轮不通过,不得升级到 1000 对。根因是 MySQL 连接容量与 IM Server 连接池上限不匹配:
- MySQL `max_connections=170`,本轮 `Max_used_connections=172`,`Connection_errors_max_connections=27`。
- IM Server 每 Pod `max_open_conns=64`,3 个 Pod 的理论连接池上限已达 192,还未计入 admin server、其他服务和运维连接。
- 本轮 Pod 0/1 分别记录 153/19 次 `Error 1040: Too many connections`,包括 50 次 `failed to save message`和 44 次 P2P 关系检查失败;这些错误与 44 条服务端 500 同时出现。
- A 机用户 190 的 WebSocket 断开,产生 10 次 socket error,并导致对端已 ACK 的 10 条消息未能实时投递到该连接。
- 压测结束阶段 Pod 2 记录 282 次群 Topic broadcast queue full,发生在大量会话同时清理期,需要与消息发送链路分开处理。
压测阶段的 `history_found=8580`不是最终丢库:历史回查结束时 Kafka 仍在积压,等 lag 归零后从 Pod 内使用 ACK ledger 独立查询 `im_008_messages`,9946 条全部精确匹配。因此当前是“已接受消息最终零丢失,但持久化延迟超标”。
下一轮 500 对复测前必须先完成:
1. 统一规划 MySQL 全局连接预算,覆盖 IM Server、admin server 及其他共库服务;单个服务的连接池上限不能独立设置到总库容量。
2. 修正 IM Server 每 Pod 的 `max_open_conns/max_idle_conns`,或在验证 MySQL CPU、内存、线程和 PXC 限制后提高 `max_connections`;必须保留至少 20% 全局余量。
3. 压测中增加 DB pool `OpenConnections/InUse/Idle/WaitCount/WaitDuration`和 MySQL `Threads_connected/Threads_running/Max_used_connections/Connection_errors_max_connections` 指标。
4. 复测要求 Publish ACK 10000/10000、实时丢失和断连为 0、Kafka 60 秒内归零、MySQL 1040 和清理期 queue full 为 0,然后才能进入 1000 对。
### 15.1 为什么 Kafka 可以积压,但本轮仍会报错
Kafka 积压只能保护“已经成功写入 Kafka”的消息。正常情况下,consumer 落库变慢只会使 lag 上升,消息会在 Kafka 中等待,不应导致 Publish 报错。本轮的 54 条失败发生在 Kafka 之前:
1. 普通文本消息先同步查询青少年模式设置,但当前规则无论查询结果都允许消息继续进入内容审核。
2. P2P 发布在写 Kafka 前调用 `CanSendP2P`,旧实现即使 Redis 已命中好友关系,仍会读取 MySQL 用户和好友记录。
3. 入口校验、Kafka consumer、历史查询和登录审计共用 MySQL 连接预算。连接耗尽后,入口校验直接返回 500,这些消息没有进入 Kafka,因此无法通过“后续慢慢消费”恢复。
### 15.2 IM 侧改造
2026-09-27 已完成以下修复:
- 普通消息不再读取青少年模式表;通话的夜间禁用规则仍保留数据库查询。
- P2P 热路径先查双向黑名单缓存,再使用好友正缓存;缓存未命中或 Redis 异常时才回源 MySQL。新增拉黑会同时清理双向好友正缓存,避免安全状态陈旧。
- `meeting_im` 每 Pod 连接池设为 `max_open=24/max_idle=8`,`hotgo` 注册表库独立设为 `max_open=4/max_idle=2`。3 个 Pod 的 IM 理论总上限由 384 降为 84;结合 Workbench 30、Manager 20 的上限,整套服务理论上限为 134,在当前 MySQL 170 连接上限下保留 36 条全局余量。
- 连接池参数改为环境变量,并同时固化到应用仓库及 ArgoCD 真实配置源。发布流水线会在 ArgoCD 回写后校验四个连接池参数。
- 新增 `im_db_pool_*` 指标,包括 max/open/in-use/idle/wait count/wait duration/关闭计数,并为连接池 85%/95% 使用率配置预警和严重告警。
这些修复的目标是先让入口稳定完成缓存校验和 Kafka enqueue,把 MySQL 慢转化为可观测、可恢复的 Kafka lag,而不是 Publish 500。如果复测时 `im_db_pool_wait_total` 持续增长或 Kafka 仍无法在 60 秒内归零,再根据 PXC 的 CPU/内存/线程容量将 MySQL `max_connections` 持久化提高到 300–400,不使用临时 `SET GLOBAL`代替配置源。
## 16. 500 对连接池改造后复测
Run ID:`p2p500-fix-20260927-02`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。三个 IM Pod 均已使用 `meeting_im=24/8`、`hotgo=4/2` 的连接池配置。
| 指标 | 改造前 | 本轮 |
|---|---:|---:|
| WS Ready | 1000/1000 | 1000/1000 |
| Publish ACK | 9946/10000 | 10000/10000 |
| 服务端 500 / socket error | 44 / 10 | 0 / 0 |
| 实时到达 | 9936/9946 | 10000/10000 |
| 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 | 0 / 0 / 0 / 0 |
| ACK P50 / P95 / P99 / Max | - / 2429.954 / - / 3636.045 ms | 662.062 / 1567.569 / 2620.878 / 3156.025 ms |
| 压测期历史回查 | 8580/9946 | 6738/10000 |
| Kafka 排空后 MySQL 精确匹配 | 9946/9946 | 10000/10000 |
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
| Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 | 2259 / 2875 / 2326 |
| Kafka 最慢归零 | 约 4 分钟 | 发送结束后约 4 分 30 秒 |
| MySQL 1040 | 172 次 Pod 日志记录 | 0 |
| Pod 重启 | 0 | 0 |
结论分为两部分:
1. 入口和实时正确性修复有效。10000 次 Publish 全部被接受,实时投递、最终落库、顺序、重复和断连门禁全部通过;连接池上限把全局 MySQL 连接耗尽转化成了进程内排队,没有再出现 `Too many connections`。
2. 容量门禁仍未通过,暂不升级到 1000 对。ACK P95 仍为 1567.569ms,Kafka 未在 60 秒内归零,压测期历史回查因落库未追平只看到 6738 条。
本轮新增指标进一步定位出两个瓶颈:
- 发送阶段 `hotgo` 注册库连接池出现 5841 次新增等待,累计等待约 210.25 秒。`TenantAdapterFor` 当前在命中已创建的租户 adapter 之前仍执行 `tenantByID`,使共库路由的高频操作重复查询 `im_tenant`;4 条注册库连接因此成为 ACK 热路径瓶颈。
- 发送结束 30 秒后,两台 agent 同时以默认 48 worker 执行历史核验,形成 96 路历史查询。三个 `meeting_im` 连接池随后全部达到 24/24;主库新增 6780 次连接等待,累计等待约 11010.16 秒。历史查询与 Kafka persistence 共用连接池,直接延长了 lag 排空时间。
- Pod 2 有一个 100 条批次因 `context deadline exceeded` 降级为逐条写入,100 条最终均成功;三个 Pod 共记录 8 个数据库超时相关日志,没有死信,最终数据库精确核验为 10000/10000。
下一轮前应完成:
1. 为租户元数据增加带失效机制的进程内缓存;`TenantAdapterFor` 命中有效缓存时不得每次读取 `hotgo.im_tenant`,租户停用或配置变更通过现有配置事件主动失效,并保留短 TTL 兜底。
2. 压测程序把实时发压和持久化核验拆成两个阶段:先观察 Kafka 自然排空,再以每机 8–16 个 worker 执行历史核验,避免核验流量反向干扰被测落库吞吐。同时另设“历史读取并发压测”场景验证读写混合容量。
3. persistence 与在线历史查询至少增加独立并发预算;若继续共用连接池,应为 persistence 保留连接或限制历史查询并发,避免读请求耗尽全部 24 条连接。
4. 完成上述改造后仍以 500 对复测,门禁保持 ACK 10000/10000、实时错误为 0、Kafka 60 秒内归零、最终数据库 10000/10000,再决定是否升级到 1000 对。
### 16.1 路由缓存和分阶段核验改造
2026-09-27 已完成下一轮复测所需代码调整:
- MySQL `TenantAdapterFor` 增加 1 分钟租户路由有效期;有效期内直接复用 tenant adapter,不再读取 `hotgo.im_tenant`。
- 同一租户的缓存刷新使用 singleflight 合并,TTL 到期瞬间最多执行一次注册库查询,避免并发击穿。
- `tenant.updated`、`tenant.status.changed`、`tenant.limits.updated`、`tenant.features.updated` 和 `cache.tenant.invalidate` 继续使用现有 Kafka 广播事件;`InvalidateTenantConfigCache` 现在同时使数据库路由缓存失效。失效与刷新并发时通过 generation 校验阻止旧查询重新写入有效缓存。
- 压测配置新增 `history_settle`,默认 5 分钟。实时 drain 完成后先等待该窗口,再发起历史查询,从而单独测量 Kafka 自然排空时间。
- 历史查询默认并发从每机 48 降为 12;历史读取高并发能力后续使用独立读写混合场景验证,不再污染消息落库吞吐结果。
## 17. 路由缓存发布后 500 对复测与延迟改造
Run ID:`p2p500-r3-20260927-03`。A/B 各 500 条 WebSocket、每人发送 10 条消息,总计 10000 次 Publish。
| 指标 | 结果 |
|---|---:|
| WS Ready | 1000/1000 |
| Publish ACK | 10000/10000 |
| 实时到达 / 丢失 | 10000/10000 / 0 |
| 历史回查 / 落库丢失 | 10000/10000 / 0 |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 总体精确 ACK P95 | 1308.150 ms |
| A / B 精确 ACK P95 | 1121.2 / 1519.2 ms |
| 原报告投递 P95 | 2000 ms(受双机时钟偏差污染) |
| Kafka 最终 lag | 0 |
| `hotgo` pool wait | 0 |
租户路由缓存已经消除 `hotgo` 热路径等待,消息正确性和最终落库全部通过。剩余 ACK 延迟集中在消息 4–6 的突发积压阶段。发压窗口内,`meeting_im` 连接池出现明显等待;同时 Kafka persistence consumer 正在使用同一连接池。Cluster Route RPC 平均约 1–2ms,P2M 队列始终为 0,因此跨 Pod RPC 不是秒级延迟来源。
原投递 P95 不能作为服务端结论:压测程序使用发送机绝对时间戳在接收机计算单向耗时。A 只接受了 5777 个非负样本,而 B 接受了全部 10000 个样本,说明 B 时钟领先 A;A 丢弃负值,B 则把时钟偏差计入延迟。
本次完成以下改造:
1. 压测程序新增同机与跨机两个投递直方图;`delivery_local_p95_ms` 作为门禁,跨机负延迟计入 `cross_machine_negative_latency_samples`,不再静默丢弃后继续生成误导性 P95。
2. 压测消息显式携带 `sender_machine`,旧消息仍可从 `client_mid` 兼容识别来源。
3. 好友关系首次数据库确认后双向回填 Redis,减少双向聊天后续回源。
4. 新增 `im_message_publish_stage_duration_seconds{kind,stage,result}`,覆盖 `relationship_check`、`redis_idempotency`、`redis_sequence`、`kafka_produce` 和整体 `save` 阶段。
5. 新增 `log_client_packets` 配置,默认关闭逐包 INFO 日志,避免每条消息额外 JSON 解析和容器 stdout I/O;需要诊断时才短期开启。
下一轮仍使用 500 对复测。重点比较 `relationship_check`、`kafka_produce` 和 `save` 的 P95,并以 `delivery_local_p95_ms` 判断投递延迟;跨机延迟只有在 chrony/NTP 偏差确认小于 5ms 后才用于验收。数据库连接池暂不扩大,避免再次超过 MySQL 全局 170 连接预算;若 `relationship_check` 已稳定但 pool wait 与 Kafka lag 仍持续增长,再设计 persistence 独立连接预算或批量更新方案。
+29
View File
@@ -0,0 +1,29 @@
# IM 文件存储切换(OSS / MinIO)
IM Server 继续提供 `/v0/file/u/` 上传和 `/v0/file/s/` 鉴权下载接口。客户端不直连对象存储。后台 `hotgo.hg_sys_config` 的 `im_third_party.media_handler` 是存储开关:`oss` 写阿里云 OSS,`minio` 写自建 MinIO。部署配置中的 `media.use_handler` 必须为 `sys_config`(`server/meetingim.conf` 已如此配置);开关在服务启动时读取,修改后滚动重启 IM Server。
## 后台配置
先在 `hotgo` 执行 [IM-MinIO存储配置.sql](IM-MinIO存储配置.sql),再通过后台填写:
| 配置 key | 值或用途 |
| --- | --- |
| `media_handler` | `oss` / `minio`,默认保留现有值 |
| `minio_endpoint` | `https://minio.118im.com`;容器内如有可达的 HTTPS 内网地址,可改用内网地址 |
| `minio_bucket` | `xgjs`,需预先创建 |
| `minio_region` | `us-east-1` |
| `minio_access_key_id` | MinIO Access Key,后台敏感配置 |
| `minio_secret_access_key` | MinIO Secret Key,后台敏感配置 |
阿里云 OSS 的 `oss_*` 配置需继续保留,用于读取切换前的文件。回切 OSS 后,MinIO 的配置也需保留。新写入的 MinIO 对象在文件记录中保存为 `minio://<bucket>/<key>`;原有 OSS 文件位置不改。下载和清理按文件位置选择存储后端,不会因为切换开关迁移已有对象。
## 上线顺序
1. 确认 IM Server Pod 能访问 MinIO S3 API,且 `xgjs` 桶及访问密钥可用;不要使用 Console URL 作为 API endpoint。
2. 执行配置 SQL 并在后台填写密钥;保留 `media_handler=oss`,发布包含 MinIO handler 的 IM Server。
3. 将 `media_handler` 改为 `minio`,滚动重启 IM Server,在客户端做小文件上传、下载、Range 下载、删除和历史 OSS 文件下载验证。
4. 回切时将 `media_handler` 改为 `oss` 并滚动重启;已有 MinIO 文件继续从 MinIO 读取。
MinIO 对象默认按私有文件处理。示例中的直连公网 URL 只有在桶策略允许公开读取时才可访问;IM 客户端应使用服务返回的文件 URL。当前阿里云内容安全接口接受阿里云 OSS 对象参数,MinIO 文件的第三方审核任务需另行接入可用的文件提交方式,不能把 MinIO 对象直接当 OSS 对象提交。
2026-10-17:本地和测试环境的 `hotgo` 均已加入 MinIO 字典与配置项,凭据已分别填入数据库,`media_handler` 仍为 `oss`。MinIO 公网 S3 API 的独立上传、读取、删除冒烟测试已通过;切换后仍需从测试环境 IM Server Pod 验证完整客户端上传链路。
+44
View File
@@ -0,0 +1,44 @@
-- 在 hotgo 后台库执行。只增加配置项,不切换当前存储后端。
USE hotgo;
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM 存储服务商', 'im_storage_provider', 210, 'IM 文件存储 provider 字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_storage_provider');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 'MinIO', 'minio', 'string', 'im_storage_provider', 'info', 0, 20, '自建 MinIO 对象存储', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_storage_provider' AND `value` = 'minio');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Endpoint', 'string', 'minio_endpoint', 'https://minio.118im.com', 'https://minio.118im.com', 22, 'MinIO S3 API 地址;优先使用内网 HTTPS 地址', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_endpoint');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Bucket', 'string', 'minio_bucket', 'xgjs', 'xgjs', 23, 'MinIO 已创建的存储桶', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_bucket');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Region', 'string', 'minio_region', 'us-east-1', 'us-east-1', 24, 'MinIO 签名地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_region');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Access Key', 'string', 'minio_access_key_id', '', '', 25, '敏感配置;后台脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_access_key_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Secret Key', 'string', 'minio_secret_access_key', '', '', 26, '敏感配置;后台脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_secret_access_key');
UPDATE hg_sys_config SET `tip`='IM 文件存储开关:oss 或 minio;修改后滚动重启 IM Server'
WHERE `group`='im_third_party' AND `key`='media_handler';
-- 核验上传、下载、旧 OSS 文件读取后再执行:
-- UPDATE hg_sys_config SET `value`='minio', updated_at=NOW()
-- WHERE `group`='im_third_party' AND `key`='media_handler';
@@ -45,6 +45,8 @@ tenants/{tenantID}/{yyyy}/{mm}/{fileID}
| `verify_bucket` | 启动时是否联网校验 Bucket 存在 | | `verify_bucket` | 启动时是否联网校验 Bucket 存在 |
| `use_sys_config` | 为 `true` 时从 `hotgo.hg_sys_config` 读取 OSS 配置 | | `use_sys_config` | 为 `true` 时从 `hotgo.hg_sys_config` 读取 OSS 配置 |
OSS endpoint 填写 `https://oss-cn-shenzhen.aliyuncs.com`。IM Server 初始化 OSS handler 时也会给裸域名补 HTTPS,并将阿里云 OSS 的显式 HTTP endpoint 改为 HTTPS。修改后台 `im_third_party.oss_endpoint` 后需重启 IM Server;发布后用真实文件上传验证 `POST /v0/file/u/`,确认 Pod 到 OSS 走 443 端口且不再出现 TCP 写超时。若 HTTPS 仍超时,继续检查 Pod 到 OSS 的出网链路。
当前 `server/tinode.conf` 仍默认使用 `fs`。切换 OSS 时,将 `media.use_handler` 改为 `oss`,并在 `media.handlers.oss` 中设置 `use_sys_config=true` 后,运行时会从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取 OSS 配置。 当前 `server/tinode.conf` 仍默认使用 `fs`。切换 OSS 时,将 `media.use_handler` 改为 `oss`,并在 `media.handlers.oss` 中设置 `use_sys_config=true` 后,运行时会从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取 OSS 配置。
代码入口: 代码入口:
@@ -1,6 +1,31 @@
# IM 极光推送前后端对接文档 # IM 极光推送前后端对接文档
更新时间:2026-09-04 更新时间:2026-09-IM API → PUT https://api.118im.com/v1/im/devices/2386C7EB-FC30-4849-9FC8-7F2B50229F02/push-token [req=ab961f26-e3a9-4f5f-b0f6-2813b8af3597]
请求体 {
"sdk_version": "6.2.0",
"push_env": "test",
"app_version": "1.0",
"token": "1517bfd3f69c6928135",
"platform": "ios",
"notification_permission": "granted",
"provider": "jpush"
}
IM API ← 成功 PUT https://api.118im.com/v1/im/devices/2386C7EB-FC30-4849-9FC8-7F2B50229F02/push-token [req=ab961f26-e3a9-4f5f-b0f6-2813b8af3597, status=200]
响应体 {
"code": 0,
"data": {
"app_version": "1.0",
"device_id": "2386C7EB-FC30-4849-9FC8-7F2B50229F02",
"notification_permission": "granted",
"platform": "ios",
"provider": "jpush",
"push_env": "test",
"sdk_version": "6.2.0",
"updated_at": "2026-09-14T09:49:19.139Z"
},
"request_id": "ab961f26-e3a9-4f5f-b0f6-2813b8af3597",
"server_time": "2026-09-14T09:49:19.167Z"
}04
## 1. 对接边界 ## 1. 对接边界
@@ -24,7 +49,11 @@
- iOS:配置 APNs 证书或 Token Authentication,申请通知权限。 - iOS:配置 APNs 证书或 Token Authentication,申请通知权限。
- HarmonyOS:按鸿蒙 SDK 文档初始化并配置通知分类。 - HarmonyOS:按鸿蒙 SDK 文档初始化并配置通知分类。
客户端启动后取得 `registration_id`,登录 IM 后把 alias 绑定为当前 `user_id` 的极光安全别名。同一用户多设备允许绑定同一个 alias。 客户端启动后取得 `registration_id`,登录 IM 后调用 IM 后端设备接口上报 Push Token。当前测试环境服务端配置 `jpush_use_alias=0`,使用 `registration_id` 投递,不依赖 alias。
`device_id` 必须稳定,同一台设备不要每次启动变化。切换账号、切换租户、清登录态后必须重新上报 Push Token;客户端本地的上报去重缓存必须按 `tenant_code + user_id + device_id + registration_id` 隔离,避免设备继续绑定在旧账号上。
如果后续切回 alias 模式,客户端再把 alias 绑定为当前 `user_id` 的极光安全别名。同一用户多设备允许绑定同一个 alias。
极光 alias 不支持中横线 `-` 和下划线 `_`,而 IM `user_id` 可能包含这两个字符。客户端和后端统一使用以下映射: 极光 alias 不支持中横线 `-` 和下划线 `_`,而 IM `user_id` 可能包含这两个字符。客户端和后端统一使用以下映射:
@@ -124,12 +153,14 @@ IM Server 从后台配置读取 `im_jpush` 分组:
"jpush_apns_production": "false", "jpush_apns_production": "false",
"jpush_default_ttl_seconds": "86400", "jpush_default_ttl_seconds": "86400",
"jpush_call_ttl_seconds": "60", "jpush_call_ttl_seconds": "60",
"jpush_use_alias": "true", "jpush_use_alias": "false",
"jpush_test_model": "false", "jpush_test_model": "false",
"jpush_classification": "1" "jpush_classification": "1"
} }
``` ```
测试环境当前使用 `registration_id` 模式,`jpush_use_alias=false`。服务端从 `im_device_push_tokens` 按目标用户查询 `provider=jpush` 的 token,并调用极光 `audience.registration_id`。如改为 alias 模式,需要客户端 SDK alias 绑定回调成功,并保持 alias 映射规则一致。
### 3.2 设备记录 ### 3.2 设备记录
设备表建议至少支持: 设备表建议至少支持:
@@ -185,22 +216,11 @@ Content-Type: application/json
```json ```json
{ {
"platform": "all", "platform": "ios",
"audience": { "audience": {
"alias": ["usr_target"] "registration_id": ["registration_id_from_jpush"]
}, },
"notification": { "notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": { "ios": {
"alert": { "alert": {
"title": "MeetingIM", "title": "MeetingIM",
@@ -228,25 +248,11 @@ Content-Type: application/json
```json ```json
{ {
"platform": "all", "platform": "ios",
"audience": { "audience": {
"alias": ["usr_callee"] "registration_id": ["registration_id_from_jpush"]
}, },
"notification": { "notification": {
"alert": "收到一通视频通话",
"android": {
"title": "视频通话",
"alert": "张三邀请你视频通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "video",
"caller_id": "usr_caller"
}
},
"ios": { "ios": {
"alert": { "alert": {
"title": "视频通话", "title": "视频通话",
@@ -314,8 +320,8 @@ Content-Type: application/json
| 步骤 | 操作 | 验证 | | 步骤 | 操作 | 验证 |
| --- | --- | --- | | --- | --- | --- |
| 1 | 客户端初始化 JPush SDK | 能拿到 `registration_id`。 | | 1 | 客户端初始化 JPush SDK | 能拿到 `registration_id`。 |
| 2 | 登录 IM 并绑定 alias=`user_id.replace("-", ".").replace("_", "|")` | 极光控制台能查到别名或设备。 | | 2 | 登录 IM 后上报 `registration_id` | `im_device_push_tokens` 中 `device_id/token/user_id` 与当前登录用户一致。 |
| 3 | 调用设备上报接口 | 服务端设备记录 provider=`jpush`。 | | 3 | 调用设备上报接口 | 服务端设备记录 provider=`jpush`,并更新到当前登录用户。 |
| 4 | 目标用户退出或断网 | IM 判断为离线或不可 WS 投递。 | | 4 | 目标用户退出或断网 | IM 判断为离线或不可 WS 投递。 |
| 5 | 发送 P2P 消息 | 离线用户收到 Push。 | | 5 | 发送 P2P 消息 | 离线用户收到 Push。 |
| 6 | 点击 Push | App 打开对应会话并补齐 `seq`。 | | 6 | 点击 Push | App 打开对应会话并补齐 `seq`。 |
@@ -323,3 +329,4 @@ Content-Type: application/json
| 8 | 通话取消后点击旧 Push | 客户端查询后显示已取消或未接,不进入通话中。 | | 8 | 通话取消后点击旧 Push | 客户端查询后显示已取消或未接,不进入通话中。 |
| 9 | 关闭会话免打扰 | 普通消息不发 Push,@提醒按策略执行。 | | 9 | 关闭会话免打扰 | 普通消息不发 Push,@提醒按策略执行。 |
| 10 | 极光返回无效 token | 后端禁用设备 token,重新上报后恢复。 | | 10 | 极光返回无效 token | 后端禁用设备 token,重新上报后恢复。 |
| 11 | iOS APNs 环境 | 测试环境 `apns_production=false` 且客户端为开发 APNs;生产包需同步切换为 true。 |
@@ -142,7 +142,7 @@ WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' A
INSERT INTO hg_sys_config INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS Endpoint', 'string', 'oss_endpoint', '', '', 12, '例如 oss-cn-shenzhen.aliyuncs.com,禁止写 AccessPoint endpoint', 1, 1, NOW(), NOW() SELECT 'im_third_party', 'OSS Endpoint', 'string', 'oss_endpoint', '', '', 12, '例如 https://oss-cn-shenzhen.aliyuncs.com;阿里云 OSS 上传使用 HTTPS,禁止写 AccessPoint endpoint', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_endpoint'); WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_endpoint');
INSERT INTO hg_sys_config INSERT INTO hg_sys_config
@@ -0,0 +1,528 @@
# IM 阿里云实名认证接入方案
## 1. 目标
本文记录当前 IM 实名认证能力的实现情况,以及后续接入阿里云实名认证的后端、前端和数据模型改造方案。接入目标是:用户在客户端提交实名信息后,由 IM Server 统一调用阿里云完成身份核验,客户端只感知认证状态和必要的认证跳转参数,不接触阿里云密钥、AccessKey、服务端回调密钥等敏感配置。
阿里云侧建议优先使用金融级实人认证/ID Verification(Cloudauth)能力。产品选型分两类:
1. **姓名 + 身份证二要素核验**:体验最轻,适合只要求姓名和证件号一致性的场景。结果可同步或准同步返回。
2. **增强版实人认证/活体核身**:服务端发起认证并获取 `CertifyId`,客户端通过阿里云 SDK、H5 或认证 URL 完成活体流程,服务端再查询结果或接收回调。适合强实名、风控和合规要求更高的场景。
当前产品原型只有姓名和证件号码输入页,但考虑后续安全要求,后端状态机需要兼容增强版实人认证。
## 2. 当前已有实现
### 2.1 前端对接接口
现有前后端文档已定义实名认证页面的三个核心入口:
| 能力 | 接口 | 当前状态 |
| --- | --- | --- |
| 查询实名状态 | `GET /v1/im/security/realname` | 已实现 |
| 提交实名信息 | `POST /v1/im/security/realname/submissions` | 已实现 |
| 实名更正申诉 | `POST /v1/im/security/realname/appeals` | 已实现 |
查询接口返回:
```json
{
"status": "unverified",
"can_submit": true
}
```
已提交或已通过时返回 `submission_id`、`masked_name`、`masked_id_number`、`verified_at`、`review_reason` 等字段。
提交接口要求:
- 必须携带 `Authorization: Bearer <token>`。
- 必须携带 `Idempotency-Key`,服务端根据用户、租户和幂等键生成 `submission_id`。
- Body 目前为:
```json
{
"name": "张三",
"id_type": "cn_id",
"id_number": "110101199001011234"
}
```
校验规则:
- `name` 长度 2-32 个字符。
- `id_type` 当前只支持 `cn_id`。
- `id_number` 当前只支持 18 位中国大陆身份证号,末位允许 `X`。
### 2.2 服务端实现
当前代码主要在以下位置:
| 模块 | 文件 | 说明 |
| --- | --- | --- |
| HTTP 路由 | `server/main.go` | 注册 `/v1/im/security/realname`、`/realname/submissions`、`/realname/appeals` |
| HTTP Handler | `server/im_http.go` | 查询、提交、申诉逻辑 |
| Store 接口 | `server/store/store.go` | `GetRealName`、`CreateRealNameSubmission`、`CreateRealNameAppeal` |
| MySQL 实现 | `server/db/mysql/tenant_adapter.go` | 租户库读写实名表 |
| 类型定义 | `server/store/types/security.go` | `RealNameVerification`、`RealNameAppeal` |
| 内部后台查询 | `server/internal_admin_query_resources.go` | `realname-verifications` 查询资源 |
当前实名提交成功后只落库为 `pending`,不会调用第三方,也没有自动审核通过。重复提交时,如果最新状态是 `pending` 或 `verified`,服务端会直接返回当前状态,避免重复创建申请。
### 2.3 当前数据表
租户库已有 `im_realname_verifications`:
| 字段 | 说明 |
| --- | --- |
| `id` | 自增主键 |
| `user_id` | 用户 ID |
| `submission_id` | 实名提交 ID,幂等唯一 |
| `status` | 当前状态,已有逻辑使用 `pending`、`verified`、`rejected`、`unverified` |
| `name_cipher` | 姓名保护存储值 |
| `id_type` | 证件类型 |
| `id_number_cipher` | 证件号保护存储值 |
| `id_number_hash` | 证件号哈希,用于重复身份检测 |
| `masked_name` | 脱敏姓名 |
| `masked_id_number` | 脱敏证件号 |
| `review_reason` | 审核原因或驳回原因 |
| `verified_at` | 通过时间 |
| `created_at` / `updated_at` | 创建和更新时间 |
租户库已有 `im_realname_appeals`:
| 字段 | 说明 |
| --- | --- |
| `id` | 雪花 ID |
| `appeal_id` | 申诉 ID |
| `user_id` | 用户 ID |
| `reason` | 申诉原因 |
| `attachments_json` | 附件列表 |
| `status` | 申诉状态,默认 `pending` |
| `review_comment` | 审核备注 |
| `created_at` / `updated_at` | 创建和更新时间 |
### 2.4 已记录的增强项
`docs/IM-未实现后续增强项.md` 和 `docs/IM-需求实现情况审核报告.md` 已明确:实名状态查询、提交、申诉已经具备,但缺少后台审核流转、第三方认证结果回写、审核结果通知和完整申诉处理工单。
当前缺口如下:
| 缺口 | 影响 |
| --- | --- |
| 未接第三方实名供应商 | 提交后长期停留 `pending`,无法自动通过或拒绝 |
| 缺少 provider 字段 | 无法区分人工审核、阿里云二要素、阿里云活体等来源 |
| 缺少第三方任务 ID | 无法通过 `CertifyId` 或外部 request id 查询认证结果 |
| 缺少回调/查询状态机 | 无法安全处理异步认证完成、重试和重复通知 |
| 缺少后台审核动作 | 人工通过、拒绝、申诉处理还没有完整闭环 |
| 缺少结果通知 | 实名通过或拒绝后,还没有统一系统通知事件 |
## 3. 阿里云接入总体方案
### 3.0 当前联调结论
2026-09-20 已按 `docs/third/三方服务依赖信息.md` 中的阿里云 AccessKey 做过真实接口联调:
| 账号 | 调用接口 | 结果 | 结论 |
| --- | --- | --- | --- |
| 子账号 `t1@1624085816137202.onaliyun.com` | `Id2MetaVerify` | `403 Forbidden.NoPermission`,缺少 `yundun-cloudauth:Id2MetaVerify` | 子账号需要 RAM 授权 |
| 主账号 AccessKey | `Id2MetaVerify` | 业务码 `410`,提示“未开通服务” | 当前阿里云账号尚未开通身份证二要素/ID_2META 服务 |
因此代码已接入真实阿里云 API,但生产可用还需要:
1. 在阿里云开通实人认证服务中的身份证二要素核验 `ID_2META`。
2. 给运行时使用的 RAM 子账号授权 `yundun-cloudauth:Id2MetaVerify`,或授予对应实人认证服务策略。
3. 在 `hotgo.hg_sys_config` 的 `im_realname` 分组写入 `realname_provider=aliyun_cloudauth`、`aliyun_access_key_id`、`aliyun_access_key_secret`、`aliyun_region_id`、`aliyun_endpoint`。
4. 租户 `hotgo.im_tenant_config.realname_review_policy=1` 后,IM Server 才会自动调用阿里云;服务未开通、权限不足或返回不一致时都会进入 `manual_review`。
### 3.1 配置来源
实名认证配置分两层:
1. **全局供应商配置**:阿里云 AccessKey、Endpoint、回调密钥等服务商级参数放在 HotGo 控制库 `hotgo.hg_sys_config`,使用独立分组 `im_realname`。
2. **租户审核策略**:审核方式放在 HotGo 控制库的 `im_tenant_config`。该策略每个租户单独控制,默认 `0=不审核`。
全局供应商配置:
| group | key | 建议值 | 说明 |
| --- | --- | --- | --- |
| `im_realname` | `realname_provider` | `aliyun_cloudauth` | 当前供应商 |
| `im_realname` | `aliyun_region_id` | `cn-hangzhou` | 阿里云地域 |
| `im_realname` | `aliyun_endpoint` | `cloudauth.aliyuncs.com` | OpenAPI Endpoint,按实际产品确认 |
| `im_realname` | `aliyun_access_key_id` | 空 | RAM AccessKey ID,禁止下发客户端 |
| `im_realname` | `aliyun_access_key_secret` | 空 | RAM AccessKey Secret,后台脱敏展示 |
| `im_realname` | `aliyun_scene_id` | 空 | 实人认证场景 ID,控制台创建 |
| `im_realname` | `aliyun_product_code` | 空 | 如产品要求传 product code,则配置 |
| `im_realname` | `callback_secret` | 空 | IM Server 校验阿里云回调的本地密钥 |
| `im_realname` | `result_query_interval_seconds` | `10` | 异步查询间隔 |
| `im_realname` | `result_query_max_attempts` | `30` | 最大查询次数 |
| `im_realname` | `mock_enabled` | `0` | 本地/测试环境 mock 开关 |
AccessKey 建议使用最小权限 RAM 子账号,只授权实人认证相关 OpenAPI。生产环境如果后续接入 KMS/Secrets Manager,`hg_sys_config` 中可只保存密钥引用。
租户审核策略使用 `hotgo.im_tenant.realname_review_policy`:
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `realname_review_policy` | `0` | 审核方式:`0=不审核`,`1=自动审核;自动审核失败/异常/不确定时进入人工审核` |
执行规则:
- 租户 `realname_review_policy=0` 时,不做第三方审核,提交实名后写入 `pending`,等待后台人工通过或拒绝。
- 租户 `realname_review_policy=1` 时,IM Server 自动调用阿里云;通过则写 `verified`,失败、异常或结果不确定则写 `manual_review`,等待后台人工复核。
- 阿里云全局配置缺失时,即使租户开启自动审核,也不得阻断系统启动;提交时返回稳定错误,或降级写入 `manual_review` 交给人工审核。
### 3.2 推荐状态机
本地实名状态建议统一为:
| 状态 | 含义 | 前端展示 |
| --- | --- | --- |
| `unverified` | 未提交或无有效记录 | 可提交 |
| `pending` | 已创建本地申请,等待第三方或人工结果 | 审核中 |
| `processing` | 第三方认证流程已开始,等待客户端完成或服务端查询 | 认证中 |
| `verified` | 第三方或人工审核通过 | 已实名 |
| `rejected` | 第三方或人工审核拒绝 | 未通过,可重新提交或申诉 |
| `manual_review` | 第三方结果不确定,需要后台人工处理 | 审核中 |
| `expired` | 客户端未在有效期完成认证 | 可重新提交 |
| `failed` | 第三方调用失败或系统异常 | 可重试 |
对外接口可以继续兼容现有 `unverified|pending|verified|rejected`,把 `processing/manual_review` 展示为 `pending`,把 `expired/failed` 视租户策略映射为 `rejected` 或允许重新提交。
## 4. 两种接入流程
### 4.1 二要素核验流程
适合当前页面只提交姓名和身份证号的产品形态。
1. 客户端调用 `POST /v1/im/security/realname/submissions`,提交姓名、证件类型、证件号。
2. IM Server 完成本地格式校验、幂等校验、重复 pending/verified 校验。
3. IM Server 创建本地 `im_realname_verifications`,状态为 `pending`。
4. 如果租户 `realname_review_policy=0`,流程到此结束,等待后台人工审核。
5. 如果租户 `realname_review_policy=1`,IM Server 调用阿里云二要素核验 API。
6. 阿里云返回一致/不一致/无法验证。
7. IM Server 将本地状态更新为:
- 一致:`verified`,写入 `verified_at`。
- 不一致:进入 `manual_review`,由后台人工复核。
- 无法验证或供应商异常:进入 `manual_review`,由后台人工复核。
8. 客户端继续调用 `GET /v1/im/security/realname` 获取最终状态。
9. 如接入系统通知,服务端投递 `security.realname.updated`。
这个模式对前端改动最小,但实名强度只覆盖姓名和证件号一致性。
### 4.2 增强版实人认证流程
适合要求活体、人脸和证件一致性校验的场景。
1. 客户端调用 `POST /v1/im/security/realname/submissions`,提交姓名、证件类型、证件号,并附带阿里云客户端 SDK 要求的 `meta_info`。
2. IM Server 创建本地申请,状态为 `pending`。
3. IM Server 调用阿里云发起认证接口,例如 `InitSmartVerify` 或当前产品对应的初始化接口。
4. 阿里云返回 `CertifyId`、认证 URL、认证 Token 或 SDK 所需参数。
5. IM Server 保存 `provider_task_id=CertifyId`,并把客户端需要的非敏感参数返回给前端。
6. 客户端调起阿里云 SDK、H5 或认证 URL 完成活体核身。
7. IM Server 通过以下任一方式拿最终结果:
- 阿里云回调 IM Server。
- IM Server 定时调用结果查询接口,例如 `DescribeSmartVerify` 或当前产品对应的查询接口。
8. IM Server 根据结果更新本地状态为 `verified`、`rejected`、`manual_review`、`expired` 或 `failed`。
9. 客户端轮询 `GET /v1/im/security/realname` 或接收系统通知刷新页面。
增强版模式下,客户端仍然不需要知道 AccessKey、AccessKeySecret、回调密钥等服务端凭据。客户端只使用后端返回的 `certify_id`、`certify_url`、`token`、`expires_at` 等短期认证参数。
## 5. 前后端接口建议
### 5.1 提交实名信息
保持现有接口不变:
`POST /v1/im/security/realname/submissions`
二要素模式请求:
```json
{
"name": "张三",
"id_type": "cn_id",
"id_number": "110101199001011234"
}
```
增强版模式请求可扩展:
```json
{
"name": "张三",
"id_type": "cn_id",
"id_number": "110101199001011234",
"meta_info": "{\"zimVer\":\"...\"}",
"return_url": "meetingim://realname/callback"
}
```
二要素模式响应:
```json
{
"submission_id": "rn_xxx",
"status": "verified",
"can_submit": false
}
```
增强版模式响应:
```json
{
"submission_id": "rn_xxx",
"status": "processing",
"provider": "aliyun_cloudauth",
"mode": "smart_verify",
"certify_id": "xxx",
"certify_url": "https://...",
"token": "短期认证参数,如产品返回",
"expires_at": "2026-09-20T10:00:00Z"
}
```
### 5.2 查询实名状态
继续使用:
`GET /v1/im/security/realname`
建议扩展字段:
```json
{
"status": "pending",
"display_status": "pending",
"provider": "aliyun_cloudauth",
"mode": "smart_verify",
"submission_id": "rn_xxx",
"masked_name": "张*",
"masked_id_number": "110***********1234",
"can_submit": false,
"review_reason": "",
"verified_at": null,
"next_action": "wait"
}
```
`next_action` 建议值:
| 值 | 含义 |
| --- | --- |
| `submit` | 可提交 |
| `continue_provider_verify` | 需要继续完成阿里云认证 |
| `wait` | 等待结果 |
| `appeal` | 可申诉 |
| `none` | 无需操作 |
### 5.3 阿里云回调接口
建议新增:
`POST /v1/im/security/realname/providers/aliyun/callback`
要求:
- 只接收阿里云回调。
- 校验签名、时间戳、nonce 或本地 `callback_secret`。
- 根据 `CertifyId/provider_task_id` 幂等更新状态。
- 原始 payload 写入事件表,敏感字段脱敏或加密。
这个接口不直接暴露给客户端。
## 6. 数据库改造建议
### 6.1 扩展实名主表
建议在 `im_realname_verifications` 增加第三方状态字段:
```sql
ALTER TABLE im_realname_verifications
ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT '' AFTER status,
ADD COLUMN provider_mode VARCHAR(32) NOT NULL DEFAULT '' AFTER provider,
ADD COLUMN provider_scene_id VARCHAR(64) NOT NULL DEFAULT '' AFTER provider_mode,
ADD COLUMN provider_task_id VARCHAR(128) NOT NULL DEFAULT '' AFTER provider_scene_id,
ADD COLUMN provider_request_id VARCHAR(128) NOT NULL DEFAULT '' AFTER provider_task_id,
ADD COLUMN provider_status VARCHAR(32) NOT NULL DEFAULT '' AFTER provider_request_id,
ADD COLUMN provider_error_code VARCHAR(64) NOT NULL DEFAULT '' AFTER provider_status,
ADD COLUMN provider_error_message VARCHAR(256) NOT NULL DEFAULT '' AFTER provider_error_code,
ADD COLUMN provider_result_json JSON NULL AFTER provider_error_message,
ADD COLUMN submitted_at DATETIME(3) NULL AFTER provider_result_json,
ADD COLUMN callback_at DATETIME(3) NULL AFTER submitted_at,
ADD COLUMN expires_at DATETIME(3) NULL AFTER callback_at,
ADD COLUMN manual_review_required TINYINT(1) NOT NULL DEFAULT 0 AFTER expires_at,
ADD COLUMN version BIGINT NOT NULL DEFAULT 1 AFTER manual_review_required,
ADD INDEX idx_im_realname_provider_task (provider, provider_task_id),
ADD INDEX idx_im_realname_status_updated (status, updated_at);
```
注意:当前项目已经改为“一个租户一个库”,租户业务表中不再新增 `tenant_id` 字段。
### 6.2 新增第三方事件表
建议新增 `im_realname_provider_events`,用于记录回调、查询结果和异常响应,便于排查和审计:
```sql
CREATE TABLE IF NOT EXISTS im_realname_provider_events (
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
provider_task_id VARCHAR(128) NOT NULL DEFAULT '',
event_type VARCHAR(32) NOT NULL,
provider_status VARCHAR(32) NOT NULL DEFAULT '',
request_id VARCHAR(128) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
payload_json JSON NULL,
error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_realname_events_submission (submission_id, created_at),
INDEX idx_im_realname_events_provider_task (provider, provider_task_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### 6.3 配置 SQL 建议
HotGo 控制库增加全局供应商配置项:
```sql
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '实名认证 Provider', 'string', 'realname_provider', 'aliyun_cloudauth', 'aliyun_cloudauth', 11, '当前实名认证供应商', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='realname_provider');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 RegionId', 'string', 'aliyun_region_id', 'cn-hangzhou', 'cn-hangzhou', 20, '阿里云实人认证地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_region_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 Endpoint', 'string', 'aliyun_endpoint', 'cloudauth.aliyuncs.com', 'cloudauth.aliyuncs.com', 21, '阿里云实人认证 OpenAPI Endpoint,按开通产品确认', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_endpoint');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 AccessKey ID', 'string', 'aliyun_access_key_id', '', '', 22, '敏感配置,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_access_key_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 AccessKey Secret', 'string', 'aliyun_access_key_secret', '', '', 23, '敏感配置,后台脱敏展示,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_access_key_secret');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云认证场景 ID', 'string', 'aliyun_scene_id', '', '', 24, '增强版实人认证场景 ID', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_scene_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '实名认证回调密钥', 'string', 'callback_secret', '', '', 30, 'IM Server 校验回调使用,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='callback_secret');
```
HotGo 控制库增加租户级审核策略。以下写法用于现有库变更,正式发布时可合并到 `im_tenant` 建表 SQL:
```sql
ALTER TABLE hotgo.im_tenant
ADD COLUMN realname_review_policy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '实名审核策略:0不审核,1自动审核且失败转人工' AFTER max_group_members;
```
默认值要求:
- `realname_review_policy=0`,新租户默认不调用阿里云,也不自动通过;提交后进入 `pending`,等待后台人工审核。
- `realname_review_policy=1`,自动调用阿里云;通过写 `verified`,失败、异常、不确定全部进入 `manual_review`。
- 审核策略只影响当前租户,不影响其他租户。
## 7. 后端开发拆分
### 7.1 第一阶段:配置和状态机
1. 新增 `server/realname` 或 `server/identity` 包,封装实名服务。
2. 从 HotGo `hg_sys_config` 读取 `im_realname` 分组供应商配置,并从 `im_tenant_config` 读取当前租户的审核策略。
3. 扩展 `RealNameVerification` 类型和 MySQL adapter 字段。
4. 新增状态更新方法,例如 `TenantRealNameUpdateProviderResult`。
5. 新增 provider 事件表写入方法。
6. 保持现有接口兼容,未配置第三方时仍可创建 `pending` 申请,不影响系统启动。
### 7.2 第二阶段:阿里云 Client
1. 封装 `AliyunRealNameClient`,支持 mock client 和真实 client。
2. 二要素模式实现:提交姓名、证件号,返回通过/拒绝/人工审核。
3. 增强版模式实现:
- 发起认证:调用 `InitSmartVerify`、`InitFaceVerify` 或开通产品对应的初始化 API。
- 查询结果:调用 `DescribeSmartVerify`、`DescribeFaceVerify` 或开通产品对应的查询 API。
4. 记录阿里云 `RequestId`、`CertifyId`、错误码和错误消息。
### 7.3 第三阶段:回调和异步查询
1. 新增阿里云回调 Handler。
2. 校验回调签名和时间窗口。
3. 以 `provider_task_id` 幂等更新实名状态。
4. 对没有回调或回调失败的任务,后台 worker 定时查询结果。
5. 查询超过最大次数后进入 `manual_review` 或 `failed`。
### 7.4 第四阶段:后台审核和通知
1. 完善后台审核接口:通过、拒绝、申诉处理、审核备注。
2. 实名通过/拒绝后发送系统通知。
3. `GET /v1/im/bootstrap` 和 `GET /v1/im/me` 统一读取真实实名状态。
4. 接入审计日志,记录管理员和第三方状态变更。
## 8. 安全与合规要求
1. 客户端不接触阿里云 AccessKey、AccessKeySecret、回调密钥。
2. IM Server 普通日志不得打印姓名、身份证号、阿里云完整返回 payload。
3. 身份证号只保存哈希、脱敏值和必要保护存储值;如必须保存原文,应使用 KMS 或应用层加密,并设置严格读取权限。
4. `provider_result_json` 和事件表 payload 需要脱敏或加密,禁止存储身份证原文、人脸图片、活体照片。
5. 回调接口必须校验签名、时间戳和幂等,避免伪造通过。
6. AccessKey 使用最小权限 RAM 子账号,建议按环境隔离。
7. 后台查询接口需要按 `security_auditor` 等角色授权,敏感字段默认脱敏。
8. 实名通过后的状态变更需要写审计日志,便于追踪。
## 9. 异常处理策略
| 场景 | 建议处理 |
| --- | --- |
| 阿里云配置缺失 | 接口返回稳定错误 `REALNAME_PROVIDER_NOT_CONFIGURED`,或按配置退回人工 `pending` |
| 阿里云超时 | 本地状态保持 `pending/processing`,后台 worker 重试 |
| 阿里云明确不一致 | 状态改为 `rejected`,返回脱敏驳回原因 |
| 阿里云结果不确定 | 状态改为 `manual_review` |
| 用户重复提交 | 如果已有 `pending/processing/verified`,直接返回最新申请 |
| 用户认证中断 | 到期后改为 `expired`,允许重新提交 |
| 回调重复到达 | 根据 `provider_task_id + provider_status` 幂等忽略 |
## 10. 客户端改动点
1. 继续使用当前实名提交和查询接口。
2. 如果提交响应包含 `certify_url` 或 `token`,客户端调起阿里云 SDK/H5 认证流程。
3. 认证完成后回到 IM 页面,调用 `GET /v1/im/security/realname` 刷新状态。
4. 客户端不缓存原始姓名和证件号,只展示后端返回的脱敏值。
5. 对 `processing/pending/manual_review` 统一展示“审核中/认证中”。
6. 对 `rejected/expired/failed` 根据 `can_submit` 和 `next_action` 决定是否允许重新提交或申诉。
## 11. 建议验收点
1. 未配置阿里云时,IM Server 能正常启动,现有实名提交仍可落 `pending`。
2. 配置 mock provider 后,提交实名可以覆盖通过、拒绝、异常和超时场景。
3. 二要素模式下,提交后能自动更新为 `verified/rejected`。
4. 增强版模式下,提交后返回 `certify_id/certify_url`,完成认证后状态能回写。
5. 重复 `Idempotency-Key` 不产生重复申请。
6. 阿里云回调重复投递不重复更新、不重复通知。
7. 日志、系统通知、Push、搜索索引中均不出现身份证原文。
8. 后台能按状态查看实名申请,并能人工处理 `manual_review` 和申诉。
## 12. 参考文档
- 阿里云 OpenAPI 开发者门户:`https://api.aliyun.com/`
- 阿里云实人认证服务 API:`https://api.aliyun.com/product/Cloudauth`
- `InitSmartVerify`:发起增强版认证请求,返回认证请求 ID。
- `DescribeSmartVerify`:查询增强版认证结果。
- `InitFaceVerify` / `DescribeFaceVerify`:按开通产品和接入形态选择的实人认证初始化与结果查询接口。
最终使用哪个 API 名称和参数,以实际开通的阿里云产品、地域、控制台场景配置和 API 门户为准。
@@ -0,0 +1,8 @@
-- IM 阿里云实名认证:租户级审核策略字段
-- 执行库:hotgo
-- 说明:
-- 1. realname_review_policy=0:不自动审核,实名提交进入 pending,等待后台人工审核。
-- 2. realname_review_policy=1:自动调用阿里云审核,通过写 verified;失败、异常、不确定写 manual_review,等待人工复核。
ALTER TABLE hotgo.im_tenant
ADD COLUMN realname_review_policy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '实名审核策略:0不审核,1自动审核且失败转人工' AFTER max_group_members;
@@ -60,9 +60,35 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 | | WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | | 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | | 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
| 用户条款 | `https://{host}/legal/user-terms` | 无需认证、无需 API Key | 登录、注册页通过浏览器或 WebView 打开 |
| 隐私政策 | `https://{host}/legal/privacy-policy` | 无需认证、无需 API Key | 登录、注册及设置页通过浏览器或 WebView 打开 |
HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
#### 0.2.3 用户条款与隐私政策公开页面
客户端不解析或内置协议正文,只保存以下 HTTPS 地址并直接打开:
| 页面 | 通用地址 | 测试/生产域名示例 |
| --- | --- | --- |
| 用户条款 | `https://{host}/legal/user-terms` | `https://api.118im.com/legal/user-terms` |
| 隐私政策 | `https://{host}/legal/privacy-policy` | `https://api.118im.com/legal/privacy-policy` |
两个页面支持 `GET`、`HEAD`,返回 `text/html; charset=utf-8`,不要求 `X-MeetingIM-APIKey`、`X-IM-Schema` 或用户 Token。客户端登录前即可访问;链接应使用系统浏览器或支持 HTTPS 的 WebView 打开,不要把协议正文缓存为业务数据。服务端通过 ETag 和短时公共缓存控制版本刷新,协议更新并重新发布服务后客户端仍使用同一 URL。
客户端不需要硬编码协议域名。`POST /v1/im/tenants/resolve` 的 `data.endpoints` 会同步返回:
```json
{
"legal": {
"user_terms": "https://api.example.com/legal/user-terms",
"privacy_policy": "https://api.example.com/legal/privacy-policy"
}
}
```
登录、注册页点击对应文字时直接打开上述 URL,不附加 Token、API Key 或租户参数,并允许用户滚动、缩放和返回。
WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
```json ```json
@@ -128,14 +154,20 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。
资源不存在类错误统一语义(2026-09-29 修订):
- 好友申请 `accept/reject` 在申请不存在时统一返回 `404/20004 RESOURCE_NOT_FOUND`,两个入口语义一致。
- 群类接口(`DELETE /v1/im/groups/{topic}`、`POST /v1/im/groups/{topic}/leave`、`POST /v1/im/groups/{topic}/qrcodes`、`PUT /v1/im/groups/{topic}/members/me/profile`、`GET/PATCH /v1/im/groups/{topic}/settings` 等经群成员校验的路径)在群不存在时返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散时返回 `410/30004 GROUP_DISSOLVED`;群存在但当前用户非成员仍是 `403/30005 PERMISSION_DENIED`。
- 服务端对不存在的资源不再返回 5xx 或网关错误。
### 0.5 WebSocket 建连、登录和恢复顺序 ### 0.5 WebSocket 建连、登录和恢复顺序
#### 0.5.1 现有服务端可直接执行的冷启动流程 #### 0.5.1 现有服务端可直接执行的冷启动流程
```text ```text
读取客户端预置的 host 和普通 API Key 读取客户端预置的 host 和普通 API Key
-> 用户输入/客户端读取 tenant_code -> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 -> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
-> 带 API Key 完成 WebSocket Upgrade -> 带 API Key 完成 WebSocket Upgrade
-> hi(tenant_code) -> hi(tenant_code)
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 -> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
@@ -147,7 +179,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
-> 进入可发送状态 -> 进入可发送状态
``` ```
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 当前服务端已提供 `/v1/im/tenants/resolve`,用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不把它作为必选前置校验。企业是否存在、是否可用必须在 `{hi}`、登录和注册链路中最终校验。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
`hi` 请求: `hi` 请求:
@@ -210,8 +242,22 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} {"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}
``` ```
**向上翻页(加载更早历史)最终参数约定(后端已确认,2026-09-29)**:复用同一 `get data`,最终参数为 `before`(排他上界,返回 `seq < before`),**没有 REST cursor 语义**。聊天页滚动到顶部时发 `data:{"before":<当前已加载最小 seq>,"limit":50}`(`since` 可省略,默认 0)。服务端返回该锚点之前**最接近 `before` 的 `limit` 条**,`{data}` 按 `seq` 从大到小下发(首条即锚点前最新一条);收到 `ctrl 204 no content`(`params.what=="data"`)表示已到会话顶部。`data` 参数全集:`since`(含下界)、`before`(排他上界)、`limit`、`ranges`。P2P 与群聊(含热缓存 fallback)语义一致。REST `GET /v1/im/conversations/{topic}/messages` 当前为空占位(见 4.21.1),不得用于历史分页。
订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。
##### 0.5.1.1 WebSocket 心跳与在线状态
当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 15 秒发送一次 WebSocket Ping 帧(`pingPeriod=15s`),并期望客户端在 45 秒读超时窗口内返回 Pong(`idleSessionTimeout=45s`);如果没有收到 Pong,或底层读写失败,服务端会关闭当前 Session。
客户端要求:
1. 使用系统 WebSocket 库时,必须确认库会自动回复 Pong;如果库不会自动回复,需要在收到 Ping 时显式发送 Pong。
2. 不要把 `{"note":{"what":"kp"}}` 当作心跳。`kp/kpa/kpv` 只是“正在输入/正在录音/正在视频输入”等 UI 状态通知,不落库,也不能替代 WebSocket Pong。
3. 连接关闭或报错后,客户端本地应立即把 IM 长连接视为离线,并按指数退避重连。重连成功后完整执行 `WebSocket Upgrade -> hi -> token login -> sub me -> sub topics -> get data/del`,用本地最后完整持久化的 `seq + 1` 增量补消息。
4. 服务端收到任意客户端上行 JSON 消息或 WebSocket Pong 后,都会刷新在线状态。当前在线状态写入 Redis,默认 TTL 为 120 秒,用于好友在线状态、群成员在线状态等展示。
5. iOS/Android 进入后台后系统可能挂起 WebSocket。后台场景以推送唤醒和前台重连补偿为主,不要求客户端在后台无限重连。
#### 0.5.2 Long Polling 兼容流程:先取得的是 sid #### 0.5.2 Long Polling 兼容流程:先取得的是 sid
如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。
@@ -284,11 +330,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | | 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` |
| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | | Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | | 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | | 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
**注册 `username` 规则(服务端强校验,2026-10-14 确认)**:3~10 个字符,仅允许小写字母 `a-z` 和数字 `0-9`;不允许大写、下划线、点、汉字及任何符号。不传 `username` 只传 `phone` 时服务端自动生成随机用户名(此时 `secret` 使用 `base64(手机号:密码)`)。违规表现:过短返回 `422 policy violation, params.what=auth`(本次联调实测,用户名 `zz` 即此情形);超长或含非法字符返回 `422, params.what=username, reason=USERNAME_INVALID_FORMAT`。最小长度来自部署配置 `basic` 认证的 `min_login_length`(当前部署模板为 3),服务端代码上限为 10。
##### 0.5.4.2 启动订阅、会话列表和增量恢复 ##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 | | 场景 | 客户端发送 | 服务端返回示例 |
@@ -305,6 +353,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 类型 | 客户端发送 | 服务端返回/下行示例 | | 类型 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- | | --- | --- | --- |
| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | | 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` |
| 引用回复文本 | `{"pub":{"id":"pub-reply-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_reply_1","x-im-reply":{"seq":128,"type":"text","text":"今天晚上一起吃饭吗?","from":"usr..."}},"content":{"text":"好啊"}}}` | `x-im-reply` 必须保留在 `head` 中;服务端不解析业务语义,入库、实时下行和历史读取均原样返回 |
| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | | 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` |
| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | | 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` |
| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | | 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` |
@@ -316,14 +365,14 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
##### 0.5.4.3.1 会话内系统提示和可点击动作消息 ##### 0.5.4.3.1 会话内系统提示和可点击动作消息
截图中的灰色提示、入群邀请说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。 截图中的灰色提示、群内邀请确认说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。发给被邀请人的“加入群聊”邀请卡片不写入邀请人与被邀请人的 P2P 会话,展示以 `GET /v1/im/system-notifications` 返回的系统通知记录为准。
客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。 客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。
| 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 | | 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 | | `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 |
| `system_action` | 群邀请确认说明、审批入口、群设置入口、可点击人名或群名提示 | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_invite_confirm_enabled","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_invite_confirm_enabled","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[{"type":"open_group_invite_settings","label":"群聊邀请确认","target_id":"grp..."}]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染 | | `system_action` | 审批入口、群设置入口、可点击人名或群名提示;仅在当前消息对所有接收者都可操作时下发 `actions` | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_member_joined","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_member_joined","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染。若 `actions` 为空,不得将普通文案渲染为可点击入口 |
`content` 字段约定: `content` 字段约定:
@@ -333,7 +382,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` | | `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` |
| `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 | | `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 |
| `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 | | `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 |
| `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作 | | `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作。群邀请确认开关、全员禁言开关、成员私信权限开关等设置变更公告面向全员广播,服务端不下发仅管理员可操作的 `actions`;客户端不得仅凭模板名高亮入口。若历史消息或兼容字段携带 `min_role`,客户端必须按当前成员角色过滤,角色不足时忽略该 action |
| `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 | | `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 |
`actors[].click` 和 `actions[]` 的 `type` 首版白名单: `actors[].click` 和 `actions[]` 的 `type` 首版白名单:
@@ -342,7 +391,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `user_profile` | `target_id` | 打开用户资料页 | | `user_profile` | `target_id` | 打开用户资料页 |
| `group_profile` | `target_id` | 打开群资料页 | | `group_profile` | `target_id` | 打开群资料页 |
| `open_group_invite_settings` | `target_id` | 打开群邀请确认设置 | | `open_group_invite_settings` | `target_id` | 打开群邀请确认设置;仅用于明确发给管理员/群主或全员都可执行的入口,不用于全员广播的开关结果公告 |
| `open_group_join_requests` | `target_id` | 打开群入群审批列表 | | `open_group_join_requests` | `target_id` | 打开群入群审批列表 |
| `open_message` | `topic,seq` | 定位到指定消息 | | `open_message` | `topic,seq` | 定位到指定消息 |
@@ -357,6 +406,16 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` | | 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` |
| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` | | 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` |
`kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。
**单聊已读/已收回执协议约定(服务端已验证,2026-09-29):**
1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。
2. **离线补发形态**:接收方离线时收到 `{"info":{"topic":"me","src":"p2p...","from":"usr...","what":"recv","seq":12}}`,即 `topic` 固定为 `me`、`src` 才是话题名。客户端必须同时兼容上述两种形态。
3. **序号单调递增**:服务端丢弃 `seq` 不大于当前水位的回执(`read`、`recv` 各自独立判断),`read` 回执会同步抬升 `recv`,恒有 `read ≥ recv`。客户端渲染一律取服务端最大游标,禁止用本地计数覆盖服务端值。
4. **断线重连恢复**:`sub`/`get desc` 响应的 `desc` 中包含 `seq`、`read`、`recv`、`clear` 四个游标;`get {"what":"sub"}` 也返回各成员的 `read/recv`。重连后以这些游标恢复本地状态,不要依赖进程内存。
5. **换设备登录**:回执水位按「用户 × 话题」在服务端持久化,与设备无关;新设备订阅后 `desc` 同时返回自己与对端的最新 `read/recv`,可完整恢复对方"已读/已收"状态。发送者自身的 `read/recv` 由服务端自动推进到其最新发出的 `seq`。
##### 0.5.4.5 Topic 资料、个人群设置和标签 ##### 0.5.4.5 Topic 资料、个人群设置和标签
| 场景 | 客户端发送 | 服务端返回/下行示例 | | 场景 | 客户端发送 | 服务端返回/下行示例 |
@@ -398,7 +457,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"event": "group.member.joined", "event": "group.member.joined",
"payload": { "payload": {
"topic": "grp_xxx", "topic": "grp_xxx",
"user_id": "usr_xxx" "user_id": "usr_xxx",
"member_sync": "single",
"visibility": "all_members"
}, },
"ts": "2026-08-25T10:00:00Z" "ts": "2026-08-25T10:00:00Z"
} }
@@ -415,7 +476,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 | | 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 | | 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 | | 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 | | 单聊消息变更脏标记 | P2P 双方在线会话 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"message.mutation","payload":{"topic":"p2p...","seq":12,"version":2,"event":"message.recalled"}}}` | 当前未订阅该 P2P 也要标记会话脏,并调用 `message-mutations` 做补偿同步 |
| 成员加入 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"user_id":"usr_xxx","member_sync":"single","visibility":"all_members"}}}` | 全体成员可见;调用 `GET /v1/im/groups/{topic}/members/{user_id}` 刷新单个成员。加入事件同时写入群消息,离线后可按 Topic seq 补拉 |
| 成员退出/移除、角色或禁言变化 | 群主、管理员和被操作人 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.left","payload":{"user_id":"usr_xxx","member_sync":"remove","visibility":"admins_and_affected"}}}` | 普通成员不可见;`single` 拉取单成员,`remove` 本地删除,`full` 重新分页拉取全部成员 |
| 其他群业务变化 | 按具体事件授权范围 | `info.what="group"` | 刷新群详情、入群申请、公告或治理权限 |
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 | | 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 | | 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
@@ -453,16 +517,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
客户端侧要求: 客户端侧要求:
- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。 - App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。
- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。 - 当前测试环境使用 `registration_id` 模式,服务端配置 `jpush_use_alias=0`,不依赖客户端 alias;alias 绑定可保留但不是投递依据。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。 - SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。`device_id` 必须稳定,同一台设备不要每次启动变化。
- 切换账号、切换租户、清登录态后必须重新上报 Push Token;客户端本地的 `user_id/token/registration_id` 去重缓存必须按 `tenant_code + user_id + device_id + registration_id` 隔离,避免把设备继续绑在旧账号上。
- 若后续切回 alias 模式,登录 IM 后将极光 alias 绑定为当前 `user_id` 的安全别名:`user_id.replace("-", ".").replace("_", "|")`,例如 `usrd6Ag5opa-ww` 绑定为 `usrd6Ag5opa.ww`;同一用户多设备允许绑定同一个 alias。
- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。 - 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。
服务端侧要求: 服务端侧要求:
- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。 - `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。
- 当前测试环境按 `registration_id` 投递:服务端从 `im_device_push_tokens` 按目标 `user_id + provider=jpush` 查询设备 token,并调用极光 `audience.registration_id`。
- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。 - 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。
- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。 - 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。
- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。 - Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。
- iOS 测试环境固定使用 `options.apns_production=false`;客户端包也必须是开发 APNs 环境。TestFlight/App Store 生产包需要后端与客户端同时切到生产 APNs。
极光 Push extras 统一格式: 极光 Push extras 统一格式:
@@ -481,7 +549,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 | | `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 | | `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 |
| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 | | `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id,caller_display,expires_at` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 | | `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | | `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | | `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
@@ -491,22 +559,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json ```json
{ {
"platform": "all", "platform": "ios",
"audience": { "audience": {
"alias": ["usr_target"] "registration_id": ["161a3797c95ee4a8bd3"]
}, },
"notification": { "notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": { "ios": {
"alert": { "alert": {
"title": "MeetingIM", "title": "MeetingIM",
@@ -534,25 +591,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json ```json
{ {
"platform": "all", "platform": "ios",
"audience": { "audience": {
"alias": ["usr_callee"] "registration_id": ["161a3797c95ee4a8bd3"]
}, },
"notification": { "notification": {
"alert": "收到一通语音通话",
"android": {
"title": "语音通话",
"alert": "张三邀请你语音通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
},
"ios": { "ios": {
"alert": { "alert": {
"title": "语音通话", "title": "语音通话",
@@ -560,6 +603,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
}, },
"sound": "default", "sound": "default",
"badge": "+1", "badge": "+1",
"category": "im_call",
"content-available": 1,
"extras": { "extras": {
"type": "call_invite", "type": "call_invite",
"tenant_code": "default", "tenant_code": "default",
@@ -567,18 +612,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"seq": "30", "seq": "30",
"call_id": "call_01K...", "call_id": "call_01K...",
"call_type": "audio", "call_type": "audio",
"caller_id": "usr_caller" "caller_id": "usr_caller",
"caller_display": "张三",
"expires_at": "2026-09-18T10:00:30Z"
} }
} }
}, },
"options": { "options": {
"time_to_live": 60, "time_to_live": 30,
"apns_production": false, "apns_production": false,
"classification": 1 "classification": 1
} }
} }
``` ```
来电推送必须走普通 APNs/极光 alert 通知,不使用 PushKit VoIP topic;iOS `notification.ios.category` 固定为 `im_call`,用于客户端通知按钮,同时带 `content-available: 1` 唤醒后台 App 拉取 `topic/seq` 并启动 Live Activity。`time_to_live` 和 `expires_at` 以 30 秒左右短有效期为准,过期后服务端不再投递。
#### 0.5.5 Long Polling 与文件基础示例 #### 0.5.5 Long Polling 与文件基础示例
##### 0.5.5.1 WebSocket 与文件基础接口 ##### 0.5.5.1 WebSocket 与文件基础接口
@@ -587,10 +636,17 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` | | `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` |
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | | WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | | `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | | `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;聊天附件必须带 `topic=<消息所在会话 topic>`(`p2p.../grp...`),可选 `purpose=chat_file`、`id=<幂等ID>` | `{"ctrl":{"code":200,"params":{"expires":"2026-10-13T06:21:20Z","file_id":"s6yhMyiOfaU","url":"/v0/file/s/s6yhMyiOfaU.txt"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | | `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-`;URL 可带文件扩展名(服务端自动剥离) | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 |
**文件上传/下载鉴权与回收规则(已实现,客户端必须遵守):**
1. 上传聊天附件(图片/视频/语音/文件)必须携带 `topic=<消息将要发送到的会话 topic>`。服务端校验上传者是该 `p2p`/`grp` 会话的订阅成员(`modewant`/`modegiven` 含读权限),不是成员返回 `403 permission denied`;校验通过则在服务端建立「文件-会话」引用,该会话全体成员凭有效 Token 即可下载。
2. 不带 `topic` 的上传只有上传者本人可下载。任何文件若一直未被任何会话/消息引用,服务端在创建 1 小时后由后台任务物理回收;上传后必须及时发送消息或携带 `topic`,否则对端将拿到 404。用户头像/资料类文件不受此限:客户端设置头像(PATCH `avatar_file_id`)时服务端自动写入文件引用,该文件不会被回收。
3. 下载放行范围(2026-10-14 更新):上传者本人、文件所属会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、**用户头像/资料文件(对任何登录用户放行,2026-10-14 修复:此前仅上传者可读,群成员拉取他人头像 403)**。非授权返回 `403 permission denied`(即 `FILE_FORBIDDEN`),客户端不自动重试。
4. `avatar_url` 统一回填规则(2026-10-14 起,所有返回用户/群摘要的接口一致):`avatar_file_id` 为完整 URL 时 `avatar_url` 原样返回;为裸 file_id 时返回 `"/v0/file/s/{file_id}"`(可直接下载,无需客户端拼接);头像为空时回落默认头像 URL(男/女按性别,`avatar_file_id` 与 `avatar_url` 同值)。涉及接口:`/v1/im/groups/{topic}/members`、`/members/{userId}/profile`、`/v1/im/users/{id}/profile`、`/v1/im/me/profile`、转发目标列表等。
### 0.6 Topic 与产品消息 ### 0.6 Topic 与产品消息
@@ -617,6 +673,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 `x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
引用回复使用 `head.x-im-reply` 承载客户端生成的轻量快照,字段为 `seq:number`、`type:string`、`text:string`,可选 `from:string`。该对象必须放在 `head`,不能放入 `content`;服务端只负责把未知 `head` 字段随消息存储、实时转发、历史拉取和 HTTP 查询一起原样返回,不对被引用消息做二次查询或语义校验。客户端兼容历史服务端将 `x-im-reply` 作为 JSON 字符串返回的情况,但新版本客户端发送时必须使用 JSON 对象。
消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。 消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。
### 0.7 HTTP 幂等、重试和安全 ### 0.7 HTTP 幂等、重试和安全
@@ -633,22 +691,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/企业码登录.png" alt="企业码登录" width="360"> <img src="images/企业码登录.png" alt="企业码登录" width="360">
**状态:企业解析接口与 WebSocket 租户握手均已实现。** **状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
**当前系统的执行方式:** **当前系统的执行方式:**
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 - 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 - 客户端从构建/环境配置读取 `host` 和普通 API Key,直接使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket;当前登录/注册主流程不要求先调用 `POST /v1/im/tenants/resolve`。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 - Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"登录/注册页确定的 tenant_code"}}`。
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 - 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 - 当前失败表现:企业码超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。登录/注册页统一展示“企业不存在或不可用”。
**企业解析接口:** **企业解析接口:**
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](IM-个人无企业码注册前后端对接说明.md)。
成功响应示例: 成功响应示例:
@@ -658,7 +717,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"data": { "data": {
"tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}},
"endpoints": { "endpoints": {
"websocket": "wss://im.example.com/v0/channels", "websocket": "wss://im.example.com/v0/channels?apikey=AQ...",
"api": "https://im.example.com/v1/im", "api": "https://im.example.com/v1/im",
"file": "https://im.example.com/v0/file" "file": "https://im.example.com/v0/file"
}, },
@@ -780,13 +839,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** **状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 - 前端动作:企业码、账号、密码和协议勾选在同一个登录页完成;个人用户可不填企业码,企业用户填写企业码。账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 - 协议:点击登录后先按页面企业码确定 `tenant_code` 并发送 WebSocket `{hi}`;`hi` 成功后发送 `{login scheme:"basic"}`,`secret=base64(username:password)`。Base64 不是加密,必须通过 WSS 传输。同一次登录不得先登录再切换企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 - 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 - 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 - Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 - 错误:企业不存在、停用或不允许登录统一显示“企业不存在或不可用”;`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并留在登录页重新选择个人/企业身份;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
登录成功示例: 登录成功示例:
@@ -823,19 +882,21 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/注册页面.png" alt="注册页面" width="360"> <img src="images/注册页面.png" alt="注册页面" width="360">
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** **状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 - 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 - 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 - 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 - 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 - 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 - 旧版独立 `invite_code` 字段仅保留协议兼容,新客户端不得再展示独立邀请码输入框。
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 - 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 - 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。
- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 - 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 - 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `tel credential`,同时写入邀请码推荐使用关系。
- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 - 提交注册:`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 - 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 - 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 - 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
@@ -843,19 +904,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
用户名方式: 用户名方式:
```json ```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} {"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}}
``` ```
手机号验证方式: 手机号验证方式:
```json ```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} {"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
``` ```
仅手机号注册方式: 仅手机号注册方式:
```json ```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} {"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","registration_type":1,"secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
``` ```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 - 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 - 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
@@ -891,11 +952,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 - 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`;注销账号使用 `{"scene":"delete_account","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 - 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 - 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式接口通过 Header `X-Action-Token` 携带。 - 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式、群主转让、解散群聊接口通过 Header `X-Action-Token` 携带。
- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 - 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
### 1.8 实名认证.png ### 1.8 实名认证.png
@@ -917,7 +978,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 - 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。
- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 - 前端只读展示,不缓存原始证件信息,不提供直接修改接口。
- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 - 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,Body `{"reason":"...","attachments":[]}`(`reason` 必填、≤500 字;`attachments` 最多 9 个 file_id),Bearer 即可,当前无需二次身份验证;不复用本页保存按钮。
### 1.10 实名认证(安卓).png ### 1.10 实名认证(安卓).png
@@ -955,18 +1016,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` | | `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` | | `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` | | `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` | | `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` | | `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 邀请码 | `{"code":"AbCdE"}` | `{"verification_mode":"invite_code","invite_accepted":true}` |
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | | `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | | `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | | `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` | | `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
| `GET /v1/im/app/releases/latest?platform=ios\|android&current_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","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}` | | `GET /v1/im/app/releases/latest?platform=ios\|android\|web&current_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"web","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"","release_notes":"修复已知问题。","remind_after_seconds":86400}` |
## 2. 消息首页、全局导航与系统通知 ## 2. 消息首页、全局导航与系统通知
@@ -1020,8 +1082,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:需改造。** **状态:需改造。**
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 - `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
- `summary` 为服务端生成的可直接展示文案(如「对方已同意你的好友申请」「对方已婉拒你的好友申请」),前端直接渲染,无需再做状态码到文案的映射;`action` 读路径统一归一为 `{type,target_id,payload,...原始键}`。
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 - 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;重复已读返回首次持久化的 `read_at`,不回显本次提交值。跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例 ### 2.6 接口示例
@@ -1040,7 +1103,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | | `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","target_id":"usr...","payload":{"request_id":"frq_1"},"request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
## 3. 单聊会话、会话建立与消息状态 ## 3. 单聊会话、会话建立与消息状态
@@ -1099,11 +1162,14 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/双方沟通状态.png" alt="双方沟通状态" width="360"> <img src="images/双方沟通状态.png" alt="双方沟通状态" width="360">
**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** **状态:上传带 `topic` 与下载按会话授权已在服务端实现;客户端按本节接入。**
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 - 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传(**必须带 `topic=<该会话 topic>`**,服务端校验成员资格并自动建立文件-会话引用),得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 - `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 - 服务端在上传阶段即建立文件与会话的引用(带 `topic`);消息发布成功后按消息附件引用建立消息级 ACL;文件下载校验会话成员身份与消息可见性。
- 媒体文件(缩略图、原图、视频、语音)一律走同一次上传流程并带同一个 `topic`;缩略图也必须挂到会话,否则对端只能看到占位图。
- 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
### 3.7 个人对话—拉黑状态/已读状态.png ### 3.7 个人对话—拉黑状态/已读状态.png
@@ -1257,7 +1323,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:客户端任务管理新增;服务端上传需改造。** **状态:客户端任务管理新增;服务端上传需改造。**
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。 - 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;`upload_id` 仅在单次上传任务内有效(进度上报与本地去重用)。
- 重试即新建上传任务:`progress` 归零、生成新 `upload_id`,**整文件重传** `POST /v0/file/u/`,不做分片/断点续传;重传成功后消息体 `file_id` 等字段全部取最新一次上传响应。
- 自动重试采用指数退避且不超过 3 次,之后转人工点按重试;App 冷启动扫描本地 `failed` 任务队列重新上传。
- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。 - 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。
- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。 - 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。
@@ -1344,8 +1412,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":12}],"target_topics":["grp..."],"comment":"..."}`。`seq` 兼容数字和字符串;P2P 源会话也可以传对方 `usr...` 用户 ID,服务端会归一化为 P2P topic。 - `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":12}],"target_topics":["grp..."],"comment":"..."}`。`seq` 兼容数字和字符串;P2P 源会话也可以传对方 `usr...` 用户 ID,服务端会归一化为 P2P topic。
- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 - 返回每个目标和源消息的 `client_mid/seq/status`;`status` 取值为 `sent`、`pending`、`failed`。`pending` 表示服务端已分配 `seq/client_mid`,但 Kafka 投递结果暂不确定,客户端应按“发送中”展示,保留同一个 `Idempotency-Key` 重试或等待后续消息同步,不要立即生成第二条本地消息。服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。
- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 - 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送,返回项可带 `comment_client_mid/comment_seq/comment_status/comment_reason`;转发消息本身仍保持原 `head/content`,不得把留言包装进图片、文件等原消息 `content`。
### 4.20 转发—合并转发—多选状态.png ### 4.20 转发—合并转发—多选状态.png
@@ -1354,6 +1422,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。 - 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。
- `items[]` 和 `preview[]` 保留 `from` 原发送人 ID,并新增 `from_user` 用户摘要:`{"user_id":"usr...","display_name":"张三","avatar_file_id":"file_avatar"}`。头像为空时可缺省;若资料查询失败,服务端至少返回 `user_id/display_name` 兜底。
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 - 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 - 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
@@ -1372,10 +1441,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` | | `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` |
| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | | `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | | `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | | `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | | `GET /v1/im/conversations/{topic}/messages` | **服务端当前为空占位**:GET 恒返回空列表,勿用 `?since=&limit=` 调它;历史消息(首屏与向上翻页)一律走 WS `get data` 的 `since`/`before`/`limit`(向上翻页见 0.5.1 约定) | `{"topic":"p2p...","items":[],"next_cursor":"","has_more":false}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` | | `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
@@ -1549,7 +1618,32 @@ Body:
#### 4.21.3 消息变更同步水位 #### 4.21.3 消息变更同步水位
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。 撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;单聊中如果客户端在线但当前没有订阅该 P2P,服务端会额外向双方用户通道下发 `message.mutation` 脏标记事件;离线或错过实时事件时,需要靠会话级水位补偿。
单聊未订阅脏标记示例:
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "message.mutation",
"payload": {
"topic": "p2pJaOWxMvALjB4i1K_h_NZgQ",
"seq": 12,
"version": 2,
"event": "message.recalled"
}
}
}
```
收到 `message.mutation` 后不要直接改本地消息内容,先把 `payload.topic` 标记为脏,再调用:
```http
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
```
会话列表同步时,`me.meta.sub[].private` 会带: 会话列表同步时,`me.meta.sub[].private` 会带:
@@ -1604,6 +1698,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `mutation_version` 是会话级水位,不是单条消息的 `version`。 - `mutation_version` 是会话级水位,不是单条消息的 `version`。
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。 - 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
- 补偿接口返回的是最新 `limit` 条(默认 50、上限 100)变更的**状态快照**,非严格增量;变更条数超过 `limit` 时更早的变更不再返回。撤回窗口 120 秒、编辑窗口 300 秒,超窗变更无需客户端处理。
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。 - 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。 - 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。 - 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
@@ -1616,7 +1711,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增产品通讯录接口。** **状态:新增产品通讯录接口。**
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 - 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,avatar_url,initial,online,online_visibility`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。
- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 - 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。
- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 - 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1630,6 +1725,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 申请不存在时 `accept/reject` 统一返回 `404/20004 RESOURCE_NOT_FOUND`;通用语义见 0.4。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 - 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png ### 5.3 添加好友.png
@@ -1678,7 +1774,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。 - `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,avatar_url,gender,region,public_account,signature,relationship,capabilities`(`avatar_url` 为可直接下载的头像地址,统一回填规则见 0.5.5.1 第 4 条);`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 - 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1747,9 +1843,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/黑名单提示.png" alt="黑名单提示" width="360"> <img src="images/黑名单提示.png" alt="黑名单提示" width="360">
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** **状态:图片文件名与画面语义不一致;按“清除聊天记录”实施。服务端已实现并实测(2026-10-09),剩余前端改造。**
- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 - 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 服务端现状(2026-10-09 测试环境实测通过):REST 接口仅写 `im_dellog` 本人区间并递增 `deleted_id`,不改动消息行、对方视角不受影响,本人其他设备经 `deleted_id` 同步感知清空;WS `{del}` 语义相同,两条路径二选一即可。对非成员或不存在会话返回 403 FORBIDDEN。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1775,7 +1872,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | | `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | | `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | | `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | | `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
@@ -1813,7 +1910,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增邀请卡片和加入接口。** **状态:新增邀请卡片和加入接口。**
- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。 - 邀请卡展示以群邀请接口返回和系统通知记录为准;系统通知 `event_type=group.invitation.created`,`action.type=group_invitation_accept`,`action.invitation_id` 用于点击加入,`action.topic` 为目标群。相关实时刷新使用 `info.what="group"` 群业务事件。
- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
@@ -1833,7 +1930,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 - `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`;申请人展示字段直接带 `display_name/avatar_file_id/user`,邀请人展示字段带 `inviter_display_name/inviter_avatar_file_id/inviter`。
- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。 - 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。
- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 - 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。
@@ -1884,6 +1981,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 - `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。
- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 - 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。
- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 - 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.11 群资料.png ### 6.11 群资料.png
@@ -1913,7 +2011,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 - `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 - 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。
- 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。
### 6.13.1 群成员列表拉取与缓存策略 ### 6.13.1 群成员列表拉取与缓存策略
@@ -1980,6 +2080,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 - `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群主/管理员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
@@ -1992,6 +2093,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 - 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。
- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 - 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。
- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 - 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;已解散重复解散返回 `410/30004 GROUP_DISSOLVED`;非群主返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.18 设置群管理员.png ### 6.18 设置群管理员.png
@@ -2096,9 +2198,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:需改造。** **状态:需改造。**
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 - 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。
- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 - 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例 ### 6.29 接口示例
@@ -2119,18 +2221,20 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` | | `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` |
| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` | | `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` |
| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` | | `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` |
| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` | | `DELETE /v1/im/groups/{topic}` | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true,"status":"dissolved","version":2}` |
| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` | | `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` |
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | | `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | | `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | | `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` |
| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | | `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | | `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | | `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` | | `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` |
| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` | | `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","user_id":"usr...","display_name":"张三","avatar_file_id":"avatar.jpg","user":{"user_id":"usr...","display_name":"张三","avatar_file_id":"avatar.jpg"},"inviter_id":"usr_inviter","inviter_display_name":"李四","inviter_avatar_file_id":"inviter.jpg","inviter":{"user_id":"usr_inviter","display_name":"李四","avatar_file_id":"inviter.jpg"},"status":"pending","source":"invitation","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` | | `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` | | `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` | | `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` |
@@ -2237,10 +2341,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/文件-下载.png" alt="文件-下载" width="360"> <img src="images/文件-下载.png" alt="文件-下载" width="360">
**状态:底层下载已实现租户校验;资源 ACL 需改造。** **状态:下载授权已实现(按上传者/会话成员/消息引用/群成员/好友/收藏/头像放行)。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 - 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 服务端按文件 ACL 放行:上传者本人、文件所属 `p2p`/`grp` 会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、用户头像/资料文件(对任何登录用户放行,2026-10-14 更新);非授权返回 `403 permission denied`(`FILE_FORBIDDEN`)。对象存储模式下鉴权通过返回 302 短期签名 URL。
- 头像排障口径:群成员/资料接口返回的 `avatar_url` 可直接下载;若对端头像显示占位图且下载 `404`,说明该头像文件从未被引用或已被回收,让对方重新上传并设置头像即可;`403` 为非登录态(Token 失效),重新登录。
- 前提:发送方上传附件时带了 `topic`(见 0.5.5.1)。若对端下载一直 404,说明该文件从未关联会话/消息且已被 1 小时回收任务删除,应让发送方重新上传并携带 `topic`。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
### 7.10 文件-下载中.png ### 7.10 文件-下载中.png
@@ -2271,6 +2377,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 - `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 - 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 - 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
- 搜索匹配字段包括文本正文、`content.text/txt/content/title/summary/name`、文件名、链接标题/摘要、卡片/转发快照等可展示文本;排序按 `seq desc`,`next_cursor` 继续向更早消息翻页。
### 7.13 查找聊天记录-文件.png ### 7.13 查找聊天记录-文件.png
@@ -2328,10 +2435,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` | | `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` |
| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` | | `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` |
| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` | | `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` |
| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` | | `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20,"comment_seq":19,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` | | `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` |
| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` | | `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent","comment_seq":29,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1"}],"failed":[]}` | | `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1","comment_seq":30,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` | | `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` |
| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | | `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` |
@@ -2351,11 +2458,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/对方状态.png" alt="对方状态" width="360"> <img src="images/对方状态.png" alt="对方状态" width="360">
**状态:在线邀请已实现;Push 与产品鉴权需改造。** **状态:在线邀请、短有效期普通 Push 与产品鉴权已实现。**
- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 - 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。
- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 - 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。
- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 - 后台 Push 只携带 `callId,topic,seq,caller_id,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;iOS 来电 Push 使用普通 alert,`category=im_call`,`content-available=1`,短 TTL 约 30 秒,不使用 PushKit VoIP。
- 被叫在未发送 `ringing/accept` 前也可以直接发送 `hang-up`,服务端按 `topic+seq+callId` 校验后立即结束主叫侧振铃,并把原通话消息替换为 `declined`;主叫主动取消则替换为 `missed`。
### 8.3 语音通话-对方待接受状态.png ### 8.3 语音通话-对方待接受状态.png
@@ -2477,7 +2585,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 - `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,sort_order,unread_count,enabled,permission}]`、布局版本和企业配置;服务端按 `sort_order ASC` 返回,默认顺序为打卡、月报、日报、部门框架、举报信箱、会议通知、会议笔记。`meetings` 模块的 `unread_count` 由服务端按未读系统通知实时补齐。
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。 - `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 - 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
@@ -2525,6 +2633,16 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。 - 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.4.1 会议笔记
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20` 返回可查看会议笔记的会议列表、`note_status/file_count/permissions`。
- 详情:`GET /v1/im/meetings/{meeting_id}/notes` 返回会议信息和 `files[]`。
- 上传并登记:调用通用文件上传 `POST /v0/file/u/`,multipart 包含 `file=@...`、`purpose=meeting_note`、`meeting_id=<meeting_id>`、`id=<本次上传幂等ID>`,请求头按文件上传鉴权携带 `X-MeetingIM-APIKey`、`X-MeetingIM-Auth`。服务端上传成功后会立即登记会议笔记文件,响应 `ctrl.params` 包含 `url,file_id,purpose,meeting_id,note_file_id,note_file_status,version`。
- 兼容绑定:若客户端先完成普通文件上传,也可以调用 `POST /v1/im/meetings/{meeting_id}/notes/files`,Body `{"file_id":"..."}`,并携带 `Idempotency-Key` 完成登记。
- 替换/删除:`PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}`、`DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}?version=n`。只有记录人/发起人按权限可上传、替换和删除。
### 9.5 日报.png ### 9.5 日报.png
<img src="images/日报.png" alt="日报" width="360"> <img src="images/日报.png" alt="日报" width="360">
@@ -2601,7 +2719,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 - 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。
- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 - 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。`subject` 最多 200 字符,`content` 最多 5000 字符;超限返回 `VALIDATION_FAILED`,不返回 500。
- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 - 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。
### 9.13 总经理信箱-文案提示.png ### 9.13 总经理信箱-文案提示.png
@@ -2690,7 +2808,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。** **状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。 - 文件项返回 `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}`。两个接口都必须校验当前用户仍有会议笔记查看权限。 - 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。对象存储模式下服务端鉴权通过后返回 302 到短期签名 URL,客户端跟随 `Location` 获取文件内容。
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。 - 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。 - `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
@@ -2700,7 +2818,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:复用统一下载任务;会议笔记 ACL 新增。** **状态:复用统一下载任务;会议笔记 ACL 新增。**
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传。 - 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传;对象存储模式下客户端跟随 302 `Location`。
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。 - 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。 - 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。 - 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
@@ -2733,7 +2851,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `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"}}` | | `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"}` | | `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"}` | | `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` | | `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件内容 |
### 9.14.10 字段与状态约定 ### 9.14.10 字段与状态约定
@@ -2812,6 +2930,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | | `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | | `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=` | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","note_status":"available","file_count":1,"permissions":{"can_upload":true,"can_download":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` | Path meeting ID | `{"meeting_id":"mtg_1","note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","file_name":"会议纪要.pdf","mime_type":"application/pdf","permissions":{"can_download":true}}]}` |
| `POST /v0/file/u/` 会议笔记上传并登记 | Multipart `purpose=meeting_note&meeting_id=mtg_1&id=upload_1&file=@note.pdf` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file_1.pdf","file_id":"file_1","purpose":"meeting_note","meeting_id":"mtg_1","note_file_id":"mnote_1","note_file_status":"active","version":1}}}` |
| `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","status":"active","version":1}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | | `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | | `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | | `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
@@ -2849,8 +2971,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,avatar_url,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`(乐观锁必填,`version` 取自最近一次 `GET /v1/im/me/profile` 的返回值);缺失或 `<=0` 返回 `400/20000 MALFORMED_REQUEST`,与当前版本不一致返回 `409/20009 VERSION_CONFLICT`(重新 GET 取最新 `version` 后重试即可);服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png ### 10.3 设置昵称.png
@@ -2861,7 +2984,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 - `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 - 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突(`409/20009`)先拉最新资料再由用户确认。
### 10.4 出生日期.png ### 10.4 出生日期.png
@@ -2897,7 +3020,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:菜单纯客户端;数据接口部分新增。** **状态:菜单纯客户端;数据接口部分新增。**
- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。
- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 - 退出:`POST /v1/im/auth/logout` 将当前登录 Token 的 SHA-256 摘要写入 Redis 撤销集合,TTL 与 Token 剩余有效期一致,不影响同账号其他设备。成功后关闭 WS、清安全存储和本租户用户缓存;请求须带 `Authorization: Bearer <token>` 和 `Idempotency-Key`。
- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。
### 10.8 设置(安卓).png ### 10.8 设置(安卓).png
@@ -2924,8 +3047,13 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增 App 配置接口。** **状态:新增 App 配置接口。**
- `GET /v1/im/app/releases/latest?platform=ios|android&current_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。
- `GET /v1/im/app/releases/latest?platform=ios|android|web&current_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
- 返回 `current_version,has_update,force_update,latest_version,min_supported_version,update_type=none|recommended|force,release_notes,store_url,package_url,checksum,published_at`。
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 - 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
### 10.11 发现新版本.png ### 10.11 发现新版本.png
@@ -2963,7 +3091,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。 - `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,二者返回 `enforced=true`;`payment_guard` 仍为预留项,返回 `enforced=false`。
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。 - 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。 - 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 - 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
@@ -2984,7 +3112,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 - 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。 - 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,当前已覆盖夜间音视频呼叫发起;前端隐藏只是体验优化,不能作为安全控制。
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 - 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
### 10.17 关闭青少年模式(安卓).png ### 10.17 关闭青少年模式(安卓).png
@@ -3049,14 +3177,14 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | | `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | | `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` | | `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | | `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` | | `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | | `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
@@ -3065,9 +3193,29 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/security/realname/appeals` | `{"reason":"证件变更需要更正实名信息","attachments":["file_1"]}` | `{"appeal_id":"rna_1","reason":"...","attachments":["file_1"],"status":"pending"}`;当前无需二次身份验证(如后续启用,将复用 1.7 的 action_token 机制并在此处更新) |
| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | | `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` |
| `POST /v1/im/me/password` | `{"old_password":"OldPass123","new_password":"NewPass123"}` | `{"status":"ok"}`;旧密码错误返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`;新密码不满足策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}` |
| `POST /v1/im/security/challenges` 注销账号发码 | Header `Idempotency-Key`;Body `{"scene":"delete_account","channel":"sms"}` | `{"challenge_id":"sec_chg_9","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges/{id}/verify` 注销账号验证 | `{"code":"123456"}` | `{"action_token":"del_token","action":"delete_account","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/me/deletion` | Header `X-Action-Token: del_token`;Body `{}` | `{"status":"ok"}` |
#### 10.22.2 账号安全:修改密码与注销账号
**修改密码 `POST /v1/im/me/password`(已实现):**
- 已登录(Bearer)用户提交 `old_password`、`new_password`;两者必填,缺一返回 `400 MALFORMED_REQUEST`。
- 服务端先用旧密码走 `basic` 认证校验,失败返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`,不泄露账号状态。
- 新密码不满足密码策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}`。
- 成功后服务端递增 `auth_version` 并撤销该用户全部在线 Session(广播 `password_changed`);包括当前请求在内的所有旧 Token 全部失效,客户端收到成功响应后必须清理本租户凭证并跳转登录页。
**注销账号 `POST /v1/im/me/deletion`(已实现):**
- 两步挑战复用 1.7 敏感操作基建:`POST /v1/im/security/challenges`(Body `{"scene":"delete_account","channel":"sms"}`,发码到账号绑定手机号)→ `POST /v1/im/security/challenges/{id}/verify`(返回一次性 `action_token`)→ `POST /v1/im/me/deletion`(Header `X-Action-Token` 携带)。
- 服务端配置 `permanent_accounts=true` 时拒绝注销:HTTP `403` `{"code":30040,"reason":"FORBIDDEN","message":"account deletion is disabled"}`。
- 执行成功为**软删除**:撤销全部认证记录(含登录密码)、踢出所有在线 Session(`reason=user_deleted`)、退出并停用其拥有的群与 P2P 会话、向关注该用户的好友下发 `gone` 在线状态;账号不再可登录。客户端收到成功响应后清理本地数据并返回企业入口。
- 挑战阶段限流与错误码同 1.7(`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`);action token 缺失/无效返回 HTTP `403` `{"code":30042,"reason":"ACTION_TOKEN_INVALID"}`。
## 11. 反诈安全、举报与意见反馈 ## 11. 反诈安全、举报与意见反馈
+30 -11
View File
@@ -5,9 +5,11 @@ go 1.26.0
require ( require (
firebase.google.com/go v3.13.0+incompatible firebase.google.com/go v3.13.0+incompatible
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 github.com/alibabacloud-go/green-20220302/v3 v3.5.4
github.com/alibabacloud-go/tea v1.5.2 github.com/alibabacloud-go/tea v1.5.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/aws/aws-sdk-go-v2 v1.42.1 github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27 github.com/aws/aws-sdk-go-v2/config v1.32.27
@@ -22,6 +24,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/minio/minio-go/v7 v7.4.1
github.com/mozillazg/go-pinyin v0.20.0 github.com/mozillazg/go-pinyin v0.20.0
github.com/nyaruka/phonenumbers v1.8.0 github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
@@ -32,12 +35,13 @@ require (
github.com/tinode/jsonco v1.0.0 github.com/tinode/jsonco v1.0.0
github.com/tinode/snowflake v1.0.0 github.com/tinode/snowflake v1.0.0
go.mongodb.org/mongo-driver v1.17.9 go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0 golang.org/x/sync v0.23.0
golang.org/x/text v0.42.0
google.golang.org/api v0.287.0 google.golang.org/api v0.287.0
google.golang.org/grpc v1.82.0 google.golang.org/grpc v1.82.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.12
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2
) )
@@ -52,7 +56,14 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/openapi-util v0.1.0 // indirect
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1 // indirect
github.com/alibabacloud-go/tea-fileform v1.1.1 // indirect
github.com/alibabacloud-go/tea-oss-sdk v1.1.3 // indirect
github.com/alibabacloud-go/tea-oss-utils v1.1.0 // indirect
github.com/alibabacloud-go/tea-utils v1.3.6 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
@@ -70,6 +81,7 @@ require (
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -78,15 +90,22 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
@@ -96,8 +115,9 @@ require (
go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect golang.org/x/net v0.60.0 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
) )
require ( require (
@@ -119,19 +139,18 @@ require (
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/klauspost/compress v1.19.0 // indirect github.com/klauspost/compress v1.19.2 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect github.com/montanaflynn/stats v0.9.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.10.2 // indirect
github.com/twilio/twilio-go v1.30.9 github.com/twilio/twilio-go v1.30.9
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
google.golang.org/appengine v1.6.8 // indirect google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
+78 -25
View File
@@ -44,12 +44,16 @@ github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do2
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2 h1:y4s0WQ1jrBtOJfXGgsv/83brJvkkHbFdORp0WDyVAuw=
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2/go.mod h1:kD75qqMQyjCiz6lssjRzYGTumcli8STLXQstVe6ytxk=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.0/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10/go.mod h1:26a14FGhZVELuz2cc2AolvW4RHmIO3/HRwsdHhaIPDE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
@@ -64,27 +68,47 @@ github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgT
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk= github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ= github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1 h1:L0TIjr9Qh/SLVc1yPhFkcB9+9SbCNK/jPq4ZKB5zmnc=
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1/go.mod h1:EKxBRDLcMzwl4VLF/1WJwlByZZECJawPXUvinKMsTTs=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.10/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.12/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU= github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0= github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= github.com/alibabacloud-go/tea-fileform v1.1.1 h1:1YG6erAP3joQ0XdCXYIotuD7zyOM6qCR49xkp5FZDeU=
github.com/alibabacloud-go/tea-fileform v1.1.1/go.mod h1:ZeCV91o4ISmxidd686f0ebdS5EDHWU+vW+TkjLhrsFE=
github.com/alibabacloud-go/tea-oss-sdk v1.1.3 h1:EhAHI6edMeqgkZEqP7r4nc9iMWAUBKGxJHoBsOSKTtU=
github.com/alibabacloud-go/tea-oss-sdk v1.1.3/go.mod h1:yUnodpR3Bf2rudLE7V/Gft5txjJF30Pk+hH77K/Eab0=
github.com/alibabacloud-go/tea-oss-utils v1.1.0 h1:y65crjjcZ2Pbb6UZtC2deuIZHDVTS3IaDWE7M9nVLRc=
github.com/alibabacloud-go/tea-oss-utils v1.1.0/go.mod h1:PFCF12e9yEKyBUIn7X1IrF/pNjvxgkHy0CgxX4+xRuY=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils v1.3.6 h1:bVjrxHztM8hAs6nOfLWCgxQfAtKb9RgFFMV6J3rdvB4=
github.com/alibabacloud-go/tea-utils v1.3.6/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA= github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.1/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.3.10/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk= github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
@@ -138,6 +162,7 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -151,6 +176,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
@@ -239,8 +266,13 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -256,6 +288,12 @@ github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZyc
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w=
github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -279,6 +317,8 @@ github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoT
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -286,8 +326,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -301,13 +339,15 @@ github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgv
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk= github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -316,20 +356,26 @@ github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnO
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE= github.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE=
github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8= github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8=
github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE= github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=
github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ= github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
@@ -347,6 +393,10 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -375,6 +425,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -390,8 +442,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -426,8 +478,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -442,8 +494,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -467,8 +519,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -492,8 +544,9 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -543,8 +596,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII= gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU= gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU=
@@ -555,8 +608,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis= gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E= gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -564,7 +618,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+10
View File
@@ -0,0 +1,10 @@
/im-pressure
/config.json
/config.single-machine.json
/config.local*.json
/.env.test
/manifest*.json
/results/
/*-summary.json
/report.json
/metrics.jsonl
+205
View File
@@ -0,0 +1,205 @@
# 机器 B 压测执行手册
## 给机器 B 上 AI 的指令
你当前操作的是 **机器 B**,代码与机器 A 完全相同。你的职责只有:核验运行文件、启动 `agent -machine b`、观察运行状态、把结果留给机器 A。不要执行 `prepare`、`coordinator` 或 `merge`,不要修改账号分片。
机器 B 固定模拟 `ltb0001` 至 `ltb1000` 共 1000 个用户。测试场景和统一开始时间由机器 A 的 coordinator 下发。
## 当前 allrich 测试批次
以下参数适用于 2026-09-27 的 2000 人群聊压测:
| 参数 | 值 |
|---|---|
| Git 分支 | `dev` |
| 最低提交 | `ea8732e` |
| 企业码 | `allrich` |
| HTTP | `https://api.118im.com` |
| WebSocket | `wss://api.118im.com/v0/channels` |
| 机器 A 内网地址 | `192.168.1.135` |
| Coordinator | `http://192.168.1.135:8090` |
| Run ID | `group2k-r1-20260927-01` |
| 场景 | `group` |
| 机器 A 状态 | 等待启动 coordinator 和 agent A |
机器 A 已生成安全运行包:
```text
/tmp/group2k-r1-20260927-01.zip
SHA-256: 21e65b19476e64348b9214322021d880b231de1f9c9f685ea43d0082103e9cd1
```
运行包文件名固定为 `/tmp/<RUN_ID>.zip`。它包含被 Git 忽略的密钥和 Token,因此 `git pull` 不会下载该文件,必须从机器 A 的 `/tmp` 目录单独复制。
运行包内只有三个运行时文件:`.env.test`、`config.json`、`manifest.json`。其中包含 API Key、测试账号密码、固定测试验证码和账号 Token,不进入 Git,必须通过受控内网传输。测试环境验证码为 `123456`,正式环境禁止使用固定验证码。
因此,机器 B 只执行 `git pull` 后看不到这三个文件是正常现象。机器 B 需要从机器 A 单独复制运行包:
```bash
scp youshunlai@192.168.1.135:/tmp/group2k-r1-20260927-01.zip /tmp/
shasum -a 256 /tmp/group2k-r1-20260927-01.zip
# 必须输出:21e65b19476e64348b9214322021d880b231de1f9c9f685ea43d0082103e9cd1
```
如果机器 B 无法主动连接 A,也可以由机器 A 执行 `scp` 推送;目标机器的用户名和地址由 B 机提供。禁止通过 Git、公开网盘或无访问控制的 HTTP 服务传输该运行包。
本轮 A/B 都通过同一个公共 WebSocket LB 接入。它能覆盖负载均衡后的多 Pod 投递,但不能保证 A、B 被固定分配到不同 Pod;需要强制跨指定 Pod 时,应另行提供两个定向 WS 入口并更新 `config.json`。
## 运行前向机器 A 获取
开始前必须拿到以下信息:
- `MACHINE_A_IP`:机器 A 的内网 IP。
- `RUN_ID`:本轮唯一标识,例如 `p2p-20260927-01`。
- `config.json`:两台机器使用同一份配置。
- `manifest.json`:由机器 A 执行 `prepare` 生成,包含账号 Token 和 Topic。
- `IM_API_KEY`、`IM_LOAD_PASSWORD`:通过安全方式提供,不写进代码或结果文件。
- 机器 A 已启动 coordinator 的确认。
同一份代码不代表运行时 manifest 会自动同步。如果本机没有最新的 `manifest.json`,必须让机器 A 传过来;机器 B 不得自行执行 `prepare`。
## 第一步:进入仓库并构建
把 `/absolute/path/to/im_server` 换成本机仓库绝对路径:
```bash
cd /absolute/path/to/im_server
git rev-parse HEAD
go build -o loadtest/im-pressure/im-pressure ./loadtest/im-pressure
ulimit -n 100000
```
将 `git rev-parse HEAD` 的结果发给机器 A,确认两边代码版本一致。
## 第二步:解压运行文件
在机器 B 的仓库根目录执行:
```bash
unzip -o /tmp/group2k-r1-20260927-01.zip \
-d loadtest/im-pressure
chmod 600 \
loadtest/im-pressure/.env.test \
loadtest/im-pressure/config.json \
loadtest/im-pressure/manifest.json
```
解压后必须存在:
```text
loadtest/im-pressure/.env.test
loadtest/im-pressure/config.json
loadtest/im-pressure/manifest.json
```
检查文件:
```bash
test -s loadtest/im-pressure/.env.test
test -s loadtest/im-pressure/config.json
test -s loadtest/im-pressure/manifest.json
```
## 第三步:设置环境变量并核验
```bash
set -a
source loadtest/im-pressure/.env.test
set +a
export MACHINE_A_IP='192.168.1.135'
export RUN_ID='group2k-r1-20260927-01'
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json
```
`verify` 必须成功后才能继续。它只读核验 2000 个账号、P2P Topic 和 2000 人群,不会开始压测。
## 第四步:检查机器 A 协调器
```bash
curl -fsS "http://${MACHINE_A_IP}:8090/v1/status"
```
如果连接失败,停止操作并让机器 A 检查 coordinator、内网 IP、防火墙和 `8090` 端口。不要改为本机启动 coordinator。
## 第五步:启动机器 B agent
```bash
mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json \
-machine b \
-coordinator "http://${MACHINE_A_IP}:8090" \
-metrics-listen :9105 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
启动后会依次发生:
1. 机器 B 建立 1000 条 WebSocket 连接并订阅对应 P2P Topic 和群 Topic。
2. agent 向机器 A coordinator 报告 READY。
3. 如果机器 A 尚未 READY,当前命令会等待,这是正常现象。
4. 等待期间连接断开会自动重连并重新订阅;在 A/B 都实时保持 1000 条连接并完成最终屏障前,agent 不会退出或开始发压。
5. coordinator 下发 `run_id`、场景和统一 UTC 开始时间。
6. agent 完成发送和 WS 到达统计后,先向协调器提交未 ACK 但已观测的消息台账;等待 `history_settle`(默认 5 分钟)后,以同 Topic 串行、不同 Topic 并行的限速分页方式执行历史持久化回查。等待期间不要停止进程。
7. 两机历史回查完成后会通过 coordinator 汇总命中消息,B 也会据此计算 `realtime_lost_but_persisted`;随后 agent 在默认 10 秒窗口内渐进关闭连接、写出 summary 并正常退出。
机器 B 不需要传场景参数。`p2p`、`group` 或 `mixed` 由机器 A coordinator 决定。
## 第六步:检查并回传结果
```bash
test -s "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
将以下文件路径和运行是否成功告知机器 A:
```text
loadtest/im-pressure/results/<RUN_ID>/agent-b-summary.json
```
如果机器 A 无法直接读取机器 B 文件,由机器 A 使用 `scp` 拉取,或从机器 B 推送:
```bash
scp "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json" \
user@机器A内网IP:/absolute/path/to/im_server/loadtest/im-pressure/results/${RUN_ID}/
```
## 运行中观察
另开一个终端可以查看本机 agent 指标:
```bash
curl -fsS http://127.0.0.1:9105/metrics | \
grep -E 'im_load_agent_(ready_connections|publish|received|duplicates|out_of_order|disconnects)'
```
关键判断:
- `im_load_agent_ready_connections` 应到达 `1000`。
- `disconnects_total`、`duplicates_total`、`out_of_order_total` 期望为 `0`。
- agent 进程退出前不要关闭终端或停止网络。
## 常见问题
- `coordinator connection refused`:机器 A coordinator 未启动、IP 错误或 `8090` 被拦截。
- 一直等待开始:通常是另一台 agent 尚未达到 1000 个 READY,查看机器 A coordinator 状态。
- 登录返回 `401`:确认本机设置了正确的 `IM_LOAD_PASSWORD`,并让机器 A 重新同步最新 manifest。
- `listen :9105 address already in use`:换成空闲端口,例如 `-metrics-listen :9106`,并通知机器 A 修改采集地址。
- `ready connections` 不足 1000:不要继续压测,检查 WS 地址、文件描述符限制、Ingress 和网络连接限制。
- `verify` 失败:停止,不要绕过;让机器 A 核对数据准备结果和本机运行文件版本。
## 机器 B 禁止事项
- 禁止运行 `prepare`,避免重复创建或改变测试数据。
- 禁止运行 `coordinator`,全场只能使用机器 A 的 coordinator。
- 禁止把参数改成 `-machine a`。
- 禁止自行改变 `RUN_ID`、config 或 manifest。
- 禁止提交或传播包含 Token 的 manifest。
+276
View File
@@ -0,0 +1,276 @@
# IM 双机压力测试工具
该工具按配置指定的企业租户运行:两台压测机各承载 1000 个 WebSocket 用户,共 2000 个用户、1000 对好友和一个 2000 人群。当前测试环境使用 `allrich`,本轮实时参数见 [MACHINE-B.md](./MACHINE-B.md) 和 [allrich 双机压测执行记录](../../docs/test/IM-allrich双机压测执行记录-20260927.md)。
只使用一台物理压测机时,请使用 [单机 60% 容量压测方案](../../docs/test/IM-单机60%容量压力测试方案.md) 和 [config.single-machine.example.json](./config.single-machine.example.json)。单机依然运行 A/B 两个逻辑 agent,用于保留好友对、跨逻辑分片投递和合并报告的完整契约。
首轮实测数据与瓶颈分析见 [单机 60% 容量压测结果](../../docs/test/IM-单机60%容量压力测试结果-20260929.md)。
机器 B 上的 AI 或操作人请直接阅读 [MACHINE-B.md](./MACHINE-B.md)。该文件不重复讲方案,只给出机器 B 从核验到启动、观察和回传结果的完整命令。
## 两台机器的固定分工
当前仓库所在机器固定作为 **机器 A**。两台机器不要交换 `-machine` 参数,否则账号分片和结果归属会混乱。
| 项目 | 机器 A(当前机器,主控) | 机器 B(另一台机器,发压) |
|---|---|---|
| 模拟账号 | `lta0001` 至 `lta1000` | `ltb0001` 至 `ltb1000` |
| WS 连接数 | 1000 | 1000 |
| 固定参数 | `-machine a` | `-machine b` |
| 数据准备 | 执行 `prepare`、`verify` | 不执行 |
| 双机协调 | 运行 `coordinator :8090` | 连接机器 A 的 `:8090` |
| 场景发压 | 运行 agent A | 运行 agent B |
| 指标 | agent `:9105`,运行中央 `collect` | agent `:9105`,供机器 A 抓取 |
| 结果 | 保存 A 结果、接收 B 结果、执行 `merge` | 保存 B 结果并传回机器 A |
机器 A 至少准备三个终端:协调器、agent A、指标采集。机器 B 至少准备一个终端运行 agent B;需要单独采集本机指标时再开第二个终端。
### 机器 B 最短操作清单
机器 B 的完整命令见 [MACHINE-B.md](./MACHINE-B.md)。机器 B 操作人只需完成以下事项:
1. 检出与机器 A 相同的 Git 提交并构建 `im-pressure`。
2. 接收机器 A 生成的 `config.json` 和 `manifest.json`,不要自行运行 `prepare`。
3. 设置与机器 A 相同的 `IM_API_KEY`、`IM_LOAD_PASSWORD`,执行一次 `verify`。
4. 从机器 A 获取本轮 `RUN_ID` 和机器 A 内网 IP。
5. 等机器 A 启动 coordinator 后,固定使用 `-machine b` 启动 agent B。
6. agent B 退出后,将 `agent-b-summary.json` 传回机器 A。
机器 B 不负责建账号、加好友、建群、选择场景或合并报告。
### 网络要求
- 机器 B 必须能访问 `http://机器A内网IP:8090`。
- 如果由机器 A 集中采集 agent 指标,机器 A 必须能访问 `http://机器B内网IP:9105/metrics`。
- 两台机器都必须能访问配置中的 HTTP API 和各自的 WS 地址。
- `8090` 是无鉴权的压测协调端口,只允许压测内网访问,不要暴露到公网。
- 两台机器必须使用 NTP/chrony 同步时间。报告会分别输出同机时钟延迟和跨机器单向延迟;跨机器出现负延迟样本时,后者仅用于诊断,不参与门禁。
- agent 默认每 20 秒发送 WebSocket Ping,覆盖等待另一台机器、发压、drain 和历史核验阶段;`ws_ping_interval` 必须小于公网入口的空闲连接超时。
- 正式开始前 agent 会持续检查实际存活连接;连接断开后自动重新登录并订阅 P2P、群 Topic。初次连接不齐或等待另一台机器期间掉线不会退出。
- coordinator 使用三阶段屏障:两边实时连接数都达到配置值后进入验证倒计时;倒计时内任一方掉线就撤销屏障;提交最终开始时间后,两边必须在该时间前后 3 秒内再次到达起跑屏障,且双方都到达后才允许发送。任一方迟到会让两边共同终止本轮,避免生成错时压测结果。
- 压测机如保持 OpenVPN 等全局 TUN,仅在另一套代理中设置 `DIRECT` 无法绕过它。起压前必须确认 `http_base` 和 WS 域名的每个当前 IP 都走物理网卡;需保留 VPN 访问内网时,可为这些 IP 配置经局域网关的 `/32` 主机路由。VPN 重连、DNS 变更或机器重启后需重新核验。
## 统计口径
- 发送成功以 `{ctrl code=2xx, params.seq>0}` 为准,形成两台机器共享的 ACK 台账。
- 实时到达只统计同时匹配本轮 `run_id` 和 `x-im-client-mid` 的 `{data}`。
- 群消息设置 `noecho=false`,每条服务端接受的群消息期望 2000 个连接各收到一次。
- 实时 drain 结束后,两机先汇总 ACK 台账和“已实时观测但未收到 ACK”的消息台账;等待 `history_settle`(默认 5 分钟)让 Kafka 自然排空,再通过 `{get what=data}` 按 Topic/Seq 回查两类消息。
- 每轮建立 WS 后、正式计时前,以 32 个 worker 对本机 1000 个账号做好友关系、P2P Topic 和发布权限预检;任一异常会终止本轮。
- 历史回查默认每机最多使用 12 个 worker、每页 10 条、同一 Topic 两页之间至少间隔 500ms,并在每页请求后等待目标消息全部返回(最长 3 秒)再继续。同一 Topic 严格串行,不同 Topic 才并行;因此群聊只会有 1 个活跃回查 worker,单聊可并行核验不同 Topic。总超时默认 3 分钟,并在超时内持续重试缺失区间。该限速用于避免正确性核验本身制造 Topic/Proxy 洪峰;历史读取吞吐应使用独立场景测试。
- agent 完成后默认在 10 秒内均匀关闭本机 WebSocket,避免 1000 个群订阅同时离线冲击 Topic 队列。两机退出时间可能相差数秒,协调器完成以两份 summary 都已提交为准。
- publish 错误完整分类为 `403`、`502`、`503`、`ctrl_timeout`、`socket_error` 和 `other`。`publish_rejected` 只统计明确拒绝,超时和 socket 错误单独计入 `publish_outcome_unknown`。
- `delivery_local_p95_ms` 使用发送方本机收到 echo 的同机时钟样本,是投递延迟门禁口径;兼容字段 `delivery_p95_ms` 与它保持一致。
- `delivery_cross_machine_p95_ms` 是跨机器单向观测值,只有两机时钟同步达标时才有意义;`cross_machine_negative_latency_samples > 0` 明确表示存在时钟偏差。
- `manifest.json` 含登录 token,文件权限为 `0600`,不得提交到 Git 或放入结果包。
## 两台机器初始化
两台机器先检出完全相同的 Git 提交,并分别构建本机二进制:
```bash
git rev-parse HEAD
go build -o loadtest/im-pressure/im-pressure ./loadtest/im-pressure
ulimit -n 100000
```
只在机器 A 创建配置:
```bash
cp loadtest/im-pressure/config.example.json loadtest/im-pressure/config.json
export IM_API_KEY='...'
export IM_LOAD_PASSWORD='...'
```
修改 `config.json`:`http_base` 指向 IM HTTP API;`endpoints.a/b` 分别填写两台机器应直连的 WS Pod/LB 地址,多个地址时用户会轮询分配。正式测试保持 `insecure_tls=false`。
测试环境要求短信验证注册时,将 `registration_phone_prefix` 配置为 7 位数字。工具为机器 A 追加后缀 `0001` 起的序号,为机器 B 追加后缀 `5001` 起的序号,生成互不冲突的 11 位测试手机号;验证码从 `verification_code_env` 指定的环境变量读取。正式环境不得使用固定测试验证码。
`endpoints.a` 是机器 A 的 1000 个用户使用的 WS Pod/LB 地址,`endpoints.b` 是机器 B 的 1000 个用户使用的地址。要验证跨 Pod 投递时,应让 A、B 连接到不同 Pod 或不同的定向入口;如果都填同一个普通 LB,连接落在哪个 Pod 将由 LB 决定。
机器 B 同样需要设置 `IM_API_KEY` 和 `IM_LOAD_PASSWORD`。密码用于 Token 失效时自动回退 Basic 登录,不能只在机器 A 设置。
## 机器 A:一次性准备
以下命令只在当前机器 A 执行。命令可重复运行,会复用已有账号、好友和群:
```bash
./loadtest/im-pressure/im-pressure prepare \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json
```
准备成功后,机器 A 的 manifest 中包含 2000 个账号 Token、1000 个 P2P Topic 和群 Topic。将配置和 manifest 安全复制到机器 B:
```bash
export MACHINE_B_SSH='user@机器B内网IP'
export MACHINE_B_REPO='/home/user/im_server'
scp loadtest/im-pressure/config.json \
"${MACHINE_B_SSH}:${MACHINE_B_REPO}/loadtest/im-pressure/config.json"
scp loadtest/im-pressure/manifest.json \
"${MACHINE_B_SSH}:${MACHINE_B_REPO}/loadtest/im-pressure/manifest.json"
```
机器 B 收到后执行:
```bash
cd /home/user/im_server
chmod 600 loadtest/im-pressure/manifest.json
export IM_API_KEY='...'
export IM_LOAD_PASSWORD='...'
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json
```
`MACHINE_B_REPO` 必须填写机器 B 上仓库的绝对路径。每次机器 A 重新执行 `prepare` 并改变了 manifest,都必须重新同步到机器 B。
## 每轮双机运行
以下示例执行单聊场景。机器 A 设置本轮场景;两台机器使用同一个、每轮唯一且不超过 24 个字符的 `RUN_ID`,保证结果目录一致:
```bash
export RUN_ID='p2p-20260927-01'
export SCENARIO='p2p'
export MACHINE_A_IP='机器A内网IP'
export MACHINE_B_IP='机器B内网IP'
mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
```
机器 A 应在正式起压前启动终端 A3 的指标采集,然后按 A1、A2、B1 的顺序启动协调器和两个 agent。
### 第一步:机器 A 启动协调器
机器 A 终端 A1:
```bash
./loadtest/im-pressure/im-pressure coordinator \
-listen :8090 \
-run-id "${RUN_ID}" \
-scenario "${SCENARIO}" \
-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json"
```
协调器会等待两个 agent 都报告 1000 条连接 READY,再统一下发至少 30 秒后的开始时间。不要跳过协调器直接启动 agent。
### 第二步:机器 A 启动 agent A
机器 A 终端 A2:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json \
-machine a \
-coordinator "http://${MACHINE_A_IP}:8090" \
-metrics-listen :9105 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json"
```
### 第三步:机器 B 启动 agent B
机器 B 终端 B1 需要先设置与机器 A 相同的值:
```bash
cd /home/user/im_server
export IM_API_KEY='...'
export IM_LOAD_PASSWORD='...'
export RUN_ID='p2p-20260927-01'
export MACHINE_A_IP='机器A内网IP'
mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.json \
-manifest loadtest/im-pressure/manifest.json \
-machine b \
-coordinator "http://${MACHINE_A_IP}:8090" \
-metrics-listen :9105 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
机器 B 不需要传 `SCENARIO`,场景由机器 A 的 coordinator 下发。两个 agent 可以任意先后启动,但 coordinator 必须最先启动。
两个 agent 都建立 1000 条连接后,协调器下发统一的 UTC `start_at`。场景可替换为:
- `p2p`:1000 对用户两侧同时发送,默认每人 10 条、间隔 1 秒。
- `group`:默认每个 agent 有 `group_senders_per_machine` 个发送者,每人 `group_rounds` 条;两机合并后按 `group_publish_interval` 控制全局发送间隔。双机默认 500+500 发送者、100ms 一条,共 2000 条;单机 60% 默认 300+300 发送者。
- `mixed`:默认使用 `mixed_p2p_users_per_machine` 对私聊用户和 `mixed_group_senders_per_machine` 个群发送者。双机默认每机 300 个私聊用户、250 个群发送者;单机 60% 默认每机 180 个私聊用户、150 个群发送者。
- `idle`:只建立并维持 WS 连接,不发送业务消息,持续 `idle_duration`。该场景用于先确认 2000 长连接在入口、Pod、DNS、心跳和自动重连下是否稳定。
群聊容量阶梯建议只改 `group_publish_interval`,保持发送者和轮数不变:`125ms` 约等于 8 msg/s,`100ms` 约等于 10 msg/s,`83ms` 约等于 12 msg/s,`67ms` 约等于 15 msg/s,`56ms` 约等于 18 msg/s。先跑 `idle`,再从低速 `group` 逐级上探;某一级出现连接波动、ACK 长尾或 DB pool wait 明显上升时,重复该级确认后再进入下一档。
自动重连由 `reconnect_workers`、`reconnect_min_wait`、`reconnect_max_wait` 控制。断线账号会按指数退避并附带确定性抖动后重连,避免两台压测机在入口抖动时同时打出登录洪峰。
## 机器 A:回收并合并报告
两个 agent 完成历史回查并退出后,在机器 A 拉取机器 B 的 summary:
```bash
scp "${MACHINE_B_SSH}:${MACHINE_B_REPO}/loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json" \
"loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
然后仍在机器 A 合并报告:
```bash
./loadtest/im-pressure/im-pressure merge \
-a "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json" \
-b "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json" \
-output "loadtest/im-pressure/results/${RUN_ID}/report.json"
```
`report.json` 给出 ACK、实时投递、重复、乱序、断连和历史持久化核验结果。断连会额外输出 `disconnect_reasons` 分类计数和最多 100 条 `disconnect_samples` 原始错误样本。当前正确性门禁要求上述错误计数全部为 0。
每个 agent 在 `:9105/metrics` 暴露连接、发送、接收、重复、乱序、断连以及 Go/process 指标;端口冲突时可通过 `-metrics-listen` 修改,传空字符串可关闭。
## 机器 A:集中采集指标
`collect` 可并行抓取 Prometheus、Redis exporter、Kafka exporter、mysqld exporter、kube-state-metrics/cAdvisor 和压测机 node-exporter,原始响应按时间写入 JSONL:
```bash
./loadtest/im-pressure/im-pressure collect \
-duration 20m -interval 5s \
-coordinator "http://${MACHINE_A_IP}:8090" \
-coordinator-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-url agent-a=http://127.0.0.1:9105/metrics \
-url agent-b=http://${MACHINE_B_IP}:9105/metrics \
-url im=http://PROMETHEUS_HOST/api/v1/query?query=up \
-url redis=http://REDIS_EXPORTER/metrics \
-url kafka=http://KAFKA_EXPORTER/metrics \
-url mysql=http://MYSQLD_EXPORTER/metrics \
-url node-a=http://NODE_A_EXPORTER/metrics \
-url node-b=http://NODE_B_EXPORTER/metrics \
-output "loadtest/im-pressure/results/${RUN_ID}/metrics.jsonl"
```
机器 A 终端 A3 运行采集器。每条 JSONL 快照会自动标记 `phase=before|during|after`:coordinator 下发开始时间后进入 `during`,coordinator 结果文件生成后进入 `after`。URL 含 `&` 时需用单引号包住整个 `name=url` 参数。建议起压前 2 分钟开始采集,结束后继续 5 分钟,以观察 Kafka lag、数据库落库追平时间及资源恢复。如果机器 A 无法访问机器 B 的 `9105`,就在机器 B 单独运行一份 `collect`,结束后把 JSONL 一并传回机器 A。
## MySQL 独立落库核验
合并报告后在机器 A 使用只读 DSN 对 ACK ledger 做独立校验。工具会根据租户和消息时间自动查询 `im_NNN_messages` 及涉及的群消息月表,精确比较 `client_mid + topic + seq`,并统计缺失、错配、重复和非 ACK 消息:
```bash
export MYSQL_DSN='readonly:password@tcp(mysql-host:3306)/meeting_im?parseTime=true'
./loadtest/im-pressure/im-pressure verify-db \
-coordinator "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-tenant allrich \
-dsn-env MYSQL_DSN \
-registry-db hotgo \
-output "loadtest/im-pressure/results/${RUN_ID}/db-verification.json"
```
DSN 只能通过环境变量传入,不要写入配置、命令脚本或结果包。命令在 missing、mismatch、duplicate 或 unexpected 任一计数非零时返回失败。
## 运行前检查
```bash
ulimit -n 100000
curl -fsS "http://${MACHINE_A_IP}:8090/v1/status"
curl -fsS http://127.0.0.1:9105/metrics | head
```
两台压测机应完成时钟同步,并确认压测出口、Ingress、Pod 与 Redis/MySQL/Kafka 连接数限制可以容纳本轮负载。协调器没有鉴权,只能监听在受控压测网络中。
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"errors"
"fmt"
"testing"
"time"
)
func TestHistoryQueriesForMessagesGroupsRangesByTopic(t *testing.T) {
p2pClient := &WSClient{}
groupClient := &WSClient{}
run := &agentRun{cfg: &Config{HistoryPageSize: 200}, clients: map[int]*WSClient{1: groupClient, 7: p2pClient}}
messages := make([]AcceptedMessage, 0, 207)
for seq := int64(205); seq >= 1; seq-- {
messages = append(messages, AcceptedMessage{Kind: "p2p", Topic: "p2p-a", PeerIndex: 7, Seq: seq})
}
messages = append(messages,
AcceptedMessage{Kind: "group", Topic: "grp-a", PeerIndex: 99, Seq: 11},
AcceptedMessage{Kind: "group", Topic: "grp-a", PeerIndex: 99, Seq: 12},
)
queries := run.historyQueriesForMessages(messages)
if len(queries) != 3 {
t.Fatalf("queries=%d want=3", len(queries))
}
p2pQueries := 0
groupQueries := 0
for _, query := range queries {
switch query.topic {
case "p2p-a":
p2pQueries++
if query.client != p2pClient || query.limit != 200 || len(query.expected) == 0 {
t.Fatalf("unexpected p2p query: %#v", query)
}
case "grp-a":
groupQueries++
if query.client != groupClient || query.since != 11 || query.before != 13 {
t.Fatalf("unexpected group query: %#v", query)
}
}
}
if p2pQueries != 2 || groupQueries != 1 {
t.Fatalf("p2p queries=%d group queries=%d", p2pQueries, groupQueries)
}
}
func TestClassifyPublishErrorTreatsClosedConnectionAsUnknownSocketError(t *testing.T) {
run := &agentRun{}
run.classifyPublishError(errors.New("write tcp: use of closed network connection"))
if run.publishSocket.Load() != 1 || run.publishOther.Load() != 0 {
t.Fatalf("socket=%d other=%d", run.publishSocket.Load(), run.publishOther.Load())
}
}
func TestScenarioUsersScaleWithConfiguredCapacity(t *testing.T) {
tests := []struct {
users int
group int
mixedP2P int
mixedGroup int
}{
{users: 1000, group: 500, mixedP2P: 300, mixedGroup: 250},
{users: 600, group: 300, mixedP2P: 180, mixedGroup: 150},
}
for _, tc := range tests {
if got := scaledUsers(tc.users, 1, 2); got != tc.group {
t.Fatalf("users=%d group=%d want=%d", tc.users, got, tc.group)
}
if got := scaledUsers(tc.users, 3, 10); got != tc.mixedP2P {
t.Fatalf("users=%d mixed p2p=%d want=%d", tc.users, got, tc.mixedP2P)
}
if got := scaledUsers(tc.users, 1, 4); got != tc.mixedGroup {
t.Fatalf("users=%d mixed group=%d want=%d", tc.users, got, tc.mixedGroup)
}
}
}
func TestReconnectBackoffCapsAndJitters(t *testing.T) {
minWait := 2 * time.Second
maxWait := 30 * time.Second
first := reconnectBackoff(minWait, maxWait, 1, 10)
if first < minWait || first >= minWait+minWait/4 {
t.Fatalf("first backoff=%s outside expected jitter range", first)
}
capped := reconnectBackoff(minWait, maxWait, 10, 10)
if capped < maxWait || capped >= maxWait+maxWait/4 {
t.Fatalf("capped backoff=%s outside expected jitter range", capped)
}
if reconnectBackoff(minWait, maxWait, 3, 10) == reconnectBackoff(minWait, maxWait, 3, 11) {
t.Fatal("different account indexes should receive different jitter")
}
}
func TestValidScenarioIncludesIdle(t *testing.T) {
for _, scenario := range []string{"p2p", "group", "mixed", "idle"} {
if !validScenario(scenario) {
t.Fatalf("scenario %s should be valid", scenario)
}
}
if validScenario("unknown") {
t.Fatal("unknown scenario should be rejected")
}
}
func TestNewHistoryQueryIncludesInterleavedSequenceRange(t *testing.T) {
messages := make([]AcceptedMessage, 10)
for i := range messages {
messages[i] = AcceptedMessage{ClientMid: fmt.Sprintf("mid-%d", i), Seq: int64(100 + i*2)}
}
query := newHistoryQuery(nil, "p2p-one", messages, 10)
if query.since != 100 || query.before != 119 || query.limit != 19 {
t.Fatalf("history query range=%d-%d limit=%d want=100-119/19", query.since, query.before, query.limit)
}
}
func TestMergeMessageLedgersPrefersPeerObservationAndAck(t *testing.T) {
senderEcho := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "a", Seq: 10}
peerDelivery := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "b", PeerIndex: 7, Seq: 10}
merged := mergeMessageLedgers(nil, []AcceptedMessage{senderEcho, peerDelivery})
if len(merged) != 1 || merged[0].PeerMachine != "b" || merged[0].PeerIndex != 7 {
t.Fatalf("peer observation was not preferred: %+v", merged)
}
ack := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "b", PeerIndex: 7, Seq: 10,
Scenario: "p2p"}
merged = mergeMessageLedgers([]AcceptedMessage{ack}, []AcceptedMessage{peerDelivery})
if len(merged) != 1 || merged[0].Scenario != "p2p" {
t.Fatalf("ACK ledger was not authoritative: %+v", merged)
}
}
func TestCloseScheduleInterleavesLogicalAgents(t *testing.T) {
cfg := &Config{CloseRamp: "60s"}
intervalA, delayA := closeSchedule(cfg, "a", 601)
intervalB, delayB := closeSchedule(cfg, "b", 601)
if intervalA != 200*time.Millisecond || intervalB != intervalA {
t.Fatalf("intervals: a=%s b=%s, want 200ms", intervalA, intervalB)
}
if delayA != 0 || delayB != 100*time.Millisecond {
t.Fatalf("delays: a=%s b=%s, want 0/100ms", delayA, delayB)
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func startAgentMetrics(address string, run *agentRun) (func(), error) {
if address == "" {
return func() {}, nil
}
listener, err := net.Listen("tcp", address)
if err != nil {
return nil, fmt.Errorf("listen for agent metrics on %s: %w", address, err)
}
labels := prometheus.Labels{"machine": run.machine}
registry := prometheus.NewRegistry()
registry.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_ready_connections", Help: "Current ready WebSocket connections.", ConstLabels: labels}, func() float64 {
return float64(run.aliveConnections())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_attempted_total", Help: "Publish commands attempted.", ConstLabels: labels}, func() float64 {
return float64(run.attempted.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_accepted_total", Help: "Publish commands acknowledged by the server.", ConstLabels: labels}, func() float64 {
run.mu.Lock()
defer run.mu.Unlock()
return float64(len(run.accepted))
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_rejected_total", Help: "Publish commands explicitly rejected by the server.", ConstLabels: labels}, func() float64 {
return float64(run.explicitRejected())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_outcome_unknown_total", Help: "Publish commands with ACK timeout or socket failure and therefore unknown outcome.", ConstLabels: labels}, func() float64 {
return float64(run.outcomeUnknown())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_403_total", Help: "Publish commands rejected with 403.", ConstLabels: labels}, func() float64 { return float64(run.publish403.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_502_total", Help: "Publish commands rejected with 502.", ConstLabels: labels}, func() float64 { return float64(run.publish502.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_503_total", Help: "Publish commands rejected with 503.", ConstLabels: labels}, func() float64 { return float64(run.publish503.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_timeout_total", Help: "Publish commands with control-response timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishTimeout.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_observed_total", Help: "Publish control responses observed, including responses received after command timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishCtrlObserved.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_over_20s_total", Help: "Publish control responses taking longer than 20 seconds.", ConstLabels: labels}, func() float64 { return float64(run.publishCtrlOver20.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_late_total", Help: "Publish control responses received after command timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishLateCtrl.Load()) }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_publish_ctrl_unresolved", Help: "Publish control responses still unresolved after accounting for late responses.", ConstLabels: labels}, func() float64 { return float64(run.unresolvedCtrl()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_socket_error_total", Help: "Publish commands with socket errors.", ConstLabels: labels}, func() float64 { return float64(run.publishSocket.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_received_total", Help: "Unique matching data deliveries received.", ConstLabels: labels}, func() float64 {
received, _, _, _ := run.recorder.metricCounts()
return float64(received)
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_duplicates_total", Help: "Duplicate matching deliveries received.", ConstLabels: labels}, func() float64 {
_, duplicates, _, _ := run.recorder.metricCounts()
return float64(duplicates)
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_out_of_order_total", Help: "Per-connection topic sequence regressions.", ConstLabels: labels}, func() float64 {
_, _, outOfOrder, _ := run.recorder.metricCounts()
return float64(outOfOrder)
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_disconnects_total", Help: "Unexpected WebSocket disconnects.", ConstLabels: labels}, func() float64 {
_, _, _, disconnects := run.recorder.metricCounts()
return float64(disconnects)
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_queue_current", Help: "Data callbacks currently waiting to start.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.current.Load())
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_queue_max", Help: "Peak data callbacks waiting to start.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.maximum.Load())
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_processing", Help: "Data callbacks currently processing.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.processing.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_callback_queue_enqueued_total", Help: "Data callbacks enqueued.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.enqueued.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_callback_queue_completed_total", Help: "Data callbacks which completed processing.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.completed.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_callback_queue_wait_seconds_total", Help: "Cumulative time spent waiting to start data callbacks.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.waitNanos.Load()) / float64(time.Second)
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_queue_wait_seconds_max", Help: "Maximum time spent waiting to start a data callback.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.maxWait.Load()) / float64(time.Second)
}),
)
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = server.Serve(listener) }()
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}, nil
}
@@ -0,0 +1,88 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestMaintainConnectionsRestoresClosedClient(t *testing.T) {
var connections atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
connections.Add(1)
defer conn.Close()
for {
var packet map[string]json.RawMessage
if err = conn.ReadJSON(&packet); err != nil {
return
}
for _, raw := range packet {
var body struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &body) != nil || body.ID == "" {
continue
}
response := map[string]any{"ctrl": map[string]any{
"id": body.ID, "code": 200, "text": "ok",
"params": map[string]any{"user": "usrReconnectTest"},
}}
if err = conn.WriteJSON(response); err != nil {
return
}
}
}
}))
defer server.Close()
account := AccountRecord{
Machine: "a", Index: 1, Username: "lta0001", Token: "token",
P2PTopic: "p2pReconnectTest", Endpoint: "ws" + strings.TrimPrefix(server.URL, "http"),
}
cfg := &Config{Tenant: "allrich", CommandTimeout: "1s", WSPingInterval: "1h"}
run := &agentRun{
cfg: cfg, machine: "a", clients: make(map[int]*WSClient),
recorder: newDeliveryRecorder("pending", "a", 1), ack: NewDurationHistogram(),
}
initial, err := run.connectAccountWithRetry(context.Background(), account, "grpReconnectTest", "api-key", "")
if err != nil {
t.Fatal(err)
}
run.setClient(account.Index, initial)
initial.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
done := make(chan struct{})
go func() {
run.maintainConnections(ctx, []AccountRecord{account}, "grpReconnectTest", "api-key", "")
close(done)
}()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if replacement := run.client(account.Index); replacement != initial && replacement.alive() {
cancel()
<-done
run.close()
if got := connections.Load(); got < 2 {
t.Fatalf("websocket connections = %d, want at least 2", got)
}
return
}
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
run.close()
t.Fatal("closed client was not restored before deadline")
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"sync/atomic"
"time"
)
type callbackQueueMetrics struct {
current atomic.Int64
processing atomic.Int64
maximum atomic.Int64
enqueued atomic.Int64
completed atomic.Int64
waitNanos atomic.Int64
maxWait atomic.Int64
}
func (m *callbackQueueMetrics) enqueue() {
if m == nil {
return
}
current := m.current.Add(1)
m.enqueued.Add(1)
updateAtomicMax(&m.maximum, current)
}
func (m *callbackQueueMetrics) startCallback(queuedAt time.Time) {
if m == nil {
return
}
wait := time.Since(queuedAt).Nanoseconds()
if wait < 0 {
wait = 0
}
m.current.Add(-1)
m.processing.Add(1)
m.waitNanos.Add(wait)
updateAtomicMax(&m.maxWait, wait)
}
func (m *callbackQueueMetrics) finishCallback() {
if m == nil {
return
}
m.processing.Add(-1)
m.completed.Add(1)
}
func (m *callbackQueueMetrics) snapshot() CallbackQueueSnapshot {
if m == nil {
return CallbackQueueSnapshot{}
}
return CallbackQueueSnapshot{
Current: m.current.Load(), Processing: m.processing.Load(), Maximum: m.maximum.Load(), Enqueued: m.enqueued.Load(),
Completed: m.completed.Load(), WaitSecondsTotal: float64(m.waitNanos.Load()) / float64(time.Second),
WaitSecondsMax: float64(m.maxWait.Load()) / float64(time.Second),
}
}
func (m *callbackQueueMetrics) isDrained() bool {
return m == nil || (m.current.Load() == 0 && m.processing.Load() == 0)
}
func updateAtomicMax(target *atomic.Int64, value int64) {
for current := target.Load(); value > current; current = target.Load() {
if target.CompareAndSwap(current, value) {
return
}
}
}
@@ -0,0 +1,64 @@
package main
import (
"context"
"testing"
"time"
)
func TestCallbackQueueMetricsTrackWaitingAndProcessing(t *testing.T) {
metrics := &callbackQueueMetrics{}
metrics.enqueue()
metrics.enqueue()
metrics.startCallback(time.Now().Add(-10 * time.Millisecond))
snapshot := metrics.snapshot()
if snapshot.Current != 1 || snapshot.Processing != 1 || snapshot.Maximum != 2 || snapshot.Enqueued != 2 {
t.Fatalf("unexpected active queue snapshot: %+v", snapshot)
}
if snapshot.WaitSecondsTotal <= 0 || snapshot.WaitSecondsMax <= 0 || metrics.isDrained() {
t.Fatalf("wait or drain state not tracked: %+v", snapshot)
}
metrics.finishCallback()
metrics.startCallback(time.Now())
metrics.finishCallback()
if snapshot = metrics.snapshot(); snapshot.Current != 0 || snapshot.Processing != 0 || snapshot.Completed != 2 || !metrics.isDrained() {
t.Fatalf("unexpected drained queue snapshot: %+v", snapshot)
}
}
func TestWaitForCallbackQueueWaitsForProcessingCallback(t *testing.T) {
metrics := &callbackQueueMetrics{}
metrics.enqueue()
metrics.startCallback(time.Now())
go func() {
time.Sleep(20 * time.Millisecond)
metrics.finishCallback()
}()
started := time.Now()
if err := waitForCallbackQueue(context.Background(), metrics, time.Second); err != nil {
t.Fatal(err)
}
if time.Since(started) < 15*time.Millisecond {
t.Fatal("wait returned before the callback finished")
}
}
func TestWaitForDeliveryQuiescenceResetsForLateDelivery(t *testing.T) {
recorder := newDeliveryRecorder("run", "a", 1)
queue := &callbackQueueMetrics{}
go func() {
time.Sleep(20 * time.Millisecond)
recorder.received.Add(1)
}()
started := time.Now()
if err := waitForDeliveryQuiescence(context.Background(), recorder, queue, 150*time.Millisecond, time.Second); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(started); elapsed < 250*time.Millisecond {
t.Fatalf("quiet period was not reset after late delivery: elapsed=%s", elapsed)
}
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type namedURLs []string
func (n *namedURLs) String() string { return strings.Join(*n, ",") }
func (n *namedURLs) Set(value string) error {
if !strings.Contains(value, "=") {
return fmt.Errorf("URL must use name=https://host/path")
}
*n = append(*n, value)
return nil
}
type metricSnapshot struct {
Timestamp time.Time `json:"timestamp"`
Phase string `json:"phase"`
Name string `json:"name"`
URL string `json:"url"`
Status int `json:"status"`
Duration int64 `json:"duration_ms"`
Body string `json:"body,omitempty"`
Error string `json:"error,omitempty"`
}
func runCollector(ctx context.Context, sources namedURLs, output string, interval, duration time.Duration,
coordinatorURL, coordinatorOutput string) error {
if len(sources) == 0 {
return fmt.Errorf("at least one --url name=url is required")
}
if interval <= 0 || duration <= 0 {
return fmt.Errorf("interval and duration must be positive")
}
if dir := filepath.Dir(output); dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
}
file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer file.Close()
writer := bufio.NewWriterSize(file, 256<<10)
defer writer.Flush()
client := &http.Client{Timeout: 10 * time.Second}
deadline := time.Now().Add(duration)
phase := "before"
for {
phase = detectCollectorPhase(ctx, client, phase, coordinatorURL, coordinatorOutput)
snapshots := collectOnce(ctx, client, sources, phase)
for _, snapshot := range snapshots {
raw, _ := json.Marshal(snapshot)
if _, err = writer.Write(append(raw, '\n')); err != nil {
return err
}
}
if err = writer.Flush(); err != nil {
return err
}
if time.Now().Add(interval).After(deadline) {
return nil
}
if err = waitContext(ctx, interval); err != nil {
return err
}
}
}
func detectCollectorPhase(ctx context.Context, client *http.Client, current, coordinatorURL, coordinatorOutput string) string {
if coordinatorOutput != "" {
if _, err := os.Stat(coordinatorOutput); err == nil {
return "after"
}
}
if coordinatorURL == "" {
return current
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(coordinatorURL, "/")+"/v1/status", nil)
if err != nil {
return current
}
response, err := client.Do(req)
if err != nil {
return current
}
defer response.Body.Close()
var status struct {
Start StartInfo `json:"start"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&status) != nil ||
status.Start.StartAt.IsZero() || time.Now().Before(status.Start.StartAt) {
return current
}
return "during"
}
func collectOnce(ctx context.Context, client *http.Client, sources namedURLs, phase string) []metricSnapshot {
out := make([]metricSnapshot, len(sources))
var wg sync.WaitGroup
for i, source := range sources {
i, source := i, source
wg.Add(1)
go func() {
defer wg.Done()
parts := strings.SplitN(source, "=", 2)
snapshot := metricSnapshot{Timestamp: time.Now().UTC(), Phase: phase, Name: parts[0], URL: parts[1]}
started := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parts[1], nil)
if err == nil {
var response *http.Response
response, err = client.Do(req)
if err == nil {
snapshot.Status = response.StatusCode
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 16<<20))
_ = response.Body.Close()
if readErr != nil {
err = readErr
} else {
snapshot.Body = string(raw)
}
}
}
snapshot.Duration = time.Since(started).Milliseconds()
if err != nil {
snapshot.Error = err.Error()
}
out[i] = snapshot
}()
}
wg.Wait()
return out
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestDetectCollectorPhase(t *testing.T) {
start := StartInfo{RunID: "run1", StartAt: time.Now().Add(-time.Second)}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"start": start})
}))
defer server.Close()
client := &http.Client{Timeout: time.Second}
if got := detectCollectorPhase(context.Background(), client, "before", server.URL, ""); got != "during" {
t.Fatalf("phase=%s", got)
}
output := filepath.Join(t.TempDir(), "coordinator.json")
if err := os.WriteFile(output, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if got := detectCollectorPhase(context.Background(), client, "during", server.URL, output); got != "after" {
t.Fatalf("phase=%s", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"tenant": "006",
"http_base": "https://im-api.example.com",
"api_key_env": "IM_API_KEY",
"password_env": "IM_LOAD_PASSWORD",
"verification_code_env": "IM_VERIFICATION_CODE",
"registration_phone_prefix": "",
"users_per_machine": 1000,
"endpoints": {
"a": ["wss://im-ws-pod-a.example.com/v0/channels"],
"b": ["wss://im-ws-pod-b.example.com/v0/channels"]
},
"insecure_tls": false,
"prepare_workers": 20,
"connect_ramp": "20s",
"reconnect_workers": 8,
"reconnect_min_wait": "2s",
"reconnect_max_wait": "30s",
"command_timeout": "20s",
"ws_ping_interval": "20s",
"preflight_workers": 32,
"skip_p2p_preflight": false,
"history_workers": 12,
"history_page_size": 10,
"history_query_interval": "500ms",
"history_page_wait": "3s",
"history_settle": "5m",
"history_timeout": "3m",
"close_ramp": "10s",
"p2p_messages_per_user": 10,
"p2p_interval": "1s",
"group_senders_per_machine": 500,
"group_rounds": 2,
"group_publish_interval": "100ms",
"mixed_p2p_users_per_machine": 300,
"mixed_group_senders_per_machine": 250,
"idle_duration": "15m",
"drain_p2p": "30s",
"drain_group": "60s",
"delivery_quiet_period": "10s",
"delivery_quiet_timeout": "3m"
}
@@ -0,0 +1,46 @@
{
"tenant": "006",
"http_base": "https://im-api.example.com",
"api_key_env": "IM_API_KEY",
"password_env": "IM_LOAD_PASSWORD",
"verification_code_env": "IM_VERIFICATION_CODE",
"registration_phone_prefix": "",
"users_per_machine": 600,
"endpoints": {
"a": ["wss://im-api.example.com/v0/channels"],
"b": ["wss://im-api.example.com/v0/channels"]
},
"dial_ips": {
"a": [],
"b": []
},
"insecure_tls": false,
"prepare_workers": 20,
"connect_ramp": "30s",
"reconnect_workers": 8,
"reconnect_min_wait": "2s",
"reconnect_max_wait": "30s",
"command_timeout": "90s",
"ws_ping_interval": "20s",
"preflight_workers": 32,
"skip_p2p_preflight": false,
"history_workers": 12,
"history_page_size": 10,
"history_query_interval": "500ms",
"history_page_wait": "3s",
"history_settle": "5m",
"history_timeout": "3m",
"close_ramp": "60s",
"p2p_messages_per_user": 10,
"p2p_interval": "1s",
"group_senders_per_machine": 300,
"group_rounds": 2,
"group_publish_interval": "100ms",
"mixed_p2p_users_per_machine": 180,
"mixed_group_senders_per_machine": 150,
"idle_duration": "15m",
"drain_p2p": "30s",
"drain_group": "60s",
"delivery_quiet_period": "10s",
"delivery_quiet_timeout": "3m"
}
+594
View File
@@ -0,0 +1,594 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type StartInfo struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
StartAt time.Time `json:"start_at"`
Committed bool `json:"committed"`
}
type AgentReady struct {
Machine string `json:"machine"`
ReadyConnections int `json:"ready_connections"`
ExpectedConnections int `json:"expected_connections"`
Armed bool `json:"armed"`
}
type AgentStarted struct {
Machine string `json:"machine"`
ScheduledAt time.Time `json:"scheduled_at"`
}
type StartGate struct {
Proceed bool `json:"proceed"`
Error string `json:"error,omitempty"`
ArrivedAgents int `json:"arrived_agents"`
ExpectedAgents int `json:"expected_agents"`
}
const maxAgentStartSkew = 3 * time.Second
type coordinatorState struct {
mu sync.Mutex
start StartInfo
startDelay time.Duration
commitDelay time.Duration
expectedAgents int
ready map[string]AgentReady
armed map[string]bool
started map[string]time.Time
startError string
ledgers map[string][]AcceptedMessage
observed map[string][]AcceptedMessage
history map[string][]string
summaries map[string]AgentSummary
complete chan struct{}
completeOnce sync.Once
}
func runCoordinator(ctx context.Context, listen, runID, scenario, output string, expectedAgents int, startDelay time.Duration) error {
if !runIDPattern.MatchString(runID) {
return fmt.Errorf("run-id must match %s", runIDPattern.String())
}
if !validScenario(scenario) {
return fmt.Errorf("unsupported scenario %q", scenario)
}
if expectedAgents <= 0 {
expectedAgents = 2
}
state := &coordinatorState{
start: StartInfo{RunID: runID, Scenario: scenario}, startDelay: startDelay, commitDelay: 5 * time.Second,
expectedAgents: expectedAgents,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
observed: make(map[string][]AcceptedMessage), history: make(map[string][]string),
armed: make(map[string]bool), started: make(map[string]time.Time),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/started", state.handleStarted)
mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory)
mux.HandleFunc("/v1/summary", state.handleSummary)
mux.HandleFunc("/v1/status", state.handleStatus)
server := &http.Server{Addr: listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
errCh := make(chan error, 1)
go func() {
log.Printf("coordinator listening=%s run_id=%s scenario=%s expected_agents=%d", listen, runID, scenario, expectedAgents)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
select {
case <-ctx.Done():
case err := <-errCh:
return err
case <-state.complete:
log.Printf("all agent summaries received")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
if output != "" {
if err := state.writeOutput(output); err != nil {
return err
}
}
return nil
}
func (s *coordinatorState) handleHistory(w http.ResponseWriter, r *http.Request) {
machine := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("machine")))
switch r.Method {
case http.MethodPost:
if machine != "a" && machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
return
}
var messageIDs []string
if !decodeLimitedJSON(w, r, &messageIDs) {
return
}
s.mu.Lock()
if s.history == nil {
s.history = make(map[string][]string)
}
s.history[machine] = messageIDs
count := len(s.history)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]int{"submitted_agents": count})
case http.MethodGet:
s.mu.Lock()
defer s.mu.Unlock()
if len(s.history) < s.expectedAgents {
writeJSON(w, http.StatusAccepted, map[string]int{"submitted_agents": len(s.history), "expected_agents": s.expectedAgents})
return
}
unique := make(map[string]bool)
for _, messageIDs := range s.history {
for _, mid := range messageIDs {
if mid != "" {
unique[mid] = true
}
}
}
merged := make([]string, 0, len(unique))
for mid := range unique {
merged = append(merged, mid)
}
sort.Strings(merged)
writeJSON(w, http.StatusOK, merged)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *coordinatorState) handleObserved(w http.ResponseWriter, r *http.Request) {
machine := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("machine")))
switch r.Method {
case http.MethodPost:
if machine != "a" && machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
return
}
var messages []AcceptedMessage
if !decodeLimitedJSON(w, r, &messages) {
return
}
s.mu.Lock()
if s.observed == nil {
s.observed = make(map[string][]AcceptedMessage)
}
s.observed[machine] = messages
count := len(s.observed)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]int{"submitted_agents": count})
case http.MethodGet:
s.mu.Lock()
defer s.mu.Unlock()
if len(s.observed) < s.expectedAgents {
writeJSON(w, http.StatusAccepted, map[string]int{"submitted_agents": len(s.observed), "expected_agents": s.expectedAgents})
return
}
var merged []AcceptedMessage
for _, messages := range s.observed {
merged = append(merged, messages...)
}
merged = mergeMessageLedgers(nil, merged)
writeJSON(w, http.StatusOK, merged)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *coordinatorState) handleReady(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var ready AgentReady
if !decodeLimitedJSON(w, r, &ready) || (ready.Machine != "a" && ready.Machine != "b") ||
ready.ExpectedConnections <= 0 || ready.ReadyConnections < 0 || ready.ReadyConnections > ready.ExpectedConnections {
if ready.Machine != "a" && ready.Machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
} else {
http.Error(w, "invalid connection counts", http.StatusBadRequest)
}
return
}
s.mu.Lock()
s.ready[ready.Machine] = ready
now := time.Now().UTC()
allStable := len(s.ready) == s.expectedAgents
for _, agent := range s.ready {
if agent.ReadyConnections != agent.ExpectedConnections {
allStable = false
break
}
}
if !allStable && (!s.start.Committed || now.Before(s.start.StartAt)) {
log.Printf("agent readiness dropped before start; resetting start barrier")
s.start.StartAt = time.Time{}
s.start.Committed = false
clear(s.armed)
clear(s.started)
s.startError = ""
}
if allStable && s.start.StartAt.IsZero() {
s.start.StartAt = now.Add(s.startDelay)
s.start.Committed = false
clear(s.armed)
clear(s.started)
s.startError = ""
log.Printf("all agents ready; validation_at=%s", s.start.StartAt.Format(time.RFC3339Nano))
}
if allStable && !s.start.Committed && ready.Armed && !now.Before(s.start.StartAt) {
s.armed[ready.Machine] = true
}
if allStable && !s.start.Committed && len(s.armed) == s.expectedAgents {
delay := s.commitDelay
if delay <= 0 {
delay = 5 * time.Second
}
s.start.StartAt = now.Add(delay)
s.start.Committed = true
log.Printf("all agents validated; committed start_at=%s", s.start.StartAt.Format(time.RFC3339Nano))
}
start := s.start
s.mu.Unlock()
writeJSON(w, http.StatusOK, start)
}
func (s *coordinatorState) handleStarted(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var arrival AgentStarted
if !decodeLimitedJSON(w, r, &arrival) || (arrival.Machine != "a" && arrival.Machine != "b") || arrival.ScheduledAt.IsZero() {
http.Error(w, "invalid start arrival", http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.start.Committed || !arrival.ScheduledAt.Equal(s.start.StartAt) {
writeJSON(w, http.StatusOK, StartGate{Error: "start schedule changed", ExpectedAgents: s.expectedAgents})
return
}
if s.started == nil {
s.started = make(map[string]time.Time)
}
if _, exists := s.started[arrival.Machine]; !exists {
now := time.Now().UTC()
s.started[arrival.Machine] = now
skew := now.Sub(s.start.StartAt)
if skew < -maxAgentStartSkew || skew > maxAgentStartSkew {
s.startError = fmt.Sprintf("agent %s reached start barrier %s from schedule (limit %s)",
arrival.Machine, skew.Round(time.Millisecond), maxAgentStartSkew)
log.Printf("start barrier rejected: %s", s.startError)
}
}
gate := StartGate{
Proceed: len(s.started) == s.expectedAgents && s.startError == "", Error: s.startError,
ArrivedAgents: len(s.started), ExpectedAgents: s.expectedAgents,
}
writeJSON(w, http.StatusOK, gate)
}
func (s *coordinatorState) handleStart(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if s.start.StartAt.IsZero() {
writeJSON(w, http.StatusAccepted, map[string]any{"ready_agents": len(s.ready), "expected_agents": s.expectedAgents})
return
}
writeJSON(w, http.StatusOK, s.start)
}
func (s *coordinatorState) handleLedger(w http.ResponseWriter, r *http.Request) {
machine := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("machine")))
switch r.Method {
case http.MethodPost:
if machine != "a" && machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
return
}
var messages []AcceptedMessage
if !decodeLimitedJSON(w, r, &messages) {
return
}
s.mu.Lock()
s.ledgers[machine] = messages
count := len(s.ledgers)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]int{"submitted_agents": count})
case http.MethodGet:
s.mu.Lock()
defer s.mu.Unlock()
if len(s.ledgers) < s.expectedAgents {
writeJSON(w, http.StatusAccepted, map[string]int{"submitted_agents": len(s.ledgers), "expected_agents": s.expectedAgents})
return
}
var merged []AcceptedMessage
for _, messages := range s.ledgers {
merged = append(merged, messages...)
}
sort.Slice(merged, func(i, j int) bool { return merged[i].ClientMid < merged[j].ClientMid })
writeJSON(w, http.StatusOK, merged)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *coordinatorState) handleSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var summary AgentSummary
if !decodeLimitedJSON(w, r, &summary) {
return
}
if summary.Machine != "a" && summary.Machine != "b" {
http.Error(w, "invalid machine", http.StatusBadRequest)
return
}
s.mu.Lock()
s.summaries[summary.Machine] = summary
complete := len(s.summaries) == s.expectedAgents
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"complete": complete})
if complete {
s.completeOnce.Do(func() {
go func() {
time.Sleep(500 * time.Millisecond)
close(s.complete)
}()
})
}
}
func (s *coordinatorState) handleStatus(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{
"start": s.start, "ready": s.ready, "armed": s.armed, "started": s.started,
"start_error": s.startError,
"ledger_agents": len(s.ledgers), "observed_agents": len(s.observed),
"history_agents": len(s.history), "summary_agents": len(s.summaries),
})
}
func (s *coordinatorState) writeOutput(path string) error {
s.mu.Lock()
data := struct {
Start StartInfo `json:"start"`
Ready map[string]AgentReady `json:"ready"`
Started map[string]time.Time `json:"started"`
StartError string `json:"start_error,omitempty"`
Ledgers map[string][]AcceptedMessage `json:"ledgers"`
Observed map[string][]AcceptedMessage `json:"observed"`
History map[string][]string `json:"history"`
Summaries map[string]AgentSummary `json:"summaries"`
}{
Start: s.start, Ready: s.ready, Started: s.started, StartError: s.startError,
Ledgers: s.ledgers, Observed: s.observed,
History: s.history, Summaries: s.summaries,
}
s.mu.Unlock()
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(path), 0o750); err != nil && filepath.Dir(path) != "." {
return err
}
return os.WriteFile(path, append(raw, '\n'), 0o600)
}
func decodeLimitedJSON(w http.ResponseWriter, r *http.Request, output any) bool {
dec := json.NewDecoder(io.LimitReader(r.Body, 32<<20))
if err := dec.Decode(output); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
type coordinatorClient struct {
base string
client *http.Client
}
func newCoordinatorClient(base string) *coordinatorClient {
return &coordinatorClient{base: strings.TrimRight(base, "/"), client: &http.Client{Timeout: 35 * time.Second}}
}
func (c *coordinatorClient) ready(ctx context.Context, current func() AgentReady) (StartInfo, error) {
var start StartInfo
armed := false
for {
state := current()
state.Armed = armed
if _, err := c.request(ctx, http.MethodPost, "/v1/ready", state, &start); err != nil {
return start, err
}
if start.StartAt.IsZero() {
armed = false
} else {
remaining := time.Until(start.StartAt)
if start.Committed && remaining <= 0 {
return start, nil
}
if !start.Committed && remaining <= 0 {
armed = true
continue
}
if remaining < time.Second {
if err := waitContext(ctx, remaining); err != nil {
return start, err
}
continue
}
}
if err := waitContext(ctx, time.Second); err != nil {
return start, err
}
}
}
func (c *coordinatorClient) submitLedger(ctx context.Context, machine string, messages []AcceptedMessage) error {
_, err := c.request(ctx, http.MethodPost, "/v1/ledger?machine="+machine, messages, nil)
return err
}
func (c *coordinatorClient) started(ctx context.Context, machine string, scheduledAt time.Time) error {
arrival := AgentStarted{Machine: machine, ScheduledAt: scheduledAt}
for {
var gate StartGate
if _, err := c.request(ctx, http.MethodPost, "/v1/started", arrival, &gate); err != nil {
return err
}
if gate.Error != "" {
return errors.New(gate.Error)
}
if gate.Proceed {
return nil
}
if err := waitContext(ctx, 100*time.Millisecond); err != nil {
return err
}
}
}
func (c *coordinatorClient) ledger(ctx context.Context) ([]AcceptedMessage, error) {
for {
var messages []AcceptedMessage
status, err := c.request(ctx, http.MethodGet, "/v1/ledger", nil, &messages)
if err != nil {
return nil, err
}
if status == http.StatusOK {
return messages, nil
}
if err = waitContext(ctx, time.Second); err != nil {
return nil, err
}
}
}
func (c *coordinatorClient) submitObserved(ctx context.Context, machine string, messages []AcceptedMessage) error {
_, err := c.request(ctx, http.MethodPost, "/v1/observed?machine="+machine, messages, nil)
return err
}
func (c *coordinatorClient) observed(ctx context.Context) ([]AcceptedMessage, error) {
for {
var messages []AcceptedMessage
status, err := c.request(ctx, http.MethodGet, "/v1/observed", nil, &messages)
if err != nil {
return nil, err
}
if status == http.StatusOK {
return messages, nil
}
if err = waitContext(ctx, time.Second); err != nil {
return nil, err
}
}
}
func (c *coordinatorClient) submitHistory(ctx context.Context, machine string, messageIDs []string) error {
_, err := c.request(ctx, http.MethodPost, "/v1/history?machine="+machine, messageIDs, nil)
return err
}
func (c *coordinatorClient) history(ctx context.Context) ([]string, error) {
for {
var messageIDs []string
status, err := c.request(ctx, http.MethodGet, "/v1/history", nil, &messageIDs)
if err != nil {
return nil, err
}
if status == http.StatusOK {
return messageIDs, nil
}
if err = waitContext(ctx, time.Second); err != nil {
return nil, err
}
}
}
func (c *coordinatorClient) submitSummary(ctx context.Context, summary AgentSummary) error {
_, err := c.request(ctx, http.MethodPost, "/v1/summary", summary, nil)
return err
}
func (c *coordinatorClient) request(ctx context.Context, method, path string, input, output any) (int, error) {
var body io.Reader
if input != nil {
raw, err := json.Marshal(input)
if err != nil {
return 0, err
}
body = strings.NewReader(string(raw))
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
if err != nil {
return 0, err
}
if input != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return resp.StatusCode, fmt.Errorf("coordinator HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
if output != nil && resp.StatusCode == http.StatusOK {
if err = json.NewDecoder(resp.Body).Decode(output); err != nil {
return resp.StatusCode, err
}
}
return resp.StatusCode, nil
}
func waitContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCoordinatorResetsBarrierWhenConnectionCountDrops(t *testing.T) {
state := &coordinatorState{
start: StartInfo{RunID: "run-reset", Scenario: "group"}, startDelay: time.Minute,
commitDelay: time.Second, expectedAgents: 2, ready: make(map[string]AgentReady),
armed: make(map[string]bool), ledgers: make(map[string][]AcceptedMessage),
started: make(map[string]time.Time),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
}
postReady := func(ready AgentReady) StartInfo {
raw, err := json.Marshal(ready)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/ready", bytes.NewReader(raw))
w := httptest.NewRecorder()
state.handleReady(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ready status=%d body=%s", w.Code, w.Body.String())
}
var start StartInfo
if err = json.Unmarshal(w.Body.Bytes(), &start); err != nil {
t.Fatal(err)
}
return start
}
fullA := AgentReady{Machine: "a", ReadyConnections: 1000, ExpectedConnections: 1000}
fullB := AgentReady{Machine: "b", ReadyConnections: 1000, ExpectedConnections: 1000}
postReady(fullA)
if start := postReady(fullB); start.StartAt.IsZero() {
t.Fatal("full agents must create a validation barrier")
}
fullA.ReadyConnections = 999
if start := postReady(fullA); !start.StartAt.IsZero() || start.Committed {
t.Fatalf("connection drop must reset barrier: %+v", start)
}
}
func TestCoordinatorClientWaitsForBothAgentsAndMergesLedger(t *testing.T) {
state := &coordinatorState{
start: StartInfo{RunID: "run1", Scenario: "p2p"}, startDelay: 50 * time.Millisecond,
commitDelay: 50 * time.Millisecond, expectedAgents: 2,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
aDone := make(chan StartInfo, 1)
go func() {
start, _ := client.ready(context.Background(), func() AgentReady {
return AgentReady{Machine: "a", ReadyConnections: 1000, ExpectedConnections: 1000}
})
aDone <- start
}()
time.Sleep(50 * time.Millisecond)
startB, err := client.ready(context.Background(), func() AgentReady {
return AgentReady{Machine: "b", ReadyConnections: 1000, ExpectedConnections: 1000}
})
if err != nil || startB.StartAt.IsZero() || !startB.Committed {
t.Fatalf("machine B ready: start=%+v err=%v", startB, err)
}
select {
case startA := <-aDone:
if !startA.StartAt.Equal(startB.StartAt) {
t.Fatalf("different start times A=%s B=%s", startA.StartAt, startB.StartAt)
}
case <-time.After(2 * time.Second):
t.Fatal("machine A did not receive start barrier")
}
if err = client.submitLedger(context.Background(), "a", []AcceptedMessage{{ClientMid: "b"}}); err != nil {
t.Fatal(err)
}
if err = client.submitLedger(context.Background(), "b", []AcceptedMessage{{ClientMid: "a"}}); err != nil {
t.Fatal(err)
}
ledger, err := client.ledger(context.Background())
if err != nil || len(ledger) != 2 || ledger[0].ClientMid != "a" || ledger[1].ClientMid != "b" {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
if err = client.submitObserved(context.Background(), "a", []AcceptedMessage{{ClientMid: "unknown", Seq: 10}}); err != nil {
t.Fatal(err)
}
if err = client.submitObserved(context.Background(), "b", []AcceptedMessage{{ClientMid: "unknown", Seq: 10}}); err != nil {
t.Fatal(err)
}
observed, err := client.observed(context.Background())
if err != nil || len(observed) != 1 || observed[0].ClientMid != "unknown" {
t.Fatalf("observed=%+v err=%v", observed, err)
}
if err = client.submitHistory(context.Background(), "a", []string{"mid-2", "mid-1"}); err != nil {
t.Fatal(err)
}
if err = client.submitHistory(context.Background(), "b", []string{"mid-2", "mid-3"}); err != nil {
t.Fatal(err)
}
history, err := client.history(context.Background())
if err != nil || len(history) != 3 || history[0] != "mid-1" || history[1] != "mid-2" || history[2] != "mid-3" {
t.Fatalf("history=%+v err=%v", history, err)
}
}
func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/started", state.handleStarted)
mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory)
mux.HandleFunc("/v1/summary", state.handleSummary)
mux.HandleFunc("/v1/status", state.handleStatus)
return mux
}
func TestCoordinatorRejectsLateAgentForBothMachines(t *testing.T) {
startAt := time.Now().Add(-maxAgentStartSkew - time.Second).UTC()
state := &coordinatorState{
start: StartInfo{RunID: "run-late", Scenario: "group", StartAt: startAt, Committed: true},
expectedAgents: 2, started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
if err := client.started(context.Background(), "b", startAt); err == nil {
t.Fatal("late agent must be rejected")
}
if err := client.started(context.Background(), "a", startAt); err == nil {
t.Fatal("the other agent must observe the same rejected run")
}
}
func TestCoordinatorReleasesAgentsOnlyAfterBothReachStart(t *testing.T) {
startAt := time.Now().UTC()
state := &coordinatorState{
start: StartInfo{RunID: "run-synced", Scenario: "group", StartAt: startAt, Committed: true},
expectedAgents: 2, started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
aDone := make(chan error, 1)
go func() { aDone <- client.started(context.Background(), "a", startAt) }()
time.Sleep(50 * time.Millisecond)
select {
case err := <-aDone:
t.Fatalf("machine A passed before B arrived: %v", err)
default:
}
if err := client.started(context.Background(), "b", startAt); err != nil {
t.Fatalf("machine B start: %v", err)
}
select {
case err := <-aDone:
if err != nil {
t.Fatalf("machine A start: %v", err)
}
case <-time.After(time.Second):
t.Fatal("machine A was not released")
}
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
_ "github.com/go-sql-driver/mysql"
)
type dbVerifyResult struct {
RunID string `json:"run_id"`
TenantCode string `json:"tenant_code"`
TenantID int64 `json:"tenant_id"`
Expected int `json:"expected"`
FoundExact int `json:"found_exact"`
Missing int `json:"missing"`
Mismatch int `json:"mismatch"`
DuplicateRows int `json:"duplicate_rows"`
Unexpected int `json:"unexpected_rows"`
Tables []string `json:"tables"`
MissingMIDs []string `json:"missing_client_mids,omitempty"`
UnexpectedMIDs []string `json:"unexpected_client_mids,omitempty"`
}
type persistedIdentity struct {
Topic string
Seq int64
}
var dbIdentifierRE = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
func runDBVerify(ctx context.Context, coordinatorPath, output, tenantCode, dsnEnv, registryDB string) error {
raw, err := os.ReadFile(coordinatorPath)
if err != nil {
return err
}
var coordinator struct {
Start StartInfo `json:"start"`
Ledgers map[string][]AcceptedMessage `json:"ledgers"`
Observed map[string][]AcceptedMessage `json:"observed"`
}
if err = json.Unmarshal(raw, &coordinator); err != nil {
return fmt.Errorf("decode coordinator: %w", err)
}
if coordinator.Start.RunID == "" {
return fmt.Errorf("coordinator has no run id")
}
dsn := strings.TrimSpace(os.Getenv(dsnEnv))
if dsn == "" {
return fmt.Errorf("environment variable %s is required", dsnEnv)
}
if registryDB == "" {
registryDB = strings.TrimSpace(os.Getenv("TENANT_REGISTRY_DB"))
}
if registryDB == "" {
registryDB = "meeting_im"
}
if !dbIdentifierRE.MatchString(registryDB) {
return fmt.Errorf("invalid registry database identifier")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
if err = db.PingContext(ctx); err != nil {
return err
}
var tenantID int64
query := fmt.Sprintf("SELECT id FROM `%s`.im_tenant WHERE code=?", registryDB)
if err = db.QueryRowContext(ctx, query, tenantCode).Scan(&tenantID); err != nil {
return err
}
expected := expectedDBMessages(coordinator.Start.RunID, coordinator.Ledgers, coordinator.Observed)
months := make(map[string]struct{})
for _, message := range expected {
if message.Kind == "group" {
months[message.SentAt.UTC().Format("200601")] = struct{}{}
}
}
tables := []string{fmt.Sprintf("im_%03d_messages", tenantID)}
for month := range months {
tables = append(tables, fmt.Sprintf("im_%03d_group_messages_%s", tenantID, month))
}
sort.Strings(tables)
actual := make(map[string][]persistedIdentity)
for _, table := range tables {
if !dbIdentifierRE.MatchString(table) {
return fmt.Errorf("invalid generated table name %q", table)
}
rows, queryErr := db.QueryContext(ctx, fmt.Sprintf(
"SELECT client_msg_id,topic,seqid FROM `%s` WHERE tenant_id=? AND client_msg_id LIKE ?", table),
tenantID, coordinator.Start.RunID+"-%")
if queryErr != nil {
return fmt.Errorf("query %s: %w", table, queryErr)
}
for rows.Next() {
var mid, topic string
var seq int64
if err = rows.Scan(&mid, &topic, &seq); err != nil {
_ = rows.Close()
return err
}
actual[mid] = append(actual[mid], persistedIdentity{Topic: topic, Seq: seq})
}
if err = rows.Close(); err != nil {
return err
}
}
result := dbVerifyResult{RunID: coordinator.Start.RunID, TenantCode: tenantCode, TenantID: tenantID,
Expected: len(expected), Tables: tables}
for mid, want := range expected {
rows := actual[mid]
if len(rows) == 0 {
result.Missing++
result.MissingMIDs = append(result.MissingMIDs, mid)
continue
}
matched := false
for _, got := range rows {
if got.Topic == want.Topic && got.Seq == want.Seq {
matched = true
}
}
if matched {
result.FoundExact++
} else {
result.Mismatch++
}
if len(rows) > 1 {
result.DuplicateRows += len(rows) - 1
}
}
for mid := range actual {
if _, ok := expected[mid]; !ok {
result.Unexpected++
result.UnexpectedMIDs = append(result.UnexpectedMIDs, mid)
}
}
sort.Strings(result.MissingMIDs)
sort.Strings(result.UnexpectedMIDs)
resultRaw, _ := json.MarshalIndent(result, "", " ")
if output == "" || output == "-" {
if _, err = fmt.Fprintln(os.Stdout, string(resultRaw)); err != nil {
return err
}
} else {
if err = writeJSONFile(output, result); err != nil {
return err
}
}
if result.Missing != 0 || result.Mismatch != 0 || result.DuplicateRows != 0 || result.Unexpected != 0 {
return fmt.Errorf("database verification failed: missing=%d mismatch=%d duplicates=%d unexpected=%d",
result.Missing, result.Mismatch, result.DuplicateRows, result.Unexpected)
}
return nil
}
func expectedDBMessages(runID string, ledgers, observed map[string][]AcceptedMessage) map[string]AcceptedMessage {
all := make([]AcceptedMessage, 0)
for _, messages := range observed {
all = append(all, messages...)
}
for _, messages := range ledgers {
all = append(all, messages...)
}
expected := make(map[string]AcceptedMessage, len(all))
for _, message := range mergeMessageLedgers(nil, all) {
if message.RunID == runID && message.ClientMid != "" {
expected[message.ClientMid] = message
}
}
return expected
}
+24
View File
@@ -0,0 +1,24 @@
package main
import "testing"
func TestExpectedDBMessagesIncludesObservedWithoutAck(t *testing.T) {
ledgers := map[string][]AcceptedMessage{
"a": {{RunID: "run-1", ClientMid: "acked", Topic: "grp-one", Seq: 1}},
}
observed := map[string][]AcceptedMessage{
"a": {
{RunID: "run-1", ClientMid: "unacked", Topic: "grp-one", Seq: 2},
{RunID: "other", ClientMid: "foreign", Topic: "grp-one", Seq: 3},
},
"b": {{RunID: "run-1", ClientMid: "unacked", Topic: "grp-one", Seq: 2}},
}
expected := expectedDBMessages("run-1", ledgers, observed)
if len(expected) != 2 {
t.Fatalf("expected messages=%d want=2: %+v", len(expected), expected)
}
if expected["acked"].Seq != 1 || expected["unacked"].Seq != 2 {
t.Fatalf("unexpected merged messages: %+v", expected)
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"sort"
"sync/atomic"
"time"
)
var latencyBounds = []time.Duration{
time.Millisecond, 3 * time.Millisecond, 5 * time.Millisecond, 10 * time.Millisecond,
25 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond,
500 * time.Millisecond, time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second,
20 * time.Second, 30 * time.Second, 45 * time.Second, 60 * time.Second, 90 * time.Second, 120 * time.Second,
}
type DurationHistogram struct {
count atomic.Int64
sum atomic.Int64
max atomic.Int64
bucket []atomic.Int64
}
type HistogramSnapshot struct {
Count int64 `json:"count"`
SumMS float64 `json:"sum_ms"`
MaxMS float64 `json:"max_ms"`
Buckets map[string]int64 `json:"buckets"`
}
func NewDurationHistogram() *DurationHistogram {
return &DurationHistogram{bucket: make([]atomic.Int64, len(latencyBounds)+1)}
}
func (h *DurationHistogram) Observe(value time.Duration) {
if value < 0 {
return
}
nanos := int64(value)
h.count.Add(1)
h.sum.Add(nanos)
for previous := h.max.Load(); nanos > previous; previous = h.max.Load() {
if h.max.CompareAndSwap(previous, nanos) {
break
}
}
idx := sort.Search(len(latencyBounds), func(i int) bool { return value <= latencyBounds[i] })
h.bucket[idx].Add(1)
}
func (h *DurationHistogram) Snapshot() HistogramSnapshot {
buckets := make(map[string]int64, len(h.bucket))
for i := range h.bucket {
label := "+Inf"
if i < len(latencyBounds) {
label = latencyBounds[i].String()
}
buckets[label] = h.bucket[i].Load()
}
return HistogramSnapshot{
Count: h.count.Load(), SumMS: float64(h.sum.Load()) / float64(time.Millisecond),
MaxMS: float64(h.max.Load()) / float64(time.Millisecond), Buckets: buckets,
}
}
+308
View File
@@ -0,0 +1,308 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"time"
)
type apiClient struct {
baseURL string
apiKey string
tenant string
client *http.Client
seq atomic.Uint64
}
type apiEnvelope struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Reason string `json:"reason"`
Message string `json:"message"`
RequestID string `json:"request_id"`
}
type apiError struct {
Status int
Code int
Reason string
Message string
}
func (e *apiError) Error() string {
return fmt.Sprintf("HTTP %d code=%d reason=%s message=%s", e.Status, e.Code, e.Reason, e.Message)
}
func newAPIClient(cfg *Config, apiKey string) *apiClient {
transport := http.DefaultTransport.(*http.Transport).Clone()
if cfg.InsecureTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
}
return &apiClient{
baseURL: strings.TrimRight(cfg.HTTPBase, "/"), apiKey: apiKey, tenant: cfg.Tenant,
client: &http.Client{Timeout: 2 * time.Minute, Transport: transport},
}
}
func (a *apiClient) do(ctx context.Context, method, path, token, idempotencyKey string, body any, output any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenant)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", fmt.Sprintf("loadtest-%d-%d", time.Now().UnixNano(), a.seq.Add(1)))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
var envelope apiEnvelope
if err = json.Unmarshal(raw, &envelope); err != nil {
return fmt.Errorf("decode HTTP %d response: %w", resp.StatusCode, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 {
return &apiError{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message}
}
if output != nil && len(envelope.Data) > 0 {
if err = json.Unmarshal(envelope.Data, output); err != nil {
return fmt.Errorf("decode response data: %w", err)
}
}
return nil
}
type privacyResponse struct {
FriendRequestVerification bool `json:"friend_request_verification"`
GroupInviteVerification bool `json:"group_invite_verification"`
Version int `json:"version"`
}
type registrationCredential struct {
ChallengeID string
VerificationToken string
}
func (a *apiClient) createRegistrationCredential(ctx context.Context, account AccountRecord, code string) (*registrationCredential, error) {
if account.Phone == "" {
return nil, nil
}
var challenge struct {
ChallengeID string `json:"challenge_id"`
}
key := fmt.Sprintf("loadtest-verification-%s-%d", account.Username, time.Now().UnixNano())
body := map[string]any{"scene": "register", "registration_type": 0, "tenant_code": a.tenant, "phone": account.Phone}
if err := a.do(ctx, http.MethodPost, "/v1/im/auth/verification-codes", "", key, body, &challenge); err != nil {
return nil, fmt.Errorf("create verification for %s: %w", account.Username, err)
}
if challenge.ChallengeID == "" {
return nil, fmt.Errorf("create verification for %s: missing challenge_id", account.Username)
}
var verified struct {
VerificationToken string `json:"verification_token"`
}
key = fmt.Sprintf("loadtest-verify-%s-%d", account.Username, time.Now().UnixNano())
path := "/v1/im/auth/verification-codes/" + url.PathEscape(challenge.ChallengeID) + "/verify"
if err := a.do(ctx, http.MethodPost, path, "", key, map[string]any{"code": code}, &verified); err != nil {
return nil, fmt.Errorf("verify code for %s: %w", account.Username, err)
}
if verified.VerificationToken == "" {
return nil, fmt.Errorf("verify code for %s: missing verification_token", account.Username)
}
return &registrationCredential{ChallengeID: challenge.ChallengeID, VerificationToken: verified.VerificationToken}, nil
}
func (a *apiClient) disableGroupInviteVerification(ctx context.Context, account AccountRecord) error {
var privacy privacyResponse
if err := a.do(ctx, http.MethodGet, "/v1/im/me/privacy", account.Token, "", nil, &privacy); err != nil {
return err
}
if !privacy.GroupInviteVerification {
return nil
}
body := map[string]any{"group_invite_verification": false, "version": privacy.Version}
return a.do(ctx, http.MethodPatch, "/v1/im/me/privacy", account.Token,
"loadtest-privacy-"+account.Username, body, &privacy)
}
type friendRequestResponse struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Topic string `json:"topic"`
}
type friendRequestListResponse struct {
Items []struct {
RequestID string `json:"request_id"`
ApplicantID string `json:"applicant_id"`
Status string `json:"status"`
} `json:"items"`
NextCursor string `json:"next_cursor"`
}
func (a *apiClient) findPendingFriendRequest(ctx context.Context, target AccountRecord, applicantID string) (string, error) {
cursor := ""
for {
path := "/v1/im/friend-requests?direction=inbound&status=pending&limit=100"
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
var page friendRequestListResponse
if err := a.do(ctx, http.MethodGet, path, target.Token, "", nil, &page); err != nil {
return "", err
}
for _, item := range page.Items {
if item.ApplicantID == applicantID && item.Status == "pending" {
return item.RequestID, nil
}
}
if page.NextCursor == "" || page.NextCursor == cursor {
return "", nil
}
cursor = page.NextCursor
}
}
func (a *apiClient) createFriendRequest(ctx context.Context, source, target AccountRecord) (*friendRequestResponse, error) {
var result friendRequestResponse
body := map[string]any{"target_user_id": target.UserID, "message": "loadtest", "source": "search", "source_topic": ""}
idempotencyKey := fmt.Sprintf("loadtest-friend-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index)
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests", source.Token,
idempotencyKey, body, &result)
return &result, err
}
func (a *apiClient) acceptFriendRequest(ctx context.Context, target AccountRecord, requestID string) (*friendRequestResponse, error) {
var result friendRequestResponse
idempotencyKey := fmt.Sprintf("loadtest-friend-accept-%d", time.Now().UnixNano())
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests/"+url.PathEscape(requestID)+"/accept", target.Token,
idempotencyKey, map[string]any{}, &result)
return &result, err
}
type p2pResponse struct {
Topic string `json:"topic"`
Relationship string `json:"relationship"`
}
func (a *apiClient) ensureP2P(ctx context.Context, source, target AccountRecord) (*p2pResponse, error) {
var result p2pResponse
// EnsureP2P must execute on every preflight so it can repair subscriptions
// and refresh a loaded topic. Use a fresh key instead of replaying an old
// successful response from a previous load-test run.
idempotencyKey := fmt.Sprintf("loadtest-p2p-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index)
err := a.do(ctx, http.MethodPost, "/v1/im/conversations/p2p", source.Token,
idempotencyKey,
map[string]any{"peer_user_id": target.UserID}, &result)
return &result, err
}
type groupCreateResponse struct {
Topic string `json:"topic"`
MemberCount int `json:"member_count"`
}
func (a *apiClient) createGroup(ctx context.Context, owner AccountRecord) (*groupCreateResponse, error) {
var result groupCreateResponse
body := map[string]any{"name": "IM 006 Load Test", "description": "im-pressure", "member_user_ids": []string{}}
err := a.do(ctx, http.MethodPost, "/v1/im/groups", owner.Token, "loadtest-group-create", body, &result)
return &result, err
}
type groupInvitationItem struct {
UserID string `json:"user_id"`
Status string `json:"status"`
Reason string `json:"reason"`
}
type groupInvitationResponse struct {
Topic string `json:"topic"`
Items []groupInvitationItem `json:"items"`
}
func (a *apiClient) inviteGroup(ctx context.Context, owner AccountRecord, topic string, batch int, userIDs []string) (*groupInvitationResponse, error) {
var result groupInvitationResponse
membersHash := topicHash(strings.Join(userIDs, ","))
err := a.do(ctx, http.MethodPost, "/v1/im/groups/"+url.PathEscape(topic)+"/invitations", owner.Token,
fmt.Sprintf("loadtest-group-%s-invite-%03d-%s", topicHash(topic), batch, membersHash),
map[string]any{"user_ids": userIDs, "message": "loadtest"}, &result)
return &result, err
}
type groupMembersResponse struct {
Items []struct {
UserID string `json:"user_id"`
} `json:"items"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord, topic string) (int, error) {
members, err := a.groupMemberIDs(ctx, account, topic)
return len(members), err
}
func (a *apiClient) groupMemberIDs(ctx context.Context, account AccountRecord, topic string) (map[string]bool, error) {
cursor := ""
seen := make(map[string]bool)
for {
path := "/v1/im/groups/" + url.PathEscape(topic) + "/members?limit=100"
if cursor != "" {
path += "&cursor=" + url.QueryEscape(cursor)
}
var page groupMembersResponse
if err := a.do(ctx, http.MethodGet, path, account.Token, "", nil, &page); err != nil {
return nil, err
}
for _, item := range page.Items {
seen[item.UserID] = true
}
if page.NextCursor == "" || (!page.HasMore && len(page.Items) < 100) {
break
}
if page.NextCursor == cursor {
return nil, fmt.Errorf("group member cursor did not advance: %s", cursor)
}
cursor = page.NextCursor
}
return seen, nil
}
func queryInt(value string, fallback int) int {
n, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return n
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "prepare":
err = prepareCommand(ctx, os.Args[2:])
case "verify":
err = verifyCommand(ctx, os.Args[2:])
case "coordinator":
err = coordinatorCommand(ctx, os.Args[2:])
case "agent":
err = agentCommand(ctx, os.Args[2:])
case "merge":
err = mergeCommand(os.Args[2:])
case "collect":
err = collectCommand(ctx, os.Args[2:])
case "verify-db":
err = verifyDBCommand(ctx, os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
log.Fatal(err)
}
}
func verifyDBCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("verify-db", flag.ContinueOnError)
coordinator := fs.String("coordinator", "loadtest/im-pressure/results/coordinator.json", "coordinator ledger path")
output := fs.String("output", "-", "verification JSON output path, or - for stdout")
tenant := fs.String("tenant", "", "tenant code")
dsnEnv := fs.String("dsn-env", "MYSQL_DSN", "environment variable containing the business database DSN")
registryDB := fs.String("registry-db", "", "tenant registry database; defaults to TENANT_REGISTRY_DB")
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*tenant) == "" {
return fmt.Errorf("--tenant is required")
}
return runDBVerify(ctx, *coordinator, *output, *tenant, *dsnEnv, *registryDB)
}
func prepareCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("prepare", flag.ContinueOnError)
configPath := fs.String("config", "loadtest/im-pressure/config.example.json", "JSON configuration path")
manifestPath := fs.String("manifest", "loadtest/im-pressure/manifest.json", "manifest output path")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := loadConfig(*configPath)
if err != nil {
return err
}
return runPrepare(ctx, cfg, *manifestPath)
}
func verifyCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
configPath := fs.String("config", "loadtest/im-pressure/config.example.json", "JSON configuration path")
manifestPath := fs.String("manifest", "loadtest/im-pressure/manifest.json", "manifest path")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := loadConfig(*configPath)
if err != nil {
return err
}
manifest, err := loadManifest(*manifestPath)
if err != nil {
return err
}
apiKey, err := cfg.apiKey()
if err != nil {
return err
}
return runVerify(ctx, cfg, manifest, newAPIClient(cfg, apiKey))
}
func coordinatorCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("coordinator", flag.ContinueOnError)
listen := fs.String("listen", ":8090", "coordinator listen address")
runID := fs.String("run-id", "", "unique run ID, max 24 characters")
scenario := fs.String("scenario", "", "p2p, group, mixed, or idle")
output := fs.String("output", "loadtest/im-pressure/results/coordinator.json", "coordinator result path")
expected := fs.Int("expected-agents", 2, "number of agents")
delay := fs.Duration("start-delay", 30*time.Second, "delay after all agents are ready")
if err := fs.Parse(args); err != nil {
return err
}
return runCoordinator(ctx, *listen, *runID, *scenario, *output, *expected, *delay)
}
func agentCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
configPath := fs.String("config", "loadtest/im-pressure/config.example.json", "JSON configuration path")
manifestPath := fs.String("manifest", "loadtest/im-pressure/manifest.json", "manifest path")
machine := fs.String("machine", "", "a or b")
coordinatorURL := fs.String("coordinator", "", "coordinator base URL")
output := fs.String("output", "loadtest/im-pressure/results/agent-summary.json", "agent result path")
metricsListen := fs.String("metrics-listen", ":9105", "Prometheus listen address; empty disables it")
if err := fs.Parse(args); err != nil {
return err
}
if *coordinatorURL == "" {
return fmt.Errorf("--coordinator is required")
}
cfg, err := loadConfig(*configPath)
if err != nil {
return err
}
manifest, err := loadManifest(*manifestPath)
if err != nil {
return err
}
return runAgent(ctx, cfg, manifest, *machine, *coordinatorURL, *output, *metricsListen)
}
func mergeCommand(args []string) error {
fs := flag.NewFlagSet("merge", flag.ContinueOnError)
a := fs.String("a", "loadtest/im-pressure/results/agent-a-summary.json", "machine A summary")
b := fs.String("b", "loadtest/im-pressure/results/agent-b-summary.json", "machine B summary")
output := fs.String("output", "loadtest/im-pressure/results/report.json", "combined report")
if err := fs.Parse(args); err != nil {
return err
}
return runMerge(*a, *b, *output)
}
func collectCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("collect", flag.ContinueOnError)
var urls namedURLs
fs.Var(&urls, "url", "repeatable name=https://host/metrics source")
output := fs.String("output", "loadtest/im-pressure/results/metrics.jsonl", "snapshot output")
interval := fs.Duration("interval", 5*time.Second, "collection interval")
duration := fs.Duration("duration", 10*time.Minute, "collection duration")
coordinatorURL := fs.String("coordinator", "", "optional coordinator base URL used to label before/during phases")
coordinatorOutput := fs.String("coordinator-output", "", "coordinator JSON path used to detect the after phase")
if err := fs.Parse(args); err != nil {
return err
}
return runCollector(ctx, urls, *output, *interval, *duration, *coordinatorURL, *coordinatorOutput)
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: im-pressure <prepare|verify|coordinator|agent|merge|collect|verify-db> [flags]")
}
+279
View File
@@ -0,0 +1,279 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type CombinedReport struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
GeneratedAt time.Time `json:"generated_at"`
Agents []AgentSummary `json:"agents"`
ExpectedConnections int `json:"expected_connections"`
ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"`
PublishLateAccepted int64 `json:"publish_late_accepted"`
PublishFinalAccepted int64 `json:"publish_final_accepted"`
PublishRejected int64 `json:"publish_rejected"`
PublishExplicitReject int64 `json:"publish_explicitly_rejected"`
PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
PublishCtrlObserved int64 `json:"publish_ctrl_observed"`
PublishCtrlOver20 int64 `json:"publish_ctrl_over_20s"`
PublishLateCtrl int64 `json:"publish_ctrl_late"`
PublishCtrlUnresolved int64 `json:"publish_ctrl_unresolved"`
PublishCtrlP95MS float64 `json:"publish_ctrl_p95_ms"`
CtrlObservationSamples []CtrlObservationSample `json:"ctrl_observation_samples,omitempty"`
PublishSuccessRate float64 `json:"publish_success_rate"`
AckP95MS float64 `json:"ack_p95_ms"`
DeliveryP95MS float64 `json:"delivery_p95_ms"`
DeliveryLocalP95MS float64 `json:"delivery_local_p95_ms"`
DeliveryCrossP95MS float64 `json:"delivery_cross_machine_p95_ms"`
CrossClockSkewSamples int64 `json:"cross_machine_negative_latency_samples"`
ExpectedDeliveries int64 `json:"expected_deliveries"`
ReceivedDeliveries int64 `json:"received_deliveries"`
RealtimeLost int64 `json:"realtime_lost"`
RealtimeLostStored int64 `json:"realtime_lost_but_persisted"`
ArrivalRate float64 `json:"arrival_rate"`
LossRate float64 `json:"loss_rate"`
Duplicates int64 `json:"duplicates"`
OutOfOrder int64 `json:"out_of_order"`
OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"`
Disconnects int64 `json:"disconnects"`
DisconnectReasons map[string]int64 `json:"disconnect_reasons,omitempty"`
DisconnectSamples []DisconnectSample `json:"disconnect_samples,omitempty"`
UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"`
HistoryUnackedChecked int64 `json:"history_unacked_observed_checked"`
HistoryUnackedFound int64 `json:"history_unacked_observed_found"`
PersistenceLost int64 `json:"persistence_lost"`
Missing []MissingDelivery `json:"missing,omitempty"`
Passed bool `json:"passed"`
FailureReasons []string `json:"failure_reasons,omitempty"`
}
func runMerge(aPath, bPath, output string) error {
a, err := readAgentSummary(aPath)
if err != nil {
return err
}
b, err := readAgentSummary(bPath)
if err != nil {
return err
}
if a.RunID != b.RunID || a.Scenario != b.Scenario {
return fmt.Errorf("agent summaries do not belong to the same run")
}
report := CombinedReport{RunID: a.RunID, Scenario: a.Scenario, GeneratedAt: time.Now().UTC(), Agents: []AgentSummary{a, b}, Passed: true,
PublishErrors: make(map[string]int64), DisconnectReasons: make(map[string]int64)}
for _, summary := range report.Agents {
expectedConnections := summary.ExpectedConnections
if expectedConnections == 0 {
expectedConnections = 1000
}
report.ExpectedConnections += expectedConnections
report.ReadyConnections += summary.ReadyConnections
report.PublishAttempted += summary.PublishAttempted
report.PublishAccepted += summary.PublishAccepted
lateAccepted := summary.PublishLateAccepted
// Backfill summaries generated before publish_ctrl_late_accepted was added.
if summary.CtrlTrackingEnabled && lateAccepted == 0 {
for _, sample := range summary.CtrlObservationSamples {
if sample.Late && sample.Code >= 200 && sample.Code < 300 {
lateAccepted++
}
}
}
report.PublishLateAccepted += lateAccepted
report.PublishFinalAccepted += summary.PublishAccepted + lateAccepted
for category, count := range summary.PublishErrors {
report.PublishErrors[category] += count
}
unknown := summary.PublishOutcomeUnknown
if summary.CtrlTrackingEnabled {
unknown = summary.PublishCtrlUnresolved + summary.PublishErrors["socket"]
} else if unknown == 0 {
unknown = summary.PublishErrors["ctrl_timeout"] + summary.PublishErrors["socket"]
}
explicit := summary.PublishErrors["403"] + summary.PublishErrors["502"] +
summary.PublishErrors["503"] + summary.PublishErrors["other"]
report.PublishOutcomeUnknown += unknown
report.PublishCtrlObserved += summary.PublishCtrlObserved
report.PublishCtrlOver20 += summary.PublishCtrlOver20
report.PublishLateCtrl += summary.PublishLateCtrl
report.PublishCtrlUnresolved += summary.PublishCtrlUnresolved
if remaining := 1000 - len(report.CtrlObservationSamples); remaining > 0 {
if remaining > len(summary.CtrlObservationSamples) {
remaining = len(summary.CtrlObservationSamples)
}
report.CtrlObservationSamples = append(report.CtrlObservationSamples, summary.CtrlObservationSamples[:remaining]...)
}
report.PublishExplicitReject += explicit
report.PublishRejected += explicit
report.ExpectedDeliveries += summary.ExpectedPeer + summary.ExpectedGroup
report.ReceivedDeliveries += summary.ReceivedPeer + summary.ReceivedGroup
report.RealtimeLost += summary.RealtimeLost
report.RealtimeLostStored += summary.RealtimeLostStored
report.Duplicates += summary.Duplicates
report.OutOfOrder += summary.OutOfOrder
report.OrderingViolations = append(report.OrderingViolations, summary.OrderingViolations...)
report.Disconnects += summary.Disconnects
for category, count := range summary.DisconnectReasons {
report.DisconnectReasons[category] += count
}
if remaining := 100 - len(report.DisconnectSamples); remaining > 0 {
if remaining > len(summary.DisconnectSamples) {
remaining = len(summary.DisconnectSamples)
}
report.DisconnectSamples = append(report.DisconnectSamples, summary.DisconnectSamples[:remaining]...)
}
report.UnexpectedMessages += summary.UnexpectedMessages
report.HistoryChecked += summary.HistoryChecked
report.HistoryFound += summary.HistoryFound
report.HistoryUnackedChecked += summary.HistoryUnackedChecked
report.HistoryUnackedFound += summary.HistoryUnackedFound
report.PersistenceLost += summary.PersistenceLost
report.CrossClockSkewSamples += summary.CrossClockSkewSamples
report.Missing = append(report.Missing, summary.Missing...)
actionableErrors := len(summary.Errors)
lateTimeouts := int(lateAccepted)
for _, message := range summary.Errors {
if lateTimeouts > 0 && strings.Contains(message, "ctrl timeout") {
actionableErrors--
lateTimeouts--
}
}
if actionableErrors > 0 {
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("agent %s unresolved errors=%d", summary.Machine, actionableErrors))
}
}
if report.PublishAttempted > 0 {
report.PublishSuccessRate = float64(report.PublishFinalAccepted) / float64(report.PublishAttempted)
}
report.AckP95MS = mergedAckP95(report.Agents)
report.PublishCtrlP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.PublishCtrlLatency })
report.DeliveryLocalP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyLocal })
report.DeliveryCrossP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyCross })
// delivery_p95_ms remains the compatibility field, but now uses only same-clock
// samples. Cross-machine wall clocks cannot produce a valid one-way latency.
report.DeliveryP95MS = report.DeliveryLocalP95MS
if report.DeliveryP95MS == 0 {
report.DeliveryP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatency })
}
if report.ExpectedDeliveries > 0 {
report.ArrivalRate = float64(report.ReceivedDeliveries) / float64(report.ExpectedDeliveries)
report.LossRate = float64(report.RealtimeLost) / float64(report.ExpectedDeliveries)
}
if report.ReadyConnections != report.ExpectedConnections {
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("ready connections=%d expected=%d",
report.ReadyConnections, report.ExpectedConnections))
}
if report.PublishAttempted > 0 && report.PublishSuccessRate < 0.9999 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("publish success rate=%.6f expected>=0.999900", report.PublishSuccessRate))
}
if report.PublishOutcomeUnknown > 0 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("publish outcome unknown=%d (ACK timeout or socket failure)", report.PublishOutcomeUnknown))
}
if report.UnexpectedMessages > 0 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("deliveries observed without ACK ledger=%d", report.UnexpectedMessages))
}
if report.PublishRejected > 0 || report.RealtimeLost > 0 || report.PersistenceLost > 0 || report.Duplicates > 0 || report.OutOfOrder > 0 || report.Disconnects > 0 || report.UnexpectedMessages > 0 {
report.FailureReasons = append(report.FailureReasons, "message correctness gate failed")
}
report.Passed = len(report.FailureReasons) == 0
sort.Slice(report.Missing, func(i, j int) bool {
if report.Missing[i].ClientMid == report.Missing[j].ClientMid {
return report.Missing[i].ReceiverIndex < report.Missing[j].ReceiverIndex
}
return report.Missing[i].ClientMid < report.Missing[j].ClientMid
})
return writeJSONFile(output, report)
}
func mergedAckP95(agents []AgentSummary) float64 {
var expected int64
latencies := make([]float64, 0)
for _, agent := range agents {
expected += agent.AckLatency.Count
for _, message := range agent.AcceptedLocal {
if message.SentAt.IsZero() || message.AckAt.Before(message.SentAt) {
continue
}
latencies = append(latencies, float64(message.AckAt.Sub(message.SentAt))/float64(time.Millisecond))
}
}
if expected == 0 || int64(len(latencies)) != expected {
return mergedHistogramP95(agents, func(summary AgentSummary) HistogramSnapshot { return summary.AckLatency })
}
sort.Float64s(latencies)
index := (len(latencies)*95 + 99) / 100
return latencies[index-1]
}
func mergedHistogramP95(agents []AgentSummary, selectHistogram func(AgentSummary) HistogramSnapshot) float64 {
var count int64
buckets := make([]int64, len(latencyBounds)+1)
maxMS := float64(0)
for _, agent := range agents {
histogram := selectHistogram(agent)
count += histogram.Count
if histogram.MaxMS > maxMS {
maxMS = histogram.MaxMS
}
for i, bound := range latencyBounds {
buckets[i] += histogram.Buckets[bound.String()]
}
buckets[len(latencyBounds)] += histogram.Buckets["+Inf"]
}
if count == 0 {
return 0
}
target := (count*95 + 99) / 100
var cumulative int64
for i, bucket := range buckets {
cumulative += bucket
if cumulative >= target {
if i == len(latencyBounds) {
return maxMS
}
return float64(latencyBounds[i]) / float64(time.Millisecond)
}
}
return maxMS
}
func readAgentSummary(path string) (AgentSummary, error) {
var summary AgentSummary
raw, err := os.ReadFile(path)
if err != nil {
return summary, err
}
if err = json.Unmarshal(raw, &summary); err != nil {
return summary, err
}
return summary, nil
}
func writeJSONFile(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
if err = os.MkdirAll(dir, 0o750); err != nil {
return err
}
}
return os.WriteFile(path, append(raw, '\n'), 0o600)
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestRunMergeComputesArrivalAndPersistenceGate(t *testing.T) {
dir := t.TempDir()
a := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "a", ReadyConnections: 1000,
ExpectedConnections: 1000,
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 9, UnexpectedMessages: 42,
CtrlTrackingEnabled: true, PublishCtrlObserved: 9, PublishCtrlOver20: 2, PublishLateCtrl: 2,
PublishCtrlUnresolved: 1,
PublishErrors: map[string]int64{"ctrl_timeout": 3, "socket": 2, "503": 1},
RealtimeLost: 1, HistoryChecked: 10, HistoryFound: 10, Disconnects: 1,
DisconnectReasons: map[string]int64{"timeout": 1},
DisconnectSamples: []DisconnectSample{{ReceiverMachine: "a", ReceiverIndex: 3, Category: "timeout"}}}
b := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "b", ReadyConnections: 1000,
ExpectedConnections: 1000,
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 10,
HistoryChecked: 10, HistoryFound: 9, PersistenceLost: 1, OutOfOrder: 1,
CtrlTrackingEnabled: true, PublishCtrlObserved: 10,
Disconnects: 2, DisconnectReasons: map[string]int64{"timeout": 1, "connection_reset": 1},
OrderingViolations: []OrderingViolation{{ReceiverMachine: "b", ReceiverIndex: 7, PreviousSeq: 12, CurrentSeq: 11}}}
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, a)
writeSummaryForTest(t, bPath, b)
if err := runMerge(aPath, bPath, reportPath); err != nil {
t.Fatal(err)
}
var report CombinedReport
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.ExpectedDeliveries != 20 || report.ReceivedDeliveries != 19 || report.ArrivalRate != 0.95 || report.LossRate != 0.05 {
t.Fatalf("unexpected delivery totals: %+v", report)
}
if report.Passed || report.PersistenceLost != 1 {
t.Fatalf("correctness gate did not fail: %+v", report)
}
if report.UnexpectedMessages != 42 || report.PublishOutcomeUnknown != 3 ||
report.PublishExplicitReject != 1 || report.PublishRejected != 1 {
t.Fatalf("ambiguous publish outcomes were not merged: %+v", report)
}
if report.PublishCtrlObserved != 19 || report.PublishCtrlOver20 != 2 || report.PublishLateCtrl != 2 ||
report.PublishCtrlUnresolved != 1 {
t.Fatalf("ctrl observations were not merged: %+v", report)
}
if len(report.OrderingViolations) != 1 || report.OrderingViolations[0].ReceiverIndex != 7 {
t.Fatalf("ordering violations were not merged: %+v", report.OrderingViolations)
}
if report.Disconnects != 3 || report.DisconnectReasons["timeout"] != 2 ||
report.DisconnectReasons["connection_reset"] != 1 || len(report.DisconnectSamples) != 1 {
t.Fatalf("disconnect diagnostics were not merged: count=%d reasons=%v samples=%+v",
report.Disconnects, report.DisconnectReasons, report.DisconnectSamples)
}
}
func TestMergedHistogramP95(t *testing.T) {
agents := []AgentSummary{
{AckLatency: HistogramSnapshot{Count: 10, MaxMS: 900, Buckets: map[string]int64{"200ms": 9, "1s": 1}}},
{AckLatency: HistogramSnapshot{Count: 10, MaxMS: 300, Buckets: map[string]int64{"200ms": 10}}},
}
if got := mergedHistogramP95(agents, func(summary AgentSummary) HistogramSnapshot { return summary.AckLatency }); got != 200 {
t.Fatalf("p95=%v", got)
}
}
func TestMergedHistogramP95DoesNotUseMaximumForLongTail(t *testing.T) {
histogram := NewDurationHistogram()
for i := 0; i < 94; i++ {
histogram.Observe(5 * time.Second)
}
for i := 0; i < 5; i++ {
histogram.Observe(25 * time.Second)
}
histogram.Observe(70 * time.Second)
agents := []AgentSummary{{PublishCtrlLatency: histogram.Snapshot()}}
if got := mergedHistogramP95(agents, func(summary AgentSummary) HistogramSnapshot {
return summary.PublishCtrlLatency
}); got != 30000 {
t.Fatalf("p95=%v want=30000", got)
}
}
func TestMergedAckP95UsesExactAcceptedTimestamps(t *testing.T) {
base := time.Date(2026, 9, 27, 8, 0, 0, 0, time.UTC)
agents := []AgentSummary{{
AckLatency: HistogramSnapshot{Count: 4, MaxMS: 900, Buckets: map[string]int64{"1s": 4}},
AcceptedLocal: []AcceptedMessage{
{SentAt: base, AckAt: base.Add(100 * time.Millisecond)},
{SentAt: base, AckAt: base.Add(200 * time.Millisecond)},
{SentAt: base, AckAt: base.Add(300 * time.Millisecond)},
{SentAt: base, AckAt: base.Add(400 * time.Millisecond)},
},
}}
if got := mergedAckP95(agents); got != 400 {
t.Fatalf("exact p95=%v want=400", got)
}
}
func TestMergedAckP95FallsBackWhenRawSamplesAreIncomplete(t *testing.T) {
agents := []AgentSummary{{
AckLatency: HistogramSnapshot{Count: 2, MaxMS: 900, Buckets: map[string]int64{"1s": 2}},
AcceptedLocal: []AcceptedMessage{{SentAt: time.Now(), AckAt: time.Now().Add(100 * time.Millisecond)}},
}}
if got := mergedAckP95(agents); got != 1000 {
t.Fatalf("fallback p95=%v want=1000", got)
}
}
func TestRunMergeReportsSameClockDeliveryLatency(t *testing.T) {
dir := t.TempDir()
local := HistogramSnapshot{Count: 10, MaxMS: 200, Buckets: map[string]int64{"200ms": 10}}
cross := HistogramSnapshot{Count: 10, MaxMS: 5000, Buckets: map[string]int64{"5s": 10}}
a := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "a", ExpectedConnections: 1,
ReadyConnections: 1, PublishAttempted: 1, PublishAccepted: 1, ExpectedPeer: 1, ReceivedPeer: 1,
DeliveryLatencyLocal: local, DeliveryLatencyCross: cross, CrossClockSkewSamples: 3}
b := a
b.Machine = "b"
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, a)
writeSummaryForTest(t, bPath, b)
if err := runMerge(aPath, bPath, reportPath); err != nil {
t.Fatal(err)
}
var report CombinedReport
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.DeliveryP95MS != 200 || report.DeliveryLocalP95MS != 200 || report.DeliveryCrossP95MS != 5000 {
t.Fatalf("unexpected delivery percentiles: %+v", report)
}
if report.CrossClockSkewSamples != 6 || !report.Passed {
t.Fatalf("cross-machine clock skew must not fail correctness acceptance: %+v", report)
}
}
func TestRunMergeTreatsLateSuccessAsFinalAcceptance(t *testing.T) {
dir := t.TempDir()
late := AgentSummary{
RunID: "run1", Scenario: "group", Machine: "a", ExpectedConnections: 1, ReadyConnections: 1,
PublishAttempted: 1, PublishAccepted: 0, CtrlTrackingEnabled: true,
PublishCtrlObserved: 1, PublishLateCtrl: 1, PublishCtrlUnresolved: 0,
PublishErrors: map[string]int64{"ctrl_timeout": 1},
CtrlObservationSamples: []CtrlObservationSample{{Code: 202, Late: true}},
Errors: []string{"publish message-1: ctrl timeout id=pub-a-1"},
}
timely := AgentSummary{
RunID: "run1", Scenario: "group", Machine: "b", ExpectedConnections: 1, ReadyConnections: 1,
PublishAttempted: 1, PublishAccepted: 1, CtrlTrackingEnabled: true,
PublishCtrlObserved: 1, PublishErrors: map[string]int64{},
}
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, late)
writeSummaryForTest(t, bPath, timely)
if err := runMerge(aPath, bPath, reportPath); err != nil {
t.Fatal(err)
}
var report CombinedReport
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.PublishAccepted != 1 || report.PublishLateAccepted != 1 || report.PublishFinalAccepted != 2 || report.PublishSuccessRate != 1 {
t.Fatalf("late acceptance was not included in final publish outcome: %+v", report)
}
if !report.Passed || len(report.FailureReasons) != 0 {
t.Fatalf("resolved late ACK must not fail the report: %+v", report.FailureReasons)
}
}
func writeSummaryForTest(t *testing.T, path string, summary AgentSummary) {
t.Helper()
raw, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
}
+563
View File
@@ -0,0 +1,563 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"sort"
"strings"
"time"
)
const manifestVersion = 1
type Config struct {
Tenant string `json:"tenant"`
HTTPBase string `json:"http_base"`
APIKeyEnv string `json:"api_key_env"`
PasswordEnv string `json:"password_env"`
UsersPerMachine int `json:"users_per_machine"`
Endpoints map[string][]string `json:"endpoints"`
DialIPs map[string][]string `json:"dial_ips,omitempty"`
InsecureTLS bool `json:"insecure_tls"`
PrepareWorkers int `json:"prepare_workers"`
ConnectRamp string `json:"connect_ramp"`
ReconnectWorkers int `json:"reconnect_workers"`
ReconnectMinWait string `json:"reconnect_min_wait"`
ReconnectMaxWait string `json:"reconnect_max_wait"`
CommandTimeout string `json:"command_timeout"`
WSPingInterval string `json:"ws_ping_interval"`
P2PMessages int `json:"p2p_messages_per_user"`
P2PInterval string `json:"p2p_interval"`
GroupSenders int `json:"group_senders_per_machine"`
GroupRounds int `json:"group_rounds"`
GroupInterval string `json:"group_publish_interval"`
MixedP2PUsers int `json:"mixed_p2p_users_per_machine"`
MixedGroupSenders int `json:"mixed_group_senders_per_machine"`
IdleDuration string `json:"idle_duration"`
DrainP2P string `json:"drain_p2p"`
DrainGroup string `json:"drain_group"`
DeliveryQuiet string `json:"delivery_quiet_period"`
DeliveryTimeout string `json:"delivery_quiet_timeout"`
PhonePrefix string `json:"registration_phone_prefix"`
VerifyCodeEnv string `json:"verification_code_env"`
HistoryWorkers int `json:"history_workers"`
HistoryPageSize int `json:"history_page_size"`
HistoryInterval string `json:"history_query_interval"`
HistoryPageWait string `json:"history_page_wait"`
HistorySettle string `json:"history_settle"`
HistoryTimeout string `json:"history_timeout"`
CloseRamp string `json:"close_ramp"`
PreflightWorkers int `json:"preflight_workers"`
SkipP2PPreflight bool `json:"skip_p2p_preflight"`
}
func loadConfig(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err = json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if cfg.Tenant == "" {
cfg.Tenant = "006"
}
if cfg.UsersPerMachine <= 0 {
cfg.UsersPerMachine = 1000
}
if cfg.PrepareWorkers <= 0 {
cfg.PrepareWorkers = 20
}
if cfg.ConnectRamp == "" {
cfg.ConnectRamp = "20s"
}
if cfg.ReconnectWorkers <= 0 {
cfg.ReconnectWorkers = 8
}
if cfg.ReconnectWorkers > 128 {
return nil, errors.New("reconnect_workers must not exceed 128")
}
if cfg.ReconnectMinWait == "" {
cfg.ReconnectMinWait = "2s"
}
if _, err = configDuration(cfg.ReconnectMinWait); err != nil {
return nil, fmt.Errorf("reconnect_min_wait: %w", err)
}
if cfg.ReconnectMaxWait == "" {
cfg.ReconnectMaxWait = "30s"
}
minReconnect, _ := configDuration(cfg.ReconnectMinWait)
maxReconnect, err := configDuration(cfg.ReconnectMaxWait)
if err != nil {
return nil, fmt.Errorf("reconnect_max_wait: %w", err)
}
if maxReconnect < minReconnect {
return nil, errors.New("reconnect_max_wait must be greater than or equal to reconnect_min_wait")
}
if cfg.CommandTimeout == "" {
cfg.CommandTimeout = "20s"
}
if cfg.WSPingInterval == "" {
cfg.WSPingInterval = "20s"
}
if _, err = configDuration(cfg.WSPingInterval); err != nil {
return nil, fmt.Errorf("ws_ping_interval: %w", err)
}
if cfg.P2PMessages <= 0 {
cfg.P2PMessages = 10
}
if cfg.P2PInterval == "" {
cfg.P2PInterval = "1s"
}
if cfg.GroupRounds <= 0 {
cfg.GroupRounds = 2
}
if cfg.GroupInterval == "" {
cfg.GroupInterval = "100ms"
}
if _, err = configDuration(cfg.GroupInterval); err != nil {
return nil, fmt.Errorf("group_publish_interval: %w", err)
}
if cfg.GroupSenders < 0 {
return nil, errors.New("group_senders_per_machine must not be negative")
}
if cfg.MixedP2PUsers < 0 {
return nil, errors.New("mixed_p2p_users_per_machine must not be negative")
}
if cfg.MixedGroupSenders < 0 {
return nil, errors.New("mixed_group_senders_per_machine must not be negative")
}
if cfg.IdleDuration == "" {
cfg.IdleDuration = "15m"
}
if _, err = configDuration(cfg.IdleDuration); err != nil {
return nil, fmt.Errorf("idle_duration: %w", err)
}
if cfg.DrainP2P == "" {
cfg.DrainP2P = "30s"
}
if cfg.DrainGroup == "" {
cfg.DrainGroup = "60s"
}
if cfg.DeliveryQuiet == "" {
cfg.DeliveryQuiet = "10s"
}
if _, err = configDuration(cfg.DeliveryQuiet); err != nil {
return nil, fmt.Errorf("delivery_quiet_period: %w", err)
}
if cfg.DeliveryTimeout == "" {
cfg.DeliveryTimeout = "3m"
}
if _, err = configDuration(cfg.DeliveryTimeout); err != nil {
return nil, fmt.Errorf("delivery_quiet_timeout: %w", err)
}
if cfg.HistoryWorkers <= 0 {
cfg.HistoryWorkers = 12
}
if cfg.HistoryWorkers > 128 {
return nil, errors.New("history_workers must not exceed 128")
}
if cfg.HistoryPageSize <= 0 {
cfg.HistoryPageSize = 10
}
if cfg.HistoryPageSize > 200 {
return nil, errors.New("history_page_size must not exceed 200")
}
if cfg.HistoryInterval == "" {
cfg.HistoryInterval = "500ms"
}
if _, err = configDuration(cfg.HistoryInterval); err != nil {
return nil, fmt.Errorf("history_query_interval: %w", err)
}
if cfg.HistoryPageWait == "" {
cfg.HistoryPageWait = "3s"
}
if _, err = configDuration(cfg.HistoryPageWait); err != nil {
return nil, fmt.Errorf("history_page_wait: %w", err)
}
if cfg.HistoryTimeout == "" {
cfg.HistoryTimeout = "3m"
}
if cfg.HistorySettle == "" {
cfg.HistorySettle = "5m"
}
if _, err = configDuration(cfg.HistorySettle); err != nil {
return nil, fmt.Errorf("history_settle: %w", err)
}
if _, err = configDuration(cfg.HistoryTimeout); err != nil {
return nil, fmt.Errorf("history_timeout: %w", err)
}
if cfg.CloseRamp == "" {
cfg.CloseRamp = "10s"
}
if _, err = configDuration(cfg.CloseRamp); err != nil {
return nil, fmt.Errorf("close_ramp: %w", err)
}
if cfg.PreflightWorkers <= 0 {
cfg.PreflightWorkers = 32
}
if cfg.PreflightWorkers > 128 {
return nil, errors.New("preflight_workers must not exceed 128")
}
if strings.TrimSpace(cfg.HTTPBase) == "" {
return nil, errors.New("http_base is required")
}
for _, machine := range []string{"a", "b"} {
if len(cfg.Endpoints[machine]) == 0 {
return nil, fmt.Errorf("at least one endpoint is required for machine %s", machine)
}
for _, ip := range cfg.DialIPs[machine] {
if net.ParseIP(strings.TrimSpace(ip)) == nil {
return nil, fmt.Errorf("invalid dial IP %q for machine %s", ip, machine)
}
}
}
if cfg.PhonePrefix != "" {
if len(cfg.PhonePrefix) != 7 || strings.Trim(cfg.PhonePrefix, "0123456789") != "" {
return nil, errors.New("registration_phone_prefix must contain exactly 7 digits")
}
if cfg.UsersPerMachine > 4999 {
return nil, errors.New("registration phone suffix supports at most 4999 users per machine")
}
}
return &cfg, nil
}
func (c *Config) apiKey() (string, error) {
name := c.APIKeyEnv
if name == "" {
name = "IM_API_KEY"
}
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return "", fmt.Errorf("environment variable %s is required", name)
}
return value, nil
}
func (c *Config) password() (string, error) {
name := c.PasswordEnv
if name == "" {
name = "IM_LOAD_PASSWORD"
}
value := os.Getenv(name)
if value == "" {
return "", fmt.Errorf("environment variable %s is required", name)
}
return value, nil
}
func (c *Config) verificationCode() (string, error) {
name := c.VerifyCodeEnv
if name == "" {
name = "IM_VERIFICATION_CODE"
}
value := strings.TrimSpace(os.Getenv(name))
if c.PhonePrefix != "" && value == "" {
return "", fmt.Errorf("environment variable %s is required", name)
}
return value, nil
}
func configDuration(value string) (time.Duration, error) {
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return 0, fmt.Errorf("invalid positive duration %q", value)
}
return d, nil
}
type Manifest struct {
Version int `json:"version"`
Tenant string `json:"tenant"`
GroupTopic string `json:"group_topic,omitempty"`
CreatedAt time.Time `json:"created_at"`
Accounts []AccountRecord `json:"accounts"`
}
type AccountRecord struct {
Machine string `json:"machine"`
Index int `json:"index"`
Username string `json:"username"`
Phone string `json:"phone,omitempty"`
UserID string `json:"user_id"`
Token string `json:"token"`
TokenExpires time.Time `json:"token_expires,omitempty"`
P2PTopic string `json:"p2p_topic,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
DialIP string `json:"-"`
}
func newManifest(cfg *Config) *Manifest {
m := &Manifest{Version: manifestVersion, Tenant: cfg.Tenant, CreatedAt: time.Now().UTC()}
for _, machine := range []string{"a", "b"} {
for i := 1; i <= cfg.UsersPerMachine; i++ {
phone, _ := registrationPhone(cfg, machine, i)
m.Accounts = append(m.Accounts, AccountRecord{
Machine: machine,
Index: i,
Username: fmt.Sprintf("lt%s%04d", machine, i),
Phone: phone,
Endpoint: cfg.Endpoints[machine][(i-1)%len(cfg.Endpoints[machine])],
})
}
}
return m
}
func applyConfigEndpoints(cfg *Config, manifest *Manifest) error {
if manifest.Tenant != cfg.Tenant {
return fmt.Errorf("manifest tenant=%s config tenant=%s", manifest.Tenant, cfg.Tenant)
}
if len(manifest.Accounts) != cfg.UsersPerMachine*2 {
return fmt.Errorf("manifest accounts=%d expected=%d", len(manifest.Accounts), cfg.UsersPerMachine*2)
}
for i := range manifest.Accounts {
account := &manifest.Accounts[i]
endpoints := cfg.Endpoints[account.Machine]
if account.Index <= 0 || len(endpoints) == 0 {
return fmt.Errorf("invalid endpoint assignment for %s", accountKey(account.Machine, account.Index))
}
account.Endpoint = endpoints[(account.Index-1)%len(endpoints)]
phone, err := registrationPhone(cfg, account.Machine, account.Index)
if err != nil {
return err
}
account.Phone = phone
}
return nil
}
func reconcileManifest(cfg *Config, existing *Manifest) (*Manifest, error) {
if existing.Tenant != cfg.Tenant {
return nil, fmt.Errorf("manifest tenant=%s config tenant=%s", existing.Tenant, cfg.Tenant)
}
desired := newManifest(cfg)
if len(existing.Accounts) > len(desired.Accounts) {
return nil, fmt.Errorf("refusing to shrink manifest accounts from %d to %d", len(existing.Accounts), len(desired.Accounts))
}
desired.GroupTopic = existing.GroupTopic
desired.CreatedAt = existing.CreatedAt
old := manifestAccountMap(existing)
for i := range desired.Accounts {
account := &desired.Accounts[i]
if previous := old[accountKey(account.Machine, account.Index)]; previous != nil {
account.UserID = previous.UserID
account.Token = previous.Token
account.TokenExpires = previous.TokenExpires
account.P2PTopic = previous.P2PTopic
}
}
return desired, nil
}
func registrationPhone(cfg *Config, machine string, index int) (string, error) {
if cfg.PhonePrefix == "" {
return "", nil
}
ordinal := index
if machine == "b" {
ordinal += 5000
} else if machine != "a" {
return "", fmt.Errorf("invalid machine %q", machine)
}
if index <= 0 || ordinal > 9999 {
return "", fmt.Errorf("invalid phone ordinal %d", ordinal)
}
return fmt.Sprintf("%s%04d", cfg.PhonePrefix, ordinal), nil
}
func loadManifest(path string) (*Manifest, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var manifest Manifest
if err = json.Unmarshal(raw, &manifest); err != nil {
return nil, err
}
if manifest.Version != manifestVersion {
return nil, fmt.Errorf("unsupported manifest version %d", manifest.Version)
}
return &manifest, manifest.validate()
}
func (m *Manifest) validate() error {
seen := make(map[string]bool, len(m.Accounts))
for _, account := range m.Accounts {
key := accountKey(account.Machine, account.Index)
if account.Machine != "a" && account.Machine != "b" {
return fmt.Errorf("invalid machine %q", account.Machine)
}
if account.Index <= 0 || account.Username == "" || seen[key] {
return fmt.Errorf("invalid or duplicate account %s", key)
}
seen[key] = true
}
return nil
}
func saveManifest(path string, manifest *Manifest) error {
if err := manifest.validate(); err != nil {
return err
}
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err = os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func accountKey(machine string, index int) string {
return strings.ToLower(machine) + fmt.Sprintf("-%04d", index)
}
func accountsForMachine(manifest *Manifest, machine string) []AccountRecord {
var out []AccountRecord
for _, account := range manifest.Accounts {
if account.Machine == machine {
out = append(out, account)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
return out
}
func manifestAccountMap(manifest *Manifest) map[string]*AccountRecord {
out := make(map[string]*AccountRecord, len(manifest.Accounts))
for i := range manifest.Accounts {
account := &manifest.Accounts[i]
out[accountKey(account.Machine, account.Index)] = account
}
return out
}
func topicHash(topic string) string {
sum := sha256.Sum256([]byte(topic))
return hex.EncodeToString(sum[:6])
}
type AcceptedMessage struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
Kind string `json:"kind"`
ClientMid string `json:"client_mid"`
Topic string `json:"topic"`
Seq int64 `json:"seq"`
SenderMachine string `json:"sender_machine"`
SenderIndex int `json:"sender_index"`
PeerMachine string `json:"peer_machine,omitempty"`
PeerIndex int `json:"peer_index,omitempty"`
SentAt time.Time `json:"sent_at"`
AckAt time.Time `json:"ack_at"`
}
type AgentSummary struct {
RunID string `json:"run_id"`
Scenario string `json:"scenario"`
Machine string `json:"machine"`
StartedAt time.Time `json:"started_at"`
LoadEndedAt time.Time `json:"load_ended_at"`
FinishedAt time.Time `json:"finished_at"`
ExpectedConnections int `json:"expected_connections"`
ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"`
PublishRejected int64 `json:"publish_rejected"`
PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
CtrlTrackingEnabled bool `json:"ctrl_tracking_enabled"`
PublishCtrlObserved int64 `json:"publish_ctrl_observed"`
PublishCtrlOver20 int64 `json:"publish_ctrl_over_20s"`
PublishLateCtrl int64 `json:"publish_ctrl_late"`
PublishLateAccepted int64 `json:"publish_ctrl_late_accepted"`
PublishCtrlUnresolved int64 `json:"publish_ctrl_unresolved"`
PublishCtrlLatency HistogramSnapshot `json:"publish_ctrl_latency"`
CtrlObservationSamples []CtrlObservationSample `json:"ctrl_observation_samples,omitempty"`
ExpectedPeer int64 `json:"expected_peer"`
ReceivedPeer int64 `json:"received_peer"`
ExpectedEcho int64 `json:"expected_echo"`
ReceivedEcho int64 `json:"received_echo"`
ExpectedGroup int64 `json:"expected_group"`
ReceivedGroup int64 `json:"received_group"`
RealtimeLost int64 `json:"realtime_lost"`
RealtimeLostStored int64 `json:"realtime_lost_but_persisted"`
Duplicates int64 `json:"duplicates"`
OutOfOrder int64 `json:"out_of_order"`
OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"`
Disconnects int64 `json:"disconnects"`
DisconnectReasons map[string]int64 `json:"disconnect_reasons,omitempty"`
DisconnectSamples []DisconnectSample `json:"disconnect_samples,omitempty"`
UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"`
HistoryUnackedChecked int64 `json:"history_unacked_observed_checked"`
HistoryUnackedFound int64 `json:"history_unacked_observed_found"`
PersistenceLost int64 `json:"persistence_lost"`
AckLatency HistogramSnapshot `json:"ack_latency"`
DeliveryLatency HistogramSnapshot `json:"delivery_latency"`
DeliveryLatencyLocal HistogramSnapshot `json:"delivery_latency_local"`
DeliveryLatencyCross HistogramSnapshot `json:"delivery_latency_cross_machine"`
CrossClockSkewSamples int64 `json:"cross_machine_negative_latency_samples"`
CallbackQueue CallbackQueueSnapshot `json:"callback_queue"`
Errors []string `json:"errors,omitempty"`
Missing []MissingDelivery `json:"missing,omitempty"`
PersistenceMissing []string `json:"persistence_missing,omitempty"`
AcceptedLocal []AcceptedMessage `json:"accepted_local,omitempty"`
}
type CtrlObservationSample struct {
Machine string `json:"machine"`
Index int `json:"index"`
ID string `json:"id"`
Code int `json:"code"`
SentAt time.Time `json:"sent_at"`
ReceivedAt time.Time `json:"received_at"`
LatencyMS float64 `json:"latency_ms"`
Late bool `json:"late"`
}
type CallbackQueueSnapshot struct {
Current int64 `json:"current"`
Processing int64 `json:"processing"`
Maximum int64 `json:"maximum"`
Enqueued int64 `json:"enqueued"`
Completed int64 `json:"completed"`
WaitSecondsTotal float64 `json:"wait_seconds_total"`
WaitSecondsMax float64 `json:"wait_seconds_max"`
}
type DisconnectSample struct {
ReceiverMachine string `json:"receiver_machine"`
ReceiverIndex int `json:"receiver_index"`
Category string `json:"category"`
Error string `json:"error"`
At time.Time `json:"at"`
}
type OrderingViolation struct {
ReceiverMachine string `json:"receiver_machine"`
ReceiverIndex int `json:"receiver_index"`
TopicHash string `json:"topic_hash"`
PreviousSeq int64 `json:"previous_seq"`
CurrentSeq int64 `json:"current_seq"`
ClientMid string `json:"client_mid"`
}
type MissingDelivery struct {
ClientMid string `json:"client_mid"`
Kind string `json:"kind"`
TopicHash string `json:"topic_hash"`
Seq int64 `json:"seq"`
ReceiverIndex int `json:"receiver_index"`
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestLoadConfigAppliesBoundedHistoryDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
raw := []byte(`{"http_base":"https://example.test","endpoints":{"a":["wss://a"],"b":["wss://b"]}}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
if cfg.HistoryWorkers != 12 || cfg.HistoryPageSize != 10 || cfg.HistoryInterval != "500ms" ||
cfg.HistoryPageWait != "3s" || cfg.CloseRamp != "10s" ||
cfg.HistorySettle != "5m" || cfg.HistoryTimeout != "3m" ||
cfg.PreflightWorkers != 32 || cfg.WSPingInterval != "20s" {
t.Fatalf("unexpected defaults: %+v", cfg)
}
}
func TestAssignAgentEndpointsDistributesDialIPsAcrossMachines(t *testing.T) {
cfg := &Config{
Endpoints: map[string][]string{"a": {"wss://api.example.test/ws"}, "b": {"wss://api.example.test/ws"}},
DialIPs: map[string][]string{"a": {"192.0.2.1", "192.0.2.2", "192.0.2.3"}, "b": {"192.0.2.1", "192.0.2.2", "192.0.2.3"}},
}
a := []AccountRecord{{Index: 1}, {Index: 2}, {Index: 3}}
b := []AccountRecord{{Index: 1}, {Index: 2}, {Index: 3}}
assignAgentEndpoints(cfg, "a", a)
assignAgentEndpoints(cfg, "b", b)
got := []string{a[0].DialIP, b[0].DialIP, a[1].DialIP, b[1].DialIP, a[2].DialIP, b[2].DialIP}
want := []string{"192.0.2.1", "192.0.2.2", "192.0.2.3", "192.0.2.1", "192.0.2.2", "192.0.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected dial IP distribution: got=%v want=%v", got, want)
}
}
func TestLoadConfigRejectsInvalidDialIP(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
raw := []byte(`{"http_base":"https://example.test","endpoints":{"a":["wss://a"],"b":["wss://b"]},"dial_ips":{"a":["not-an-ip"]}}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("invalid dial IP was accepted")
}
}
func TestSingleMachineExampleUsesSixtyPercentCapacity(t *testing.T) {
cfg, err := loadConfig("config.single-machine.example.json")
if err != nil {
t.Fatal(err)
}
if cfg.UsersPerMachine != 600 || cfg.CloseRamp != "60s" {
t.Fatalf("unexpected single-machine capacity: users=%d close_ramp=%s", cfg.UsersPerMachine, cfg.CloseRamp)
}
if len(cfg.Endpoints["a"]) != 1 || len(cfg.Endpoints["b"]) != 1 {
t.Fatalf("single-machine endpoints must define both logical agents: %+v", cfg.Endpoints)
}
}
func TestNewManifestDistributesUsersAndEndpoints(t *testing.T) {
cfg := &Config{Tenant: "006", UsersPerMachine: 3, Endpoints: map[string][]string{
"a": {"ws://a1", "ws://a2"}, "b": {"ws://b1"},
}}
manifest := newManifest(cfg)
if len(manifest.Accounts) != 6 {
t.Fatalf("accounts=%d", len(manifest.Accounts))
}
if got := accountsForMachine(manifest, "a"); got[0].Username != "lta0001" || got[1].Endpoint != "ws://a2" || got[2].Endpoint != "ws://a1" {
t.Fatalf("unexpected A accounts: %+v", got)
}
if err := manifest.validate(); err != nil {
t.Fatal(err)
}
}
func TestManifestRejectsDuplicateAccount(t *testing.T) {
manifest := &Manifest{Version: manifestVersion, Accounts: []AccountRecord{
{Machine: "a", Index: 1, Username: "lta0001"},
{Machine: "a", Index: 1, Username: "duplicate"},
}}
if err := manifest.validate(); err == nil {
t.Fatal("expected duplicate account validation error")
}
}
func TestApplyConfigEndpointsRefreshesManifest(t *testing.T) {
cfg := &Config{Tenant: "006", UsersPerMachine: 1, Endpoints: map[string][]string{
"a": {"ws://new-a"}, "b": {"ws://new-b"},
}}
manifest := &Manifest{Tenant: "006", Accounts: []AccountRecord{
{Machine: "a", Index: 1, Username: "lta0001", Endpoint: "ws://old"},
{Machine: "b", Index: 1, Username: "ltb0001", Endpoint: "ws://old"},
}}
if err := applyConfigEndpoints(cfg, manifest); err != nil {
t.Fatal(err)
}
if manifest.Accounts[0].Endpoint != "ws://new-a" || manifest.Accounts[1].Endpoint != "ws://new-b" {
t.Fatalf("endpoints were not refreshed: %+v", manifest.Accounts)
}
}
func TestRegistrationPhoneUsesDistinctMachineRanges(t *testing.T) {
cfg := &Config{UsersPerMachine: 1000, PhonePrefix: "1990927"}
a, err := registrationPhone(cfg, "a", 1)
if err != nil || a != "19909270001" {
t.Fatalf("machine A phone=%q err=%v", a, err)
}
b, err := registrationPhone(cfg, "b", 1)
if err != nil || b != "19909275001" {
t.Fatalf("machine B phone=%q err=%v", b, err)
}
last, err := registrationPhone(cfg, "b", 1000)
if err != nil || last != "19909276000" {
t.Fatalf("last phone=%q err=%v", last, err)
}
}
func TestReconcileManifestExpandsSmokeRun(t *testing.T) {
existing := &Manifest{Version: manifestVersion, Tenant: "allrich", GroupTopic: "grp-one", Accounts: []AccountRecord{
{Machine: "a", Index: 1, Username: "lta0001", UserID: "usr-a", Token: "token-a", P2PTopic: "p2p-one"},
{Machine: "b", Index: 1, Username: "ltb0001", UserID: "usr-b", Token: "token-b", P2PTopic: "p2p-one"},
}}
cfg := &Config{Tenant: "allrich", UsersPerMachine: 2, PhonePrefix: "1990927", Endpoints: map[string][]string{
"a": {"wss://a"}, "b": {"wss://b"},
}}
expanded, err := reconcileManifest(cfg, existing)
if err != nil {
t.Fatal(err)
}
if len(expanded.Accounts) != 4 || expanded.GroupTopic != "grp-one" || expanded.Accounts[0].Token != "token-a" {
t.Fatalf("unexpected expanded manifest: %+v", expanded)
}
if expanded.Accounts[3].Phone != "19909275002" {
t.Fatalf("machine B phone=%q", expanded.Accounts[3].Phone)
}
}
+375
View File
@@ -0,0 +1,375 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
)
func runPrepare(ctx context.Context, cfg *Config, manifestPath string) error {
apiKey, err := cfg.apiKey()
if err != nil {
return err
}
password, err := cfg.password()
if err != nil {
return err
}
verificationCode, err := cfg.verificationCode()
if err != nil {
return err
}
manifest := newManifest(cfg)
if existing, loadErr := loadManifest(manifestPath); loadErr == nil {
manifest, err = reconcileManifest(cfg, existing)
if err != nil {
return err
}
log.Printf("resuming manifest with %d existing accounts, target=%d", len(existing.Accounts), len(manifest.Accounts))
}
if err = applyConfigEndpoints(cfg, manifest); err != nil {
return err
}
api := newAPIClient(cfg, apiKey)
if err = prepareAccounts(ctx, cfg, manifest, api, password, verificationCode); err != nil {
return err
}
if err = saveManifest(manifestPath, manifest); err != nil {
return err
}
if err = prepareFriendships(ctx, cfg, manifest, api); err != nil {
return err
}
if err = saveManifest(manifestPath, manifest); err != nil {
return err
}
if err = prepareGroup(ctx, cfg, manifest, api, manifestPath); err != nil {
return err
}
if err = saveManifest(manifestPath, manifest); err != nil {
return err
}
return runVerify(ctx, cfg, manifest, api)
}
func prepareAccounts(ctx context.Context, cfg *Config, manifest *Manifest, api *apiClient, password, verificationCode string) error {
var created atomic.Int64
var existing atomic.Int64
var mu sync.Mutex
err := parallelAccounts(ctx, manifest.Accounts, cfg.PrepareWorkers, func(account AccountRecord) error {
if account.UserID != "" && account.Token != "" && account.TokenExpires.After(time.Now().Add(5*time.Minute)) {
existing.Add(1)
return nil
}
client, wasCreated, err := connectForPrepareWithRetry(ctx, cfg, account, api, password, verificationCode)
if err != nil {
return fmt.Errorf("account %s: %w", account.Username, err)
}
updated := client.Account()
client.Close()
mu.Lock()
defer mu.Unlock()
for i := range manifest.Accounts {
if manifest.Accounts[i].Machine == account.Machine && manifest.Accounts[i].Index == account.Index {
manifest.Accounts[i].UserID = updated.UserID
manifest.Accounts[i].Token = updated.Token
manifest.Accounts[i].TokenExpires = updated.TokenExpires
break
}
}
if wasCreated {
created.Add(1)
} else {
existing.Add(1)
}
return nil
})
if err == nil {
log.Printf("accounts ready=%d created=%d existing=%d", len(manifest.Accounts), created.Load(), existing.Load())
}
return err
}
func connectForPrepareWithRetry(ctx context.Context, cfg *Config, account AccountRecord, api *apiClient,
password, verificationCode string) (*WSClient, bool, error) {
var lastErr error
for attempt := 1; attempt <= 6; attempt++ {
client, created, err := connectForPrepare(ctx, cfg, account, api, password, verificationCode)
if err == nil {
return client, created, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
delay := time.Duration(attempt*5) * time.Second
log.Printf("account=%s prepare attempt=%d failed; retry_in=%s: %v", account.Username, attempt, delay, err)
if err = waitContext(ctx, delay); err != nil {
return nil, false, err
}
}
return nil, false, fmt.Errorf("account %s prepare failed after retries: %w", account.Username, lastErr)
}
func prepareFriendships(ctx context.Context, cfg *Config, manifest *Manifest, api *apiClient) error {
accounts := manifestAccountMap(manifest)
pairs := make([]AccountRecord, 0, cfg.UsersPerMachine)
for i := 1; i <= cfg.UsersPerMachine; i++ {
pairs = append(pairs, *accounts[accountKey("a", i)])
}
var mu sync.Mutex
err := parallelAccounts(ctx, pairs, cfg.PrepareWorkers, func(a AccountRecord) error {
return retryPrepareOperation(ctx, fmt.Sprintf("friend-pair-%04d", a.Index), func() error {
b := *accounts[accountKey("b", a.Index)]
if a.UserID == "" || b.UserID == "" {
return fmt.Errorf("pair %d has missing user ID", a.Index)
}
conversation, conversationErr := api.ensureP2P(ctx, a, b)
if conversationErr == nil && conversation.Relationship == "friend" && conversation.Topic != "" {
mu.Lock()
accounts[accountKey("a", a.Index)].P2PTopic = conversation.Topic
accounts[accountKey("b", a.Index)].P2PTopic = conversation.Topic
mu.Unlock()
return nil
}
if conversationErr != nil {
var apiErr *apiError
if !errors.As(conversationErr, &apiErr) || apiErr.Status != http.StatusForbidden {
return fmt.Errorf("check friend pair %d: %w", a.Index, conversationErr)
}
}
request, requestErr := api.createFriendRequest(ctx, a, b)
if requestErr == nil && request.Status == "pending" {
if _, requestErr = api.acceptFriendRequest(ctx, b, request.RequestID); requestErr != nil {
return fmt.Errorf("accept pair %d: %w", a.Index, requestErr)
}
} else if requestErr != nil {
var apiErr *apiError
if !errors.As(requestErr, &apiErr) || apiErr.Status != http.StatusConflict {
return fmt.Errorf("friend pair %d: %w", a.Index, requestErr)
}
pendingID, findErr := api.findPendingFriendRequest(ctx, b, a.UserID)
if findErr != nil {
return fmt.Errorf("find pending friend pair %d: %w", a.Index, findErr)
}
if pendingID != "" {
if _, acceptErr := api.acceptFriendRequest(ctx, b, pendingID); acceptErr != nil {
return fmt.Errorf("resume friend pair %d: %w", a.Index, acceptErr)
}
}
}
conversation, err := api.ensureP2P(ctx, a, b)
if err != nil {
return fmt.Errorf("ensure P2P pair %d: %w", a.Index, err)
}
if conversation.Relationship != "friend" || conversation.Topic == "" {
return fmt.Errorf("pair %d relationship=%s topic=%q", a.Index, conversation.Relationship, conversation.Topic)
}
mu.Lock()
accounts[accountKey("a", a.Index)].P2PTopic = conversation.Topic
accounts[accountKey("b", a.Index)].P2PTopic = conversation.Topic
mu.Unlock()
return nil
})
})
if err == nil {
log.Printf("friendships ready=%d", cfg.UsersPerMachine)
}
return err
}
func prepareGroup(ctx context.Context, cfg *Config, manifest *Manifest, api *apiClient, manifestPath string) error {
if err := parallelAccounts(ctx, manifest.Accounts, cfg.PrepareWorkers, func(account AccountRecord) error {
return retryPrepareOperation(ctx, "privacy-"+account.Username, func() error {
return api.disableGroupInviteVerification(ctx, account)
})
}); err != nil {
return fmt.Errorf("prepare group invite privacy: %w", err)
}
accounts := manifestAccountMap(manifest)
owner := *accounts[accountKey("a", 1)]
if manifest.GroupTopic == "" {
group, err := api.createGroup(ctx, owner)
if err != nil {
return err
}
manifest.GroupTopic = group.Topic
if err = saveManifest(manifestPath, manifest); err != nil {
return fmt.Errorf("checkpoint created group: %w", err)
}
log.Printf("created load-test group topic_hash=%s", topicHash(group.Topic))
}
memberIDs := make([]string, 0, len(manifest.Accounts)-1)
for _, account := range manifest.Accounts {
if account.UserID != owner.UserID {
memberIDs = append(memberIDs, account.UserID)
}
}
sort.Strings(memberIDs)
for offset, batch := 0, 1; offset < len(memberIDs); offset, batch = offset+100, batch+1 {
end := offset + 100
if end > len(memberIDs) {
end = len(memberIDs)
}
if err := ensureGroupMembers(ctx, api, owner, manifest.GroupTopic, batch, memberIDs[offset:end]); err != nil {
return fmt.Errorf("invite group batch %d: %w", batch, err)
}
}
count, err := api.countGroupMembers(ctx, owner, manifest.GroupTopic)
if err != nil {
return err
}
if count != len(manifest.Accounts) {
return fmt.Errorf("group has %d members, expected %d", count, len(manifest.Accounts))
}
log.Printf("group ready members=%d topic_hash=%s", count, topicHash(manifest.GroupTopic))
return nil
}
func ensureGroupMembers(ctx context.Context, api *apiClient, owner AccountRecord, topic string, batch int, desired []string) error {
var lastErr error
for attempt := 1; attempt <= 12; attempt++ {
members, err := api.groupMemberIDs(ctx, owner, topic)
if err != nil {
lastErr = fmt.Errorf("list current members: %w", err)
} else {
missing := make([]string, 0, len(desired))
for _, userID := range desired {
if !members[userID] {
missing = append(missing, userID)
}
}
if len(missing) == 0 {
return nil
}
response, inviteErr := api.inviteGroup(ctx, owner, topic, batch, missing)
if inviteErr != nil {
lastErr = inviteErr
} else {
lastErr = nil
for _, item := range response.Items {
if item.Status != "joined" && item.Status != "already_member" {
lastErr = fmt.Errorf("invite user %s status=%s reason=%s", item.UserID, item.Status, item.Reason)
break
}
}
if lastErr == nil {
continue
}
}
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt == 12 {
break
}
delay := time.Duration(attempt*5) * time.Second
log.Printf("operation=group-invite-%03d attempt=%d failed; retry_in=%s: %v", batch, attempt, delay, lastErr)
if err := waitContext(ctx, delay); err != nil {
return err
}
}
return fmt.Errorf("missing members not recovered after retries: %w", lastErr)
}
func retryPrepareOperation(ctx context.Context, label string, operation func() error) error {
return retryOperation(ctx, label, 6, operation)
}
func retryOperation(ctx context.Context, label string, attempts int, operation func() error) error {
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if err := operation(); err == nil {
return nil
} else {
lastErr = err
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt == attempts {
break
}
delay := time.Duration(attempt*5) * time.Second
log.Printf("operation=%s attempt=%d failed; retry_in=%s: %v", label, attempt, delay, lastErr)
if err := waitContext(ctx, delay); err != nil {
return err
}
}
return fmt.Errorf("operation %s failed after retries: %w", label, lastErr)
}
func runVerify(ctx context.Context, cfg *Config, manifest *Manifest, api *apiClient) error {
if len(manifest.Accounts) != cfg.UsersPerMachine*2 {
return fmt.Errorf("manifest accounts=%d expected=%d", len(manifest.Accounts), cfg.UsersPerMachine*2)
}
for _, account := range manifest.Accounts {
if account.UserID == "" || account.Token == "" || account.P2PTopic == "" {
return fmt.Errorf("account %s is incomplete", account.Username)
}
}
if manifest.GroupTopic == "" {
return errors.New("manifest group topic is empty")
}
owner := manifestAccountMap(manifest)[accountKey("a", 1)]
count, err := api.countGroupMembers(ctx, *owner, manifest.GroupTopic)
if err != nil {
return err
}
if count != len(manifest.Accounts) {
return fmt.Errorf("group member count=%d expected=%d", count, len(manifest.Accounts))
}
log.Printf("verify passed accounts=%d pairs=%d group_members=%d", len(manifest.Accounts), cfg.UsersPerMachine, count)
return nil
}
func parallelAccounts(ctx context.Context, accounts []AccountRecord, workers int, fn func(AccountRecord) error) error {
if workers <= 0 {
workers = 1
}
workCtx, cancel := context.WithCancel(ctx)
defer cancel()
jobs := make(chan AccountRecord)
errCh := make(chan error, 1)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for account := range jobs {
if err := fn(account); err != nil {
select {
case errCh <- err:
default:
}
cancel()
return
}
}
}()
}
go func() {
defer close(jobs)
for _, account := range accounts {
select {
case jobs <- account:
case <-workCtx.Done():
return
}
}
}()
wg.Wait()
select {
case err := <-errCh:
return err
default:
return ctx.Err()
}
}
+512
View File
@@ -0,0 +1,512 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"math/bits"
"net"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
const observationShardCount = 64
type observation struct {
topic string
seq int64
senderMachine string
senderIndex int
scenario string
sentAt int64
bits []atomic.Uint64
}
type observationView struct {
topic string
seq int64
senderMachine string
senderIndex int
scenario string
sentAt int64
bits []uint64
}
type observationShard struct {
mu sync.RWMutex
observed map[string]*observation
history map[string]bool
}
type sequenceState struct {
mu sync.Mutex
last map[string]int64
}
type deliveryRecorder struct {
machine string
localUsers int
latency *DurationHistogram
localLatency *DurationHistogram
crossLatency *DurationHistogram
disconnects atomic.Int64
received atomic.Int64
runID atomic.Value
historyMode atomic.Bool
shards []observationShard
sequences []sequenceState
duplicates atomic.Int64
outOfOrder atomic.Int64
malformed atomic.Int64
crossClockSkewSamples atomic.Int64
orderingMu sync.Mutex
ordering []OrderingViolation
disconnectMu sync.Mutex
disconnectReasons map[string]int64
disconnectSamples []DisconnectSample
}
func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorder {
shards := make([]observationShard, observationShardCount)
for i := range shards {
shards[i].observed = make(map[string]*observation)
shards[i].history = make(map[string]bool)
}
sequences := make([]sequenceState, localUsers)
for i := range sequences {
sequences[i].last = make(map[string]int64)
}
recorder := &deliveryRecorder{
machine: machine, localUsers: localUsers, latency: NewDurationHistogram(),
localLatency: NewDurationHistogram(), crossLatency: NewDurationHistogram(),
shards: shards, sequences: sequences, disconnectReasons: make(map[string]int64),
}
recorder.runID.Store(runID)
return recorder
}
func (r *deliveryRecorder) setRunID(runID string) { r.runID.Store(runID) }
func (r *deliveryRecorder) resetDisconnects() {
r.disconnectMu.Lock()
r.disconnects.Store(0)
r.disconnectReasons = make(map[string]int64)
r.disconnectSamples = nil
r.disconnectMu.Unlock()
}
func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
runID, _ := r.runID.Load().(string)
mid, _ := data.Head["x-im-client-mid"].(string)
if mid == "" || !strings.HasPrefix(mid, runID+"-") {
return
}
var content struct {
RunID string `json:"run_id"`
SenderMachine string `json:"sender_machine"`
SenderIndex int `json:"sender_index"`
Scenario string `json:"scenario"`
SentAt int64 `json:"sent_at_unix_nano"`
}
if json.Unmarshal(data.Content, &content) != nil || content.RunID != runID {
r.malformed.Add(1)
return
}
if r.historyMode.Load() {
shard := r.shard(mid)
shard.mu.Lock()
shard.history[mid] = true
shard.mu.Unlock()
return
}
index := account.Index - 1
if index < 0 || index >= r.localUsers {
return
}
shard := r.shard(mid)
shard.mu.RLock()
obs := shard.observed[mid]
shard.mu.RUnlock()
if obs == nil {
shard.mu.Lock()
obs = shard.observed[mid]
if obs == nil {
obs = &observation{topic: data.Topic, seq: data.Seq, senderMachine: content.SenderMachine,
senderIndex: content.SenderIndex, scenario: content.Scenario, sentAt: content.SentAt,
bits: make([]atomic.Uint64, (r.localUsers+63)/64)}
shard.observed[mid] = obs
}
shard.mu.Unlock()
}
word, bit := index/64, uint(index%64)
mask := uint64(1) << bit
if !markObservationBit(obs.bits, word, mask) {
r.duplicates.Add(1)
return
}
r.received.Add(1)
sequence := &r.sequences[index]
sequence.mu.Lock()
previous := sequence.last[data.Topic]
violation := previous > 0 && data.Seq < previous
if data.Seq > previous {
sequence.last[data.Topic] = data.Seq
}
sequence.mu.Unlock()
if violation {
r.outOfOrder.Add(1)
r.orderingMu.Lock()
if len(r.ordering) < 100 {
r.ordering = append(r.ordering, OrderingViolation{
ReceiverMachine: r.machine,
ReceiverIndex: account.Index,
TopicHash: topicHash(data.Topic),
PreviousSeq: previous,
CurrentSeq: data.Seq,
ClientMid: mid,
})
}
r.orderingMu.Unlock()
}
if content.SentAt > 0 {
delay := time.Since(time.Unix(0, content.SentAt))
r.latency.Observe(delay)
senderMachine := content.SenderMachine
if senderMachine == "" {
senderMachine = senderMachineFromClientMid(runID, mid)
}
if senderMachine == r.machine {
r.localLatency.Observe(delay)
} else if delay < 0 {
r.crossClockSkewSamples.Add(1)
} else {
r.crossLatency.Observe(delay)
}
}
}
func senderMachineFromClientMid(runID, mid string) string {
prefix := strings.TrimPrefix(mid, runID+"-")
parts := strings.SplitN(prefix, "-", 3)
if len(parts) < 2 {
return ""
}
return parts[1]
}
func (r *deliveryRecorder) metricCounts() (received, duplicates, outOfOrder, disconnects int64) {
return r.received.Load(), r.duplicates.Load(), r.outOfOrder.Load(), r.disconnects.Load()
}
func (r *deliveryRecorder) beginHistory() { r.historyMode.Store(true) }
func (r *deliveryRecorder) historyFound(mid string) bool {
shard := r.shard(mid)
shard.mu.RLock()
found := shard.history[mid]
shard.mu.RUnlock()
return found
}
func (r *deliveryRecorder) historyFoundAll(messageIDs []string) bool {
for _, mid := range messageIDs {
if !r.historyFound(mid) {
return false
}
}
return true
}
func (r *deliveryRecorder) unackedObservedMessages(ledger []AcceptedMessage, scenario string) []AcceptedMessage {
accepted := make(map[string]bool, len(ledger))
for _, message := range ledger {
accepted[message.ClientMid] = true
}
runID, _ := r.runID.Load().(string)
messages := make([]AcceptedMessage, 0)
for i := range r.shards {
shard := &r.shards[i]
shard.mu.RLock()
for mid, obs := range shard.observed {
if accepted[mid] || obs.seq <= 0 || obs.topic == "" {
continue
}
kind := "p2p"
if strings.HasPrefix(obs.topic, "grp") {
kind = "group"
}
observedScenario := obs.scenario
if observedScenario == "" {
observedScenario = scenario
}
message := AcceptedMessage{RunID: runID, Scenario: observedScenario, Kind: kind,
ClientMid: mid, Topic: obs.topic, Seq: obs.seq, SenderMachine: obs.senderMachine,
SenderIndex: obs.senderIndex, PeerMachine: r.machine, PeerIndex: firstObservedAtomicIndex(obs.bits)}
if obs.sentAt > 0 {
message.SentAt = time.Unix(0, obs.sentAt).UTC()
}
messages = append(messages, message)
}
shard.mu.RUnlock()
}
sort.Slice(messages, func(i, j int) bool { return messages[i].ClientMid < messages[j].ClientMid })
return messages
}
func firstObservedIndex(words []uint64) int {
for wordIndex, word := range words {
if word != 0 {
return wordIndex*64 + bits.TrailingZeros64(word) + 1
}
}
return 0
}
func firstObservedAtomicIndex(words []atomic.Uint64) int {
for wordIndex := range words {
if word := words[wordIndex].Load(); word != 0 {
return wordIndex*64 + bits.TrailingZeros64(word) + 1
}
}
return 0
}
func markObservationBit(words []atomic.Uint64, word int, mask uint64) bool {
for {
current := words[word].Load()
if current&mask != 0 {
return false
}
if words[word].CompareAndSwap(current, current|mask) {
return true
}
}
}
func (r *deliveryRecorder) onDisconnect(account AccountRecord, err error) {
category := disconnectCategory(err)
r.disconnectMu.Lock()
r.disconnects.Add(1)
r.disconnectReasons[category]++
if len(r.disconnectSamples) < 100 {
message := ""
if err != nil {
message = err.Error()
if len(message) > 300 {
message = message[:300]
}
}
r.disconnectSamples = append(r.disconnectSamples, DisconnectSample{
ReceiverMachine: r.machine, ReceiverIndex: account.Index, Category: category,
Error: message, At: time.Now().UTC(),
})
}
r.disconnectMu.Unlock()
}
func disconnectCategory(err error) string {
if err == nil {
return "unknown"
}
if errors.Is(err, io.EOF) {
return "eof"
}
if errors.Is(err, net.ErrClosed) {
return "closed_connection"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "timeout"
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "close 1006") || strings.Contains(message, "abnormal closure"):
return "abnormal_close"
case strings.Contains(message, "close 1000") || strings.Contains(message, "normal closure"):
return "normal_close"
case strings.Contains(message, "connection reset"):
return "connection_reset"
case strings.Contains(message, "broken pipe"):
return "broken_pipe"
case strings.Contains(message, "timeout") || strings.Contains(message, "deadline exceeded"):
return "timeout"
case strings.Contains(message, "use of closed network connection"):
return "closed_connection"
default:
return "other"
}
}
func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage) AgentSummary {
base.DeliveryLatency = r.latency.Snapshot()
base.DeliveryLatencyLocal = r.localLatency.Snapshot()
base.DeliveryLatencyCross = r.crossLatency.Snapshot()
r.disconnectMu.Lock()
base.Disconnects = r.disconnects.Load()
base.DisconnectReasons = cloneCountMap(r.disconnectReasons)
base.DisconnectSamples = append([]DisconnectSample(nil), r.disconnectSamples...)
r.disconnectMu.Unlock()
runID, _ := r.runID.Load().(string)
accepted := make(map[string]AcceptedMessage, len(ledger))
const missingLimit = 100000
base.Duplicates = r.duplicates.Load()
base.OutOfOrder = r.outOfOrder.Load()
base.CrossClockSkewSamples = r.crossClockSkewSamples.Load()
r.orderingMu.Lock()
base.OrderingViolations = append([]OrderingViolation(nil), r.ordering...)
r.orderingMu.Unlock()
if malformed := r.malformed.Load(); malformed > 0 {
base.Errors = append(base.Errors, fmt.Sprintf("malformed run messages=%d", malformed))
}
for _, message := range ledger {
if message.RunID != runID {
continue
}
accepted[message.ClientMid] = message
obs := r.observationSnapshot(message.ClientMid)
switch message.Kind {
case "group":
base.ExpectedGroup += int64(r.localUsers)
for index := 1; index <= r.localUsers; index++ {
if observationHas(obs, index) {
base.ReceivedGroup++
} else if len(base.Missing) < missingLimit {
base.Missing = append(base.Missing, missingFor(message, index))
}
}
case "p2p":
if message.PeerMachine == r.machine && message.PeerIndex > 0 {
base.ExpectedPeer++
if observationHas(obs, message.PeerIndex) {
base.ReceivedPeer++
} else if len(base.Missing) < missingLimit {
base.Missing = append(base.Missing, missingFor(message, message.PeerIndex))
}
}
if message.SenderMachine == r.machine && message.SenderIndex > 0 {
base.ExpectedEcho++
if observationHas(obs, message.SenderIndex) {
base.ReceivedEcho++
}
}
}
}
for i := range r.shards {
shard := &r.shards[i]
shard.mu.RLock()
for mid, obs := range shard.observed {
if _, ok := accepted[mid]; !ok {
base.UnexpectedMessages += atomicBitsetCount(obs.bits)
}
}
shard.mu.RUnlock()
}
base.RealtimeLost = (base.ExpectedPeer - base.ReceivedPeer) + (base.ExpectedGroup - base.ReceivedGroup)
if len(base.Missing) == missingLimit {
base.Errors = append(base.Errors, "missing delivery details truncated at 100000")
}
return base
}
func (r *deliveryRecorder) countRealtimeLostStored(ledger []AcceptedMessage, storedMessageIDs []string) int64 {
runID, _ := r.runID.Load().(string)
stored := make(map[string]bool, len(storedMessageIDs))
for _, mid := range storedMessageIDs {
stored[mid] = true
}
var count int64
for _, message := range ledger {
if message.RunID != runID || !stored[message.ClientMid] {
continue
}
obs := r.observationSnapshot(message.ClientMid)
switch message.Kind {
case "group":
for index := 1; index <= r.localUsers; index++ {
if !observationHas(obs, index) {
count++
}
}
case "p2p":
if message.PeerMachine == r.machine && message.PeerIndex > 0 && !observationHas(obs, message.PeerIndex) {
count++
}
}
}
return count
}
func (r *deliveryRecorder) shard(mid string) *observationShard {
var hash uint32 = 2166136261
for i := 0; i < len(mid); i++ {
hash ^= uint32(mid[i])
hash *= 16777619
}
return &r.shards[hash%uint32(len(r.shards))]
}
func (r *deliveryRecorder) observationSnapshot(mid string) *observationView {
shard := r.shard(mid)
shard.mu.RLock()
obs := shard.observed[mid]
shard.mu.RUnlock()
if obs == nil {
return nil
}
view := &observationView{topic: obs.topic, seq: obs.seq, senderMachine: obs.senderMachine,
senderIndex: obs.senderIndex, scenario: obs.scenario, sentAt: obs.sentAt,
bits: make([]uint64, len(obs.bits))}
for i := range obs.bits {
view.bits[i] = obs.bits[i].Load()
}
return view
}
func cloneCountMap(source map[string]int64) map[string]int64 {
if len(source) == 0 {
return nil
}
clone := make(map[string]int64, len(source))
for key, value := range source {
clone[key] = value
}
return clone
}
func observationHas(obs *observationView, receiverIndex int) bool {
if obs == nil || receiverIndex <= 0 {
return false
}
index := receiverIndex - 1
word, bit := index/64, uint(index%64)
return word < len(obs.bits) && obs.bits[word]&(uint64(1)<<bit) != 0
}
func atomicBitsetCount(words []atomic.Uint64) int64 {
var count int64
for i := range words {
count += int64(bits.OnesCount64(words[i].Load()))
}
return count
}
func bitsetCount(words []uint64) int64 {
var count int64
for _, word := range words {
count += int64(bits.OnesCount64(word))
}
return count
}
func missingFor(message AcceptedMessage, receiver int) MissingDelivery {
return MissingDelivery{ClientMid: message.ClientMid, Kind: message.Kind, TopicHash: topicHash(message.Topic),
Seq: message.Seq, ReceiverIndex: receiver}
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"time"
)
func TestDeliveryRecorderSummarizesPeerGroupAndDuplicates(t *testing.T) {
recorder := newDeliveryRecorder("run1", "a", 2)
peer := AcceptedMessage{RunID: "run1", Kind: "p2p", ClientMid: "run1-p-b-0001-01", Topic: "p2p-one", Seq: 10,
SenderMachine: "b", SenderIndex: 1, PeerMachine: "a", PeerIndex: 1}
group := AcceptedMessage{RunID: "run1", Kind: "group", ClientMid: "run1-g-b-0001-01", Topic: "grp-one", Seq: 20,
SenderMachine: "b", SenderIndex: 1}
recorder.onData(AccountRecord{Index: 1}, testData(peer, time.Now()))
recorder.onData(AccountRecord{Index: 1}, testData(peer, time.Now()))
recorder.onData(AccountRecord{Index: 1}, testData(group, time.Now()))
summary := recorder.summarize(AgentSummary{}, []AcceptedMessage{peer, group})
if summary.ExpectedPeer != 1 || summary.ReceivedPeer != 1 {
t.Fatalf("peer expected/received = %d/%d", summary.ExpectedPeer, summary.ReceivedPeer)
}
if summary.ExpectedGroup != 2 || summary.ReceivedGroup != 1 || summary.RealtimeLost != 1 {
t.Fatalf("group expected/received/lost = %d/%d/%d", summary.ExpectedGroup, summary.ReceivedGroup, summary.RealtimeLost)
}
if summary.Duplicates != 1 || len(summary.Missing) != 1 || summary.Missing[0].ReceiverIndex != 2 {
t.Fatalf("duplicates=%d missing=%+v", summary.Duplicates, summary.Missing)
}
}
func TestDeliveryRecorderSeparatesHistoryFromRealtime(t *testing.T) {
recorder := newDeliveryRecorder("run1", "a", 1)
message := AcceptedMessage{RunID: "run1", Kind: "p2p", ClientMid: "run1-p-b-0001-01", Topic: "p2p-one", Seq: 10,
SenderMachine: "b", SenderIndex: 1, PeerMachine: "a", PeerIndex: 1}
recorder.beginHistory()
recorder.onData(AccountRecord{Index: 1}, testData(message, time.Now()))
summary := recorder.summarize(AgentSummary{}, []AcceptedMessage{message})
if summary.ReceivedPeer != 0 || summary.RealtimeLost != 1 {
t.Fatalf("history response polluted realtime result: %+v", summary)
}
if !recorder.historyFound(message.ClientMid) {
t.Fatal("history message was not recorded")
}
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}, []string{message.ClientMid}); got != 1 {
t.Fatalf("realtime lost but persisted=%d", got)
}
}
func TestDeliveryRecorderUsesSharedHistoryForRealtimeLostStored(t *testing.T) {
recorder := newDeliveryRecorder("run1", "b", 1)
message := AcceptedMessage{RunID: "run1", Kind: "group", ClientMid: "run1-g-a-0001-01", Topic: "grp-one", Seq: 10}
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}, []string{message.ClientMid}); got != 1 {
t.Fatalf("shared history count=%d want=1", got)
}
if got := recorder.countRealtimeLostStored([]AcceptedMessage{message}, nil); got != 0 {
t.Fatalf("empty shared history count=%d want=0", got)
}
}
func TestDeliveryRecorderExportsObservedMessagesWithoutAck(t *testing.T) {
recorder := newDeliveryRecorder("run1", "b", 2)
acked := AcceptedMessage{RunID: "run1", Scenario: "group", Kind: "group", ClientMid: "run1-g-a-0001-01",
Topic: "grp-one", Seq: 10, SenderMachine: "a", SenderIndex: 1}
unacked := AcceptedMessage{RunID: "run1", Scenario: "group", Kind: "group", ClientMid: "run1-g-a-0002-01",
Topic: "grp-one", Seq: 11, SenderMachine: "a", SenderIndex: 2}
recorder.onData(AccountRecord{Index: 1}, testData(acked, time.Now()))
recorder.onData(AccountRecord{Index: 2}, testData(unacked, time.Now()))
messages := recorder.unackedObservedMessages([]AcceptedMessage{acked}, "group")
if len(messages) != 1 || messages[0].ClientMid != unacked.ClientMid || messages[0].Seq != 11 ||
messages[0].PeerMachine != "b" || messages[0].PeerIndex != 2 || messages[0].SenderIndex != 2 {
t.Fatalf("unexpected observed ledger: %+v", messages)
}
}
func TestDeliveryRecorderCapturesOrderingViolationDetails(t *testing.T) {
recorder := newDeliveryRecorder("run1", "b", 1)
newer := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-a-0001-02", Topic: "p2p-one", Seq: 12}
older := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-a-0001-01", Topic: "p2p-one", Seq: 11}
recorder.onData(AccountRecord{Index: 1}, testData(newer, time.Now()))
recorder.onData(AccountRecord{Index: 1}, testData(older, time.Now()))
summary := recorder.summarize(AgentSummary{}, nil)
if summary.OutOfOrder != 1 || len(summary.OrderingViolations) != 1 {
t.Fatalf("out_of_order=%d violations=%+v", summary.OutOfOrder, summary.OrderingViolations)
}
violation := summary.OrderingViolations[0]
if violation.ReceiverMachine != "b" || violation.ReceiverIndex != 1 ||
violation.PreviousSeq != 12 || violation.CurrentSeq != 11 || violation.ClientMid != older.ClientMid {
t.Fatalf("unexpected violation: %+v", violation)
}
}
func TestDeliveryRecorderSeparatesLocalAndCrossMachineLatency(t *testing.T) {
recorder := newDeliveryRecorder("run1", "a", 1)
local := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-a-0001-01", Topic: "p2p-one", Seq: 1}
cross := AcceptedMessage{RunID: "run1", ClientMid: "run1-p-b-0001-01", Topic: "p2p-one", Seq: 2}
recorder.onData(AccountRecord{Index: 1}, testData(local, time.Now().Add(-10*time.Millisecond)))
recorder.onData(AccountRecord{Index: 1}, testData(cross, time.Now().Add(time.Second)))
summary := recorder.summarize(AgentSummary{}, nil)
if summary.DeliveryLatencyLocal.Count != 1 || summary.DeliveryLatencyCross.Count != 0 {
t.Fatalf("unexpected split latency counts: local=%d cross=%d",
summary.DeliveryLatencyLocal.Count, summary.DeliveryLatencyCross.Count)
}
if summary.CrossClockSkewSamples != 1 {
t.Fatalf("cross clock skew samples=%d", summary.CrossClockSkewSamples)
}
}
func TestDeliveryRecorderConcurrentGroupFanout(t *testing.T) {
const users = 32
const messages = 64
recorder := newDeliveryRecorder("run1", "a", users)
ledger := make([]AcceptedMessage, messages)
for messageIndex := range ledger {
ledger[messageIndex] = AcceptedMessage{
RunID: "run1", Kind: "group", Topic: "grp-one", Seq: 1,
ClientMid: fmt.Sprintf("run1-g-b-%04d-01", messageIndex+1), SenderMachine: "b",
}
}
var writers sync.WaitGroup
for receiver := 1; receiver <= users; receiver++ {
for _, message := range ledger {
writers.Add(1)
go func(receiver int, message AcceptedMessage) {
defer writers.Done()
recorder.onData(AccountRecord{Index: receiver}, testData(message, time.Now().Add(-time.Millisecond)))
}(receiver, message)
}
}
writers.Wait()
summary := recorder.summarize(AgentSummary{}, ledger)
want := int64(users * messages)
if summary.ExpectedGroup != want || summary.ReceivedGroup != want || summary.RealtimeLost != 0 {
t.Fatalf("group expected/received/lost = %d/%d/%d, want %d/%d/0",
summary.ExpectedGroup, summary.ReceivedGroup, summary.RealtimeLost, want, want)
}
if summary.Duplicates != 0 || summary.DeliveryLatency.Count != want {
t.Fatalf("duplicates=%d latency samples=%d want=0/%d", summary.Duplicates, summary.DeliveryLatency.Count, want)
}
}
func TestDeliveryRecorderDisconnectDiagnostics(t *testing.T) {
recorder := newDeliveryRecorder("run1", "a", 1)
recorder.onDisconnect(AccountRecord{Index: 1}, errors.New("websocket: close 1006 (abnormal closure): unexpected EOF"))
recorder.onDisconnect(AccountRecord{Index: 1}, errors.New("read tcp: connection reset by peer"))
summary := recorder.summarize(AgentSummary{}, nil)
if summary.Disconnects != 2 || summary.DisconnectReasons["abnormal_close"] != 1 ||
summary.DisconnectReasons["connection_reset"] != 1 || len(summary.DisconnectSamples) != 2 {
t.Fatalf("unexpected disconnect diagnostics: count=%d reasons=%v samples=%+v",
summary.Disconnects, summary.DisconnectReasons, summary.DisconnectSamples)
}
recorder.resetDisconnects()
summary = recorder.summarize(AgentSummary{}, nil)
if summary.Disconnects != 0 || len(summary.DisconnectReasons) != 0 || len(summary.DisconnectSamples) != 0 {
t.Fatalf("disconnect diagnostics were not reset: %+v", summary)
}
}
func TestBitsetCountCountsEachBitOnce(t *testing.T) {
if got := bitsetCount([]uint64{0b1011, 0b100000}); got != 4 {
t.Fatalf("bitset count=%d want=4", got)
}
}
func testData(message AcceptedMessage, sentAt time.Time) dataPacket {
content, _ := json.Marshal(map[string]any{"run_id": message.RunID, "scenario": message.Scenario,
"sender_machine": message.SenderMachine, "sender_index": message.SenderIndex,
"sent_at_unix_nano": sentAt.UnixNano()})
return dataPacket{Topic: message.Topic, Seq: message.Seq,
Head: map[string]any{"x-im-client-mid": message.ClientMid}, Content: content}
}
+539
View File
@@ -0,0 +1,539 @@
package main
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
type ctrlPacket struct {
ID string `json:"id"`
Topic string `json:"topic"`
Code int `json:"code"`
Text string `json:"text"`
Params map[string]json.RawMessage `json:"params"`
}
type dataPacket struct {
Topic string `json:"topic"`
From string `json:"from"`
Timestamp time.Time `json:"ts"`
Seq int64 `json:"seq"`
Head map[string]any `json:"head"`
Content json.RawMessage `json:"content"`
}
type queuedDataPacket struct {
packet dataPacket
queuedAt time.Time
}
type ctrlWait struct {
ch chan ctrlPacket
kind string
sentAt time.Time
}
type timedOutCtrl struct {
kind string
sentAt time.Time
}
type ctrlObservation struct {
ID string
Kind string
Code int
SentAt time.Time
ReceivedAt time.Time
Latency time.Duration
Late bool
}
type ctrlError struct {
Code int
Text string
}
const (
dataCallbackQueueSize = 512
lateCtrlRetention = 10 * time.Minute
)
func (e *ctrlError) Error() string {
return fmt.Sprintf("ctrl code=%d text=%s", e.Code, e.Text)
}
type WSClient struct {
account AccountRecord
conn *websocket.Conn
commandTimeout time.Duration
onData func(AccountRecord, dataPacket)
onDisconnect func(AccountRecord, error)
onCtrlObserved func(AccountRecord, ctrlObservation)
callbackQueue *callbackQueueMetrics
sendMu sync.Mutex
mu sync.Mutex
seq uint64
waits map[string]ctrlWait
timedOutCtrls map[string]timedOutCtrl
done chan struct{}
data chan queuedDataPacket
closed atomic.Bool
}
func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, apiKey, password string,
onData func(AccountRecord, dataPacket), onDisconnect func(AccountRecord, error),
onCtrlObserved func(AccountRecord, ctrlObservation), callbackQueue *callbackQueueMetrics) (*WSClient, error) {
timeout, err := configDuration(cfg.CommandTimeout)
if err != nil {
return nil, err
}
endpoint, err := websocketEndpoint(account.Endpoint, apiKey)
if err != nil {
return nil, err
}
dialer := *websocket.DefaultDialer
if account.DialIP != "" {
targetHost := endpointHostname(endpoint)
var netDialer net.Dialer
dialer.NetDialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, splitErr := net.SplitHostPort(address)
if splitErr == nil && strings.EqualFold(host, targetHost) {
address = net.JoinHostPort(account.DialIP, port)
}
return netDialer.DialContext(ctx, network, address)
}
}
if cfg.InsecureTLS {
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
}
conn, response, err := dialer.DialContext(ctx, endpoint, http.Header{})
if err != nil {
if response != nil {
return nil, fmt.Errorf("websocket dial status=%d: %w", response.StatusCode, err)
}
return nil, err
}
client := &WSClient{
account: account, conn: conn, commandTimeout: timeout, onData: onData, onDisconnect: onDisconnect,
onCtrlObserved: onCtrlObserved,
callbackQueue: callbackQueue,
waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{}),
}
client.startDataProcessor()
go client.readLoop()
if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{
"id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure/1.0",
"platf": "loadtest", "tenant": cfg.Tenant,
}}); err != nil {
client.Close()
return nil, fmt.Errorf("hi %s: %w", account.Username, err)
}
login := map[string]any{"id": client.nextID("login")}
if account.Token != "" {
login["scheme"] = "token"
login["secret"] = account.Token
} else {
login["scheme"] = "basic"
login["secret"] = base64.StdEncoding.EncodeToString([]byte(account.Username + ":" + password))
login["login_type"] = 0
login["tenant_code"] = cfg.Tenant
}
ctrl, err := client.command(ctx, "login", map[string]any{"login": login})
if err != nil && account.Token != "" && password != "" {
login["id"] = client.nextID("login-basic")
login["scheme"] = "basic"
login["secret"] = base64.StdEncoding.EncodeToString([]byte(account.Username + ":" + password))
login["login_type"] = 0
login["tenant_code"] = cfg.Tenant
ctrl, err = client.command(ctx, "login", map[string]any{"login": login})
}
if err != nil {
client.Close()
return nil, fmt.Errorf("login %s: %w", account.Username, err)
}
client.applyLogin(ctrl)
pingInterval, err := configDuration(cfg.WSPingInterval)
if err != nil {
client.Close()
return nil, err
}
client.startKeepalive(pingInterval)
return client, nil
}
func endpointHostname(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Hostname()
}
func connectForPrepare(ctx context.Context, cfg *Config, account AccountRecord, api *apiClient, password, verificationCode string) (*WSClient, bool, error) {
timeout, err := configDuration(cfg.CommandTimeout)
if err != nil {
return nil, false, err
}
endpoint, err := websocketEndpoint(account.Endpoint, api.apiKey)
if err != nil {
return nil, false, err
}
dialer := *websocket.DefaultDialer
if cfg.InsecureTLS {
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
}
conn, _, err := dialer.DialContext(ctx, endpoint, http.Header{})
if err != nil {
return nil, false, err
}
client := &WSClient{account: account, conn: conn, commandTimeout: timeout,
waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{})}
go client.readLoop()
if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{
"id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure-prepare/1.0", "tenant": cfg.Tenant,
}}); err != nil {
client.Close()
return nil, false, err
}
secret := base64.StdEncoding.EncodeToString([]byte(account.Username + ":" + password))
ctrl, loginErr := client.command(ctx, "login", map[string]any{"login": map[string]any{
"id": client.nextID("login"), "scheme": "basic", "secret": secret,
"login_type": 0, "tenant_code": cfg.Tenant,
}})
created := false
if loginErr != nil {
var ce *ctrlError
if !errors.As(loginErr, &ce) || ce.Code != http.StatusUnauthorized {
client.Close()
return nil, false, loginErr
}
credential, credentialErr := api.createRegistrationCredential(ctx, account, verificationCode)
if credentialErr != nil {
client.Close()
return nil, false, credentialErr
}
acc := map[string]any{
"id": client.nextID("acc"), "user": "new", "scheme": "basic", "username": account.Username,
"registration_type": 0, "tenant_code": cfg.Tenant, "secret": secret, "login": true,
"desc": map[string]any{"public": map[string]any{"fn": strings.ToUpper(account.Username)}},
}
if credential != nil {
acc["phone"] = account.Phone
acc["cred"] = []map[string]any{{"meth": "tel", "val": account.Phone,
"resp": credential.VerificationToken, "params": map[string]any{"challenge_id": credential.ChallengeID}}}
}
ctrl, err = client.command(ctx, "acc", map[string]any{"acc": acc})
if err != nil {
client.Close()
return nil, false, err
}
created = true
}
client.applyLogin(ctrl)
return client, created, nil
}
func websocketEndpoint(raw, apiKey string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", err
}
if u.Scheme != "ws" && u.Scheme != "wss" {
return "", fmt.Errorf("unsupported websocket scheme %q", u.Scheme)
}
q := u.Query()
if q.Get("apikey") == "" {
q.Set("apikey", apiKey)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func (c *WSClient) applyLogin(ctrl ctrlPacket) {
if raw := ctrl.Params["user"]; len(raw) > 0 {
_ = json.Unmarshal(raw, &c.account.UserID)
}
if raw := ctrl.Params["token"]; len(raw) > 0 {
_ = json.Unmarshal(raw, &c.account.Token)
}
if raw := ctrl.Params["expires"]; len(raw) > 0 {
var value string
if json.Unmarshal(raw, &value) == nil {
c.account.TokenExpires, _ = time.Parse(time.RFC3339Nano, value)
}
}
}
func (c *WSClient) Account() AccountRecord { return c.account }
func (c *WSClient) Subscribe(ctx context.Context, topic string) error {
if topic == "" {
return errors.New("empty topic")
}
id := c.nextID("sub")
_, err := c.command(ctx, "sub", map[string]any{"sub": map[string]any{
"id": id, "topic": topic, "get": map[string]any{"what": "desc"},
}})
return err
}
func (c *WSClient) GetData(ctx context.Context, topic string, since, before int64, limit int) error {
if topic == "" || since <= 0 || before <= since || limit <= 0 {
return errors.New("invalid get data range")
}
id := c.nextID("get-data")
_, err := c.command(ctx, "get", map[string]any{"get": map[string]any{
"id": id, "topic": topic, "what": "data",
"data": map[string]any{"since": since, "before": before, "limit": limit},
}})
return err
}
func (c *WSClient) Publish(ctx context.Context, topic, clientMid, runID, scenario string, messageIndex int) (AcceptedMessage, error) {
id := c.nextID("pub")
sentAt := time.Now().UTC()
ctrl, err := c.command(ctx, "pub", map[string]any{"pub": map[string]any{
"id": id, "topic": topic, "noecho": false,
"head": map[string]any{"mime": "application/json", "x-im-schema": 1, "x-im-type": "text", "x-im-client-mid": clientMid},
"content": map[string]any{"text": "loadtest", "run_id": runID, "scenario": scenario,
"sender_machine": c.account.Machine, "sender_index": c.account.Index,
"message_index": messageIndex, "sent_at_unix_nano": sentAt.UnixNano()},
}})
if err != nil {
return AcceptedMessage{}, err
}
seq, err := ctrlInt64(ctrl.Params["seq"])
if err != nil || seq <= 0 {
return AcceptedMessage{}, fmt.Errorf("publish ACK has invalid seq: %w", err)
}
kind := "p2p"
if strings.HasPrefix(topic, "grp") {
kind = "group"
}
return AcceptedMessage{RunID: runID, Scenario: scenario, Kind: kind, ClientMid: clientMid,
Topic: topic, Seq: seq, SenderMachine: c.account.Machine, SenderIndex: c.account.Index,
SentAt: sentAt, AckAt: time.Now().UTC()}, nil
}
func ctrlInt64(raw json.RawMessage) (int64, error) {
if len(raw) == 0 {
return 0, errors.New("missing value")
}
var number json.Number
if err := json.Unmarshal(raw, &number); err == nil {
return strconv.ParseInt(number.String(), 10, 64)
}
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return 0, err
}
return strconv.ParseInt(text, 10, 64)
}
func (c *WSClient) command(ctx context.Context, kind string, message map[string]any) (ctrlPacket, error) {
id := nestedMessageID(message)
if id == "" {
return ctrlPacket{}, errors.New("command id is required")
}
wait := make(chan ctrlPacket, 1)
sentAt := time.Now()
c.mu.Lock()
c.waits[id] = ctrlWait{ch: wait, kind: kind, sentAt: sentAt}
c.mu.Unlock()
c.sendMu.Lock()
err := c.conn.WriteJSON(message)
c.sendMu.Unlock()
if err != nil {
c.removeWait(id)
return ctrlPacket{}, err
}
timer := time.NewTimer(c.commandTimeout)
defer timer.Stop()
select {
case ctrl := <-wait:
if ctrl.Code < 200 || ctrl.Code >= 300 {
return ctrl, &ctrlError{Code: ctrl.Code, Text: ctrl.Text}
}
return ctrl, nil
case <-timer.C:
c.mu.Lock()
entry, pending := c.waits[id]
if pending {
delete(c.waits, id)
if entry.kind == "pub" {
c.timedOutCtrls[id] = timedOutCtrl{kind: entry.kind, sentAt: entry.sentAt}
}
}
c.mu.Unlock()
if !pending {
// The read loop won the timer race and placed the response in the
// buffered channel before removing the waiter.
ctrl := <-wait
if ctrl.Code < 200 || ctrl.Code >= 300 {
return ctrl, &ctrlError{Code: ctrl.Code, Text: ctrl.Text}
}
return ctrl, nil
}
if entry.kind == "pub" {
time.AfterFunc(lateCtrlRetention, func() {
c.mu.Lock()
if current, ok := c.timedOutCtrls[id]; ok && current.sentAt.Equal(sentAt) {
delete(c.timedOutCtrls, id)
}
c.mu.Unlock()
})
}
return ctrlPacket{}, fmt.Errorf("ctrl timeout id=%s", id)
case <-ctx.Done():
c.removeWait(id)
return ctrlPacket{}, ctx.Err()
case <-c.done:
c.removeWait(id)
return ctrlPacket{}, errors.New("websocket closed")
}
}
func nestedMessageID(message map[string]any) string {
for _, value := range message {
if body, ok := value.(map[string]any); ok {
if id, ok := body["id"].(string); ok {
return id
}
}
}
return ""
}
func (c *WSClient) nextID(prefix string) string {
n := atomic.AddUint64(&c.seq, 1)
return fmt.Sprintf("%s-%s-%04d-%d", prefix, c.account.Machine, c.account.Index, n)
}
func (c *WSClient) removeWait(id string) {
c.mu.Lock()
delete(c.waits, id)
c.mu.Unlock()
}
func (c *WSClient) readLoop() {
defer func() {
_ = c.conn.Close()
if c.data != nil {
close(c.data)
}
close(c.done)
}()
for {
var envelope map[string]json.RawMessage
if err := c.conn.ReadJSON(&envelope); err != nil {
if !c.closed.Load() && c.onDisconnect != nil {
c.onDisconnect(c.account, err)
}
return
}
if raw := envelope["ctrl"]; len(raw) > 0 {
var ctrl ctrlPacket
if json.Unmarshal(raw, &ctrl) == nil && ctrl.ID != "" {
c.mu.Lock()
wait, pending := c.waits[ctrl.ID]
late, timedOut := c.timedOutCtrls[ctrl.ID]
receivedAt := time.Now()
if pending {
wait.ch <- ctrl
delete(c.waits, ctrl.ID)
} else if timedOut {
delete(c.timedOutCtrls, ctrl.ID)
}
c.mu.Unlock()
if c.onCtrlObserved != nil && (pending && wait.kind == "pub" || timedOut && late.kind == "pub") {
kind, sentAt, isLate := wait.kind, wait.sentAt, false
if timedOut {
kind, sentAt, isLate = late.kind, late.sentAt, true
}
c.onCtrlObserved(c.account, ctrlObservation{ID: ctrl.ID, Kind: kind, Code: ctrl.Code,
SentAt: sentAt, ReceivedAt: receivedAt, Latency: receivedAt.Sub(sentAt), Late: isLate})
}
}
continue
}
if raw := envelope["data"]; len(raw) > 0 && c.onData != nil {
var data dataPacket
if json.Unmarshal(raw, &data) == nil {
if c.data == nil {
c.onData(c.account, data)
} else {
queued := queuedDataPacket{packet: data, queuedAt: time.Now()}
c.callbackQueue.enqueue()
c.data <- queued
}
}
}
}
}
func (c *WSClient) startDataProcessor() {
if c.onData == nil || c.data != nil {
return
}
c.data = make(chan queuedDataPacket, dataCallbackQueueSize)
go func() {
for data := range c.data {
c.callbackQueue.startCallback(data.queuedAt)
c.onData(c.account, data.packet)
c.callbackQueue.finishCallback()
}
}()
}
func (c *WSClient) startKeepalive(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
deadline := time.Now().Add(interval / 2)
if err := c.conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
_ = c.conn.Close()
return
}
case <-c.done:
return
}
}
}()
}
func (c *WSClient) alive() bool {
if c == nil || c.closed.Load() {
return false
}
select {
case <-c.done:
return false
default:
return true
}
}
func (c *WSClient) Close() {
if c == nil || !c.closed.CompareAndSwap(false, true) {
return
}
_ = c.conn.Close()
}
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestWSClientObservesCtrlAfterCommandTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var command map[string]any
if err = conn.ReadJSON(&command); err != nil {
return
}
time.Sleep(40 * time.Millisecond)
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": "pub-a-0001", "code": 202, "text": "accepted"}})
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
observed := make(chan ctrlObservation, 1)
client := &WSClient{
account: AccountRecord{Machine: "a", Index: 1}, conn: conn, commandTimeout: 10 * time.Millisecond,
waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{}),
onCtrlObserved: func(_ AccountRecord, observation ctrlObservation) { observed <- observation },
}
go client.readLoop()
defer client.Close()
_, err = client.command(context.Background(), "pub", map[string]any{"pub": map[string]any{"id": "pub-a-0001"}})
if err == nil || !strings.Contains(err.Error(), "ctrl timeout") {
t.Fatalf("command error=%v, want ctrl timeout", err)
}
select {
case observation := <-observed:
if !observation.Late || observation.Kind != "pub" || observation.Code != 202 || observation.Latency < 10*time.Millisecond {
t.Fatalf("unexpected observation: %+v", observation)
}
case <-time.After(time.Second):
t.Fatal("late ctrl response was not observed")
}
}
func TestWSClientKeepaliveSendsPing(t *testing.T) {
ping := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetPingHandler(func(string) error {
select {
case ping <- struct{}{}:
default:
}
return conn.WriteControl(websocket.PongMessage, nil, time.Now().Add(time.Second))
})
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
client := &WSClient{conn: conn, waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{})}
go client.readLoop()
client.startKeepalive(10 * time.Millisecond)
defer client.Close()
select {
case <-ping:
case <-time.After(time.Second):
t.Fatal("websocket keepalive ping was not received")
}
}
func TestWSClientReadLoopClosesConnectionAfterDecodeError(t *testing.T) {
serverSawClose := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if err = conn.WriteMessage(websocket.TextMessage, []byte("{")); err != nil {
return
}
if _, _, err = conn.ReadMessage(); err != nil {
serverSawClose <- struct{}{}
}
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
client := &WSClient{conn: conn, waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{})}
go client.readLoop()
select {
case <-client.done:
case <-time.After(time.Second):
t.Fatal("read loop did not exit after malformed frame")
}
select {
case <-serverSawClose:
case <-time.After(time.Second):
t.Fatal("read loop exited without closing the underlying connection")
}
}
func TestWSClientDataCallbackDoesNotBlockPong(t *testing.T) {
pong := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetPongHandler(func(string) error {
select {
case pong <- struct{}{}:
default:
}
return nil
})
if err = conn.WriteJSON(map[string]any{"data": map[string]any{
"topic": "grp-test", "seq": 1, "head": map[string]any{}, "content": map[string]any{},
}}); err != nil {
return
}
if err = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil {
return
}
conn.SetReadDeadline(time.Now().Add(time.Second))
for {
if _, _, readErr := conn.ReadMessage(); readErr != nil {
return
}
}
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
blocked := make(chan struct{})
client := &WSClient{
conn: conn, waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{}),
onData: func(AccountRecord, dataPacket) { <-blocked },
}
client.startDataProcessor()
go client.readLoop()
defer func() {
close(blocked)
client.Close()
}()
select {
case <-pong:
case <-time.After(2 * time.Second):
t.Fatal("pong was blocked by the data callback")
}
}
+2
View File
@@ -5,3 +5,5 @@ This directory contains code related to monitoring Tinode server. Supported moni
* [InfluxDB](https://www.influxdata.com/) * [InfluxDB](https://www.influxdata.com/)
See [exporter/README](./exporter/README.md) for more details. See [exporter/README](./exporter/README.md) for more details.
`im-server-alerts.yaml` contains the P0/P1 cluster routing, WebSocket balance, Kafka lag, backlog age, and dead-letter alert rules for a Prometheus Operator deployment. Apply it only after the cluster has the `PrometheusRule` CRD installed.
+85
View File
@@ -0,0 +1,85 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: im-server-p0-p1
namespace: im-server-system
spec:
groups:
- name: im-server-cluster
rules:
- alert: IMClusterP2MQueueHigh
expr: im_cluster_p2m_queue_length / clamp_min(im_cluster_p2m_queue_capacity, 1) > 0.70
for: 30s
labels:
severity: warning
annotations:
summary: "IM cluster proxy queue is above 70%"
- alert: IMClusterP2MQueueCritical
expr: im_cluster_p2m_queue_length / clamp_min(im_cluster_p2m_queue_capacity, 1) > 0.90
for: 30s
labels:
severity: critical
annotations:
summary: "IM cluster proxy queue is above 90%"
- alert: IMClusterEnqueueTimeout
expr: sum by (pod, peer) (increase(im_cluster_p2m_enqueue_total{result="timeout"}[1m])) > 0
labels:
severity: critical
annotations:
summary: "IM cluster rejected proxy-to-master enqueue"
- alert: IMClusterRouteRejected
expr: sum by (pod, peer, result) (increase(im_cluster_route_total{result=~"hub_full|peer_rejected"}[1m])) > 0
labels:
severity: critical
annotations:
summary: "IM cluster route was rejected"
- alert: IMClusterPeerUnavailable
expr: im_cluster_live_peers < im_cluster_expected_peers
for: 15s
labels:
severity: critical
annotations:
summary: "IM server has unavailable cluster peers"
- alert: IMWebSocketConnectionsImbalanced
expr: max(im_ws_connections_active) / clamp_min(avg(im_ws_connections_active), 1) > 1.30
for: 2m
labels:
severity: warning
annotations:
summary: "IM WebSocket connections are imbalanced across pods"
- name: im-server-persistence
rules:
- alert: IMDatabasePoolHigh
expr: im_db_pool_in_use_connections / clamp_min(im_db_pool_max_open_connections, 1) > 0.85
for: 1m
labels:
severity: warning
annotations:
summary: "IM database pool usage is above 85%"
- alert: IMDatabasePoolCritical
expr: im_db_pool_in_use_connections / clamp_min(im_db_pool_max_open_connections, 1) > 0.95
for: 30s
labels:
severity: critical
annotations:
summary: "IM database pool usage is above 95%"
- alert: IMKafkaPersistDeadLetter
expr: sum by (kind, reason) (increase(im_kafka_dead_letters_total[5m])) > 0
labels:
severity: critical
annotations:
summary: "IM Kafka persistence produced dead letters"
- alert: IMKafkaConsumerLag
expr: max by (pod, topic, group) (im_kafka_consumer_lag) > 1000
for: 2m
labels:
severity: warning
annotations:
summary: "IM Kafka consumer lag is above 1000"
- alert: IMKafkaBacklogOld
expr: max by (pod, topic, group) (im_kafka_backlog_oldest_age_seconds) > 60
for: 2m
labels:
severity: critical
annotations:
summary: "IM Kafka persistence backlog is older than 60 seconds"
+45 -37
View File
@@ -1,15 +1,15 @@
-- IM tenant business database initialization script. -- IM tenant business database initialization script.
-- Source baseline: local MySQL database `tinode` on 2026-09-13. -- Source baseline: shared MySQL database `meeting_im` on 2026-09-13.
-- Purpose: create a clean business database for a new IM tenant. -- Purpose: create a clean business database for a new IM tenant.
-- --
-- Usage: -- Usage:
-- 1. Replace placeholders before execution: -- 1. Replace placeholders before execution:
-- __TENANT_DB__ -> new tenant business database, for example `tinode_acme`. -- __TENANT_DB__ -> target tenant business database, normally `meeting_im`.
-- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant. -- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant.
-- __TENANT_CODE__ -> public enterprise code, for example `acme`. -- __TENANT_CODE__ -> public enterprise code, for example `acme`.
-- __TENANT_NAME__ -> tenant display name. -- __TENANT_NAME__ -> tenant display name.
-- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant. -- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant.
-- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from `tinode`. -- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from another database.
-- --
-- Notes: -- Notes:
-- - This script intentionally seeds only stable metadata/default configuration. -- - This script intentionally seeds only stable metadata/default configuration.
@@ -168,9 +168,10 @@ CREATE TABLE IF NOT EXISTS `im_attendance_records` (
CREATE TABLE IF NOT EXISTS `im_attendance_rules` ( CREATE TABLE IF NOT EXISTS `im_attendance_rules` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`rule_id` varchar(64) NOT NULL, `rule_id` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL, `name` varchar(128) NOT NULL,
`workday` tinyint NOT NULL DEFAULT '1', `workday` varchar(32) NOT NULL DEFAULT '1,2,3,4,5',
`location_required` tinyint NOT NULL DEFAULT '0', `location_required` tinyint NOT NULL DEFAULT '0',
`wifi_required` tinyint NOT NULL DEFAULT '0', `wifi_required` tinyint NOT NULL DEFAULT '0',
`allow_outside_checkin` tinyint NOT NULL DEFAULT '1', `allow_outside_checkin` tinyint NOT NULL DEFAULT '1',
@@ -181,12 +182,13 @@ CREATE TABLE IF NOT EXISTS `im_attendance_rules` (
`created_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), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_attendance_rules_rule` (`rule_id`), UNIQUE KEY `uk_im_attendance_rules_tenant_rule` (`tenant_id`,`rule_id`),
KEY `idx_im_attendance_rules_state` (`state`,`effective_from`,`effective_to`) KEY `idx_im_attendance_rules_state` (`tenant_id`,`state`,`effective_from`,`effective_to`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_attendance_shifts` ( CREATE TABLE IF NOT EXISTS `im_attendance_shifts` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`shift_id` varchar(64) NOT NULL, `shift_id` varchar(64) NOT NULL,
`rule_id` varchar(64) NOT NULL, `rule_id` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL, `name` varchar(128) NOT NULL,
@@ -199,8 +201,8 @@ CREATE TABLE IF NOT EXISTS `im_attendance_shifts` (
`created_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), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_attendance_shifts_shift` (`shift_id`), UNIQUE KEY `uk_im_attendance_shifts_tenant_shift` (`tenant_id`,`shift_id`),
KEY `idx_im_attendance_shifts_rule` (`rule_id`,`state`,`sort_order`) KEY `idx_im_attendance_shifts_rule` (`tenant_id`,`rule_id`,`state`,`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_audit_logs` ( CREATE TABLE IF NOT EXISTS `im_audit_logs` (
@@ -397,6 +399,7 @@ CREATE TABLE IF NOT EXISTS `im_favorites` (
CREATE TABLE IF NOT EXISTS `im_feedback_config` ( CREATE TABLE IF NOT EXISTS `im_feedback_config` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`categories` json DEFAULT NULL, `categories` json DEFAULT NULL,
`max_length` int NOT NULL DEFAULT '1000', `max_length` int NOT NULL DEFAULT '1000',
`attachment_policy` json DEFAULT NULL, `attachment_policy` json DEFAULT NULL,
@@ -404,7 +407,8 @@ CREATE TABLE IF NOT EXISTS `im_feedback_config` (
`diagnostics_notice` text NOT NULL, `diagnostics_notice` text NOT NULL,
`created_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), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`) PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_feedback_config_tenant` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_feedback_submissions` ( CREATE TABLE IF NOT EXISTS `im_feedback_submissions` (
@@ -463,7 +467,7 @@ CREATE TABLE IF NOT EXISTS `im_filemsglinks` (
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`createdat` datetime(3) NOT NULL, `createdat` datetime(3) NOT NULL,
`fileid` bigint NOT NULL, `fileid` bigint NOT NULL,
`msgid` int DEFAULT NULL, `msgid` bigint DEFAULT NULL,
`topic` char(25) DEFAULT NULL, `topic` char(25) DEFAULT NULL,
`userid` bigint DEFAULT NULL, `userid` bigint DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@@ -721,6 +725,8 @@ CREATE TABLE IF NOT EXISTS `im_group_messages` (
`msg_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等', `msg_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等',
`status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked', `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked',
`content` json DEFAULT NULL COMMENT '消息正文或 Drafty 内容', `content` json DEFAULT NULL COMMENT '消息正文或 Drafty 内容',
`content_nonce` varbinary(12) DEFAULT NULL COMMENT '每条消息独立随机Nonce',
`key_version` int NOT NULL DEFAULT '0' COMMENT '商户消息DEK版本',
`head` json DEFAULT NULL COMMENT '消息头,包含 @、引用、客户端能力等', `head` json DEFAULT NULL COMMENT '消息头,包含 @、引用、客户端能力等',
`mentioned_user_ids` json DEFAULT NULL COMMENT '@ 用户列表,便于提醒和检索', `mentioned_user_ids` json DEFAULT NULL COMMENT '@ 用户列表,便于提醒和检索',
`mention_all` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否 @ 全体', `mention_all` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否 @ 全体',
@@ -949,6 +955,7 @@ CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
CREATE TABLE IF NOT EXISTS `im_mailbox_config` ( CREATE TABLE IF NOT EXISTS `im_mailbox_config` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`enabled` tinyint NOT NULL DEFAULT '1', `enabled` tinyint NOT NULL DEFAULT '1',
`allow_anonymous` tinyint NOT NULL DEFAULT '1', `allow_anonymous` tinyint NOT NULL DEFAULT '1',
`required_fields` json DEFAULT NULL, `required_fields` json DEFAULT NULL,
@@ -959,7 +966,8 @@ CREATE TABLE IF NOT EXISTS `im_mailbox_config` (
`notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_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), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`) PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_mailbox_config_tenant` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_mailbox_submissions` ( CREATE TABLE IF NOT EXISTS `im_mailbox_submissions` (
@@ -1088,13 +1096,15 @@ CREATE TABLE IF NOT EXISTS `im_message_revisions` (
`actor` bigint NOT NULL, `actor` bigint NOT NULL,
`head` json DEFAULT NULL, `head` json DEFAULT NULL,
`content` json DEFAULT NULL, `content` json DEFAULT NULL,
`content_nonce` varbinary(12) DEFAULT NULL,
`key_version` int NOT NULL DEFAULT '0',
`reason` varchar(256) NOT NULL DEFAULT '', `reason` varchar(256) NOT NULL DEFAULT '',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_message_revisions_version` (`topic`,`seqid`,`version`) UNIQUE KEY `uk_im_message_revisions_version` (`topic`,`seqid`,`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_messages` ( CREATE TABLE IF NOT EXISTS `im_messages` (
`id` int NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`createdat` datetime(3) NOT NULL, `createdat` datetime(3) NOT NULL,
`updatedat` datetime(3) NOT NULL, `updatedat` datetime(3) NOT NULL,
`deletedat` datetime(3) DEFAULT NULL, `deletedat` datetime(3) DEFAULT NULL,
@@ -1108,6 +1118,8 @@ CREATE TABLE IF NOT EXISTS `im_messages` (
`recalled_by` bigint DEFAULT NULL, `recalled_by` bigint DEFAULT NULL,
`head` json DEFAULT NULL, `head` json DEFAULT NULL,
`content` json DEFAULT NULL, `content` json DEFAULT NULL,
`content_nonce` varbinary(12) DEFAULT NULL,
`key_version` int NOT NULL DEFAULT '0',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`), UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`),
UNIQUE KEY `uk_im_messages_topic_seq` (`topic`,`seqid`) UNIQUE KEY `uk_im_messages_topic_seq` (`topic`,`seqid`)
@@ -1485,6 +1497,7 @@ CREATE TABLE IF NOT EXISTS `im_verification_codes` (
CREATE TABLE IF NOT EXISTS `im_workbench_apps` ( CREATE TABLE IF NOT EXISTS `im_workbench_apps` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`code` varchar(64) NOT NULL, `code` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL, `name` varchar(128) NOT NULL,
`icon_file_id` varchar(64) NOT NULL DEFAULT '', `icon_file_id` varchar(64) NOT NULL DEFAULT '',
@@ -1496,8 +1509,8 @@ CREATE TABLE IF NOT EXISTS `im_workbench_apps` (
`created_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), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_workbench_apps_code` (`code`), UNIQUE KEY `uk_im_workbench_apps_tenant_code` (`tenant_id`,`code`),
KEY `idx_im_workbench_apps_enabled_order` (`enabled`,`sort_order`) KEY `idx_im_workbench_apps_enabled_order` (`tenant_id`,`enabled`,`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `im_youth_mode_settings` ( CREATE TABLE IF NOT EXISTS `im_youth_mode_settings` (
@@ -1527,15 +1540,15 @@ VALUES (1, 'version', CURRENT_TIMESTAMP(3), 123)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
-- Workbench default apps. These are product navigation entries, not tenant business data. -- Workbench default apps. These are product navigation entries, not tenant business data.
INSERT INTO `im_workbench_apps` (`code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`) INSERT INTO `im_workbench_apps` (`tenant_id`, `code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`)
VALUES VALUES
('attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'), (2, 'attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'), (2, 'monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), (2, 'daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'),
('organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'), (2, 'organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'),
('executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'), (2, 'executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'), (2, 'meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'),
('meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled') (2, 'meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `name` = VALUES(`name`),
`route` = VALUES(`route`), `route` = VALUES(`route`),
@@ -1546,17 +1559,17 @@ VALUES
`updated_at` = CURRENT_TIMESTAMP(3); `updated_at` = CURRENT_TIMESTAMP(3);
-- Default attendance settings. Tenants can edit or replace them later. -- Default attendance settings. Tenants can edit or replace them later.
INSERT INTO `im_attendance_rules` (`rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`) INSERT INTO `im_attendance_rules` (`tenant_id`, `rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`)
VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1) VALUES (2, 'att_rule_default', '默认考勤规则', '1,2,3,4,5', 0, 0, 1, 1)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
INSERT INTO `im_attendance_shifts` (`shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`) INSERT INTO `im_attendance_shifts` (`tenant_id`, `shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`)
VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10) VALUES (2, 'shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:00:00', '19:00:00', 10)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
-- Default mailbox configuration. -- Default mailbox configuration.
INSERT INTO `im_mailbox_config` (`id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`) INSERT INTO `im_mailbox_config` (`tenant_id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`)
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') VALUES (2, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求。')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`enabled` = VALUES(`enabled`), `enabled` = VALUES(`enabled`),
`allow_anonymous` = VALUES(`allow_anonymous`), `allow_anonymous` = VALUES(`allow_anonymous`),
@@ -1588,8 +1601,8 @@ VALUES
`updated_at` = CURRENT_TIMESTAMP(3); `updated_at` = CURRENT_TIMESTAMP(3);
-- Default feedback configuration. -- Default feedback configuration.
INSERT INTO `im_feedback_config` (`id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`) INSERT INTO `im_feedback_config` (`tenant_id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`)
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') VALUES (2, '["bug","suggestion","complaint","other"]', 2000, '{"max_files":5,"purpose":"feedback"}', 1, '')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`categories` = VALUES(`categories`), `categories` = VALUES(`categories`),
`max_length` = VALUES(`max_length`), `max_length` = VALUES(`max_length`),
@@ -1606,15 +1619,10 @@ SET FOREIGN_KEY_CHECKS = 1;
-- Keep this section in the same change set as tenant DB initialization. -- Keep this section in the same change set as tenant DB initialization.
-- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id. -- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id.
INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `db_name`, `db_dsn`, `state`, `created_by`, `updated_by`) INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `state`, `created_by`, `updated_by`)
VALUES (2,'system', 'system merchant', 'system default merchant', 'meeting_im', '', 1, 0, 0) VALUES (2,'system', 'system merchant', 'system default merchant', 1, 0, 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`code` = VALUES(`code`), `code` = VALUES(`code`),
`name` = VALUES(`name`), `name` = VALUES(`name`),
`db_name` = VALUES(`db_name`),
`state` = VALUES(`state`), `state` = VALUES(`state`),
`updated_at` = CURRENT_TIMESTAMP(3); `updated_at` = CURRENT_TIMESTAMP(3);
INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`)
VALUES (2, 0, 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
+23 -681
View File
@@ -8,15 +8,30 @@ import (
"os" "os"
"strings" "strings"
_ "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
) )
const sharedBusinessDatabase = "meeting_im"
// This startup helper intentionally performs validation only. Database schema
// changes are managed by the checked-in SQL migrations and must not be applied
// implicitly while an IM Server pod is starting.
func main() { func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://") rawDSN := strings.TrimSpace(os.Getenv("IM_MYSQL_DSN"))
dsn := strings.TrimPrefix(rawDSN, "mysql://")
if dsn == "" { if dsn == "" {
fatal("IM_MYSQL_DSN is required") fatal("IM_MYSQL_DSN is required")
} }
db, err := sql.Open("mysql", dsn)
config, err := mysqlDriver.ParseDSN(dsn)
if err != nil {
fatal(fmt.Errorf("parse IM_MYSQL_DSN: %w", err))
}
if !strings.EqualFold(strings.TrimSpace(config.DBName), sharedBusinessDatabase) {
fatal(fmt.Errorf("IM_MYSQL_DSN must select %s; got %q", sharedBusinessDatabase, config.DBName))
}
db, err := sql.Open("mysql", config.FormatDSN())
if err != nil { if err != nil {
fatal(err) fatal(err)
} }
@@ -25,691 +40,18 @@ func main() {
fatal(err) fatal(err)
} }
statements := []string{ var database string
"CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci", if err = db.QueryRow("SELECT DATABASE()").Scan(&database); err != nil {
`CREATE TABLE IF NOT EXISTS hotgo.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 INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS hotgo.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 hotgo.im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
"CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci",
// createIMSecurityTables, createIMNotificationTables and createIMRelationTables
// (server/db/mysql/adapter.go) are only reached from CreateDb's fresh-install
// path, which our pre-existing tinode DB never runs. Recreate their tables here.
`CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
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(submission_id),
INDEX idx_im_realname_user_latest(user_id, created_at),
INDEX idx_im_realname_id_hash(id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
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(token_id),
INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
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(user_id, event_id),
INDEX idx_im_system_notifications_inbox(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
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(request_id),
UNIQUE INDEX uk_im_friend_requests_idempotency(applicant_id, idempotency_key),
INDEX idx_im_friend_requests_pair(applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox(target_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friendships(
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_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_blocks(
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_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
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(favorite_id),
UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox(user_id, id DESC),
INDEX idx_im_favorites_type(user_id, item_type, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_call_records(
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id INT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
duration_ms BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(3) NOT NULL,
accepted_at DATETIME(3),
finished_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_call_records_call(call_id),
UNIQUE INDEX uk_im_call_records_topic_seq(topic, seq_id),
INDEX idx_im_call_records_caller(caller_id, id DESC),
INDEX idx_im_call_records_callee(callee_id, id DESC),
INDEX idx_im_call_records_status(status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
icon_file_id VARCHAR(64) NOT NULL DEFAULT '',
route VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
unread_key VARCHAR(64) NOT NULL DEFAULT '',
enabled TINYINT NOT NULL DEFAULT 1,
permission VARCHAR(64) NOT NULL DEFAULT 'enabled',
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_workbench_apps_code(code),
INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_org_departments(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
parent_id VARCHAR(64) NOT NULL DEFAULT 'root',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'active',
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_org_departments_department(department_id),
INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id),
INDEX idx_im_org_departments_name(name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_org_members(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
position VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
employment_state VARCHAR(16) NOT NULL DEFAULT 'active',
contactable TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
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_org_members_department_user(department_id, user_id),
INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id),
INDEX idx_im_org_members_user(user_id),
INDEX idx_im_org_members_display_name(display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_meetings(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
location VARCHAR(256) NOT NULL DEFAULT '',
state VARCHAR(16) NOT NULL DEFAULT 'scheduled',
reminder_minutes JSON,
idempotency_key VARCHAR(128),
version INT NOT NULL DEFAULT 1,
canceled_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_meetings_meeting(meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer(organizer_id, start_at),
INDEX idx_im_meetings_state_time(state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
response VARCHAR(16) NOT NULL DEFAULT 'pending',
response_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_meeting_participants_user(meeting_id, user_id),
INDEX idx_im_meeting_participants_user_time(user_id, meeting_id),
INDEX idx_im_meeting_participants_response(meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_daily_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_date DATE NOT NULL,
content TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_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_daily_reports_report(report_id),
UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date),
INDEX idx_im_daily_reports_author_id(author_id, id DESC),
INDEX idx_im_daily_reports_date_status(report_date, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_month CHAR(7) NOT NULL,
work_summary TEXT NOT NULL,
next_plan TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_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_monthly_reports_report(report_id),
UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month),
INDEX idx_im_monthly_reports_author_id(author_id, id DESC),
INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config(
id BIGINT NOT NULL AUTO_INCREMENT,
enabled TINYINT NOT NULL DEFAULT 1,
allow_anonymous TINYINT NOT NULL DEFAULT 1,
required_fields JSON,
max_length INT NOT NULL DEFAULT 5000,
attachment_policy JSON,
notice_title VARCHAR(128) NOT NULL DEFAULT '',
notice_content TEXT NOT NULL,
notice_updated_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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
sender_id BIGINT NOT NULL,
subject VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
anonymous TINYINT NOT NULL DEFAULT 0,
attachment_file_ids JSON,
notice_version VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_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_mailbox_submissions_submission(submission_id),
UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key),
INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC),
INDEX idx_im_mailbox_submissions_status(status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
birth_date DATE,
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) 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_user_profiles_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
group_invite_verification TINYINT NOT NULL DEFAULT 0,
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_user_privacy_settings_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT,
platform VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
latest_version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64) NOT NULL,
update_type VARCHAR(16) NOT NULL DEFAULT 'none',
release_notes TEXT NOT NULL,
store_url VARCHAR(512) NOT NULL DEFAULT '',
package_url VARCHAR(512) NOT NULL DEFAULT '',
checksum VARCHAR(128) NOT NULL DEFAULT '',
remind_after_seconds INT NOT NULL DEFAULT 0,
published_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_app_releases_platform_channel(platform, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
token VARCHAR(512) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
platform VARCHAR(32) 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_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
policy_version INT NOT NULL DEFAULT 1,
restrictions JSON,
enabled_at DATETIME(3),
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_mode_settings_user_purpose(user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges(
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL,
notice_code VARCHAR(64) NOT NULL,
notice_params JSON,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
decision VARCHAR(16) NOT NULL DEFAULT '',
action_token_id VARCHAR(64) 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_risk_challenges_challenge(challenge_id),
INDEX idx_im_risk_challenges_user_state(user_id, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons(
id BIGINT NOT NULL AUTO_INCREMENT,
target_type VARCHAR(16) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
requires_description TINYINT NOT NULL DEFAULT 0,
allows_evidence TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT 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_abuse_reasons_type_code(target_type, reason_code),
INDEX idx_im_abuse_reasons_type_order(target_type, enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
snapshot JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
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_abuse_reports_report(report_id),
UNIQUE INDEX uk_im_abuse_reports_idempotency(reporter_id, idempotency_key),
INDEX idx_im_abuse_reports_target(target_type, target_topic, target_seq, target_user_id),
INDEX idx_im_abuse_reports_reporter(reporter_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_feedback_config(
id BIGINT NOT NULL AUTO_INCREMENT,
categories JSON,
max_length INT NOT NULL DEFAULT 1000,
attachment_policy JSON,
allow_contact TINYINT NOT NULL DEFAULT 1,
diagnostics_notice TEXT 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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
category VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(128) NOT NULL DEFAULT '',
attachment_file_ids JSON,
client_context JSON,
include_diagnostics TINYINT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
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_feedback_submissions_feedback(feedback_id),
UNIQUE INDEX uk_im_feedback_submissions_idempotency(user_id, idempotency_key),
INDEX idx_im_feedback_submissions_user(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, stmt := range statements {
if _, err = db.Exec(stmt); err != nil {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
for _, stmt := range []string{
"ALTER TABLE hotgo.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled TINYINT NOT NULL DEFAULT 0 AFTER password_hash",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN policy_version INT NOT NULL DEFAULT 1 AFTER enabled",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN restrictions JSON AFTER policy_version",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions",
"ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent",
"ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region",
"ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts",
} {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
if _, err = db.Exec(`INSERT INTO hotgo.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)`); err != nil {
fatal(err) fatal(err)
} }
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant_config(tenant_id,max_users,max_groups,max_group_members,created_by,updated_by) if !strings.EqualFold(database, sharedBusinessDatabase) {
SELECT id,0,0,0,0,0 FROM hotgo.im_tenant WHERE code='default' fatal(fmt.Errorf("connected database is %q, want %q", database, sharedBusinessDatabase))
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES
('attendance','打卡','/attendance',10,'attendance',1,'enabled'),
('monthly_reports','月报','/reports/monthly',20,'monthly_reports',1,'enabled'),
('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'),
('organization','部门框架','/organization',40,'organization',1,'enabled'),
('executive_mailbox','举报信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled'),
('meetings','会议通知','/meetings',60,'meetings',1,'enabled'),
('meeting_notes','会议笔记','/meeting-notes',70,'',1,'enabled')
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content)
VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_app_releases(platform,channel,latest_version,min_supported_version,update_type,release_notes,remind_after_seconds)
VALUES
('ios','stable','1.0.0','1.0.0','none','当前已是最新版本。',0),
('android','stable','1.0.0','1.0.0','none','当前已是最新版本。',0)
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_abuse_reasons(target_type,reason_code,label,requires_description,allows_evidence,sort_order)
VALUES
('user','fraud','诈骗欺诈',0,1,10),
('user','harassment','骚扰辱骂',0,1,20),
('user','illegal','违法违规',1,1,30),
('group','fraud','诈骗欺诈',0,1,10),
('group','spam','垃圾广告',0,1,20),
('group','illegal','违法违规',1,1,30),
('message','fraud','诈骗欺诈',0,1,10),
('message','spam','垃圾广告',0,1,20),
('message','harassment','骚扰辱骂',0,1,30),
('message','illegal','违法违规',1,1,40)
ON DUPLICATE KEY UPDATE label=VALUES(label),requires_description=VALUES(requires_description),allows_evidence=VALUES(allows_evidence),sort_order=VALUES(sort_order),updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_feedback_config(id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice)
VALUES(1,'["bug","suggestion","complaint","other"]',1000,'{"max_files":5,"purpose":"feedback"}',1,'仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
fatal(err)
}
if err = dropTenantIDColumns(db, "tinode"); err != nil {
fatal(err)
} }
var code, dbName string fmt.Printf("IM shared database validated: %s\n", database)
var state int
if err = db.QueryRow("SELECT code, db_name, state FROM hotgo.im_tenant WHERE code='default'").Scan(&code, &dbName, &state); err != nil {
fatal(err)
}
fmt.Printf("hotgo.im_tenant default: code=%s db_name=%s state=%d\n", code, dbName, state)
rows, err := db.Query(`SELECT table_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='tinode' AND COLUMN_NAME='tenant_id'
ORDER BY table_name`)
if err != nil {
fatal(err)
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err = rows.Scan(&table); err != nil {
fatal(err)
}
tables = append(tables, table)
}
if err = rows.Err(); err != nil {
fatal(err)
}
if len(tables) == 0 {
fmt.Println("tinode tenant_id columns: none")
} else {
fmt.Printf("tinode tenant_id columns: %s\n", strings.Join(tables, ","))
}
} }
func fatal(v any) { func fatal(v any) {
fmt.Fprintln(os.Stderr, v) fmt.Fprintln(os.Stderr, v)
os.Exit(1) os.Exit(1)
} }
func dropTenantIDColumns(db *sql.DB, schema string) error {
fks, err := queryPairs(db, `SELECT DISTINCT table_name, constraint_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id' AND REFERENCED_TABLE_NAME IS NOT NULL`,
schema)
if err != nil {
return err
}
for _, fk := range fks {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(fk[0]) + " DROP FOREIGN KEY " + quoteIdent(fk[1])); err != nil {
return fmt.Errorf("drop foreign key %s.%s: %w", fk[0], fk[1], err)
}
}
indexes, err := queryPairs(db, `SELECT DISTINCT table_name, index_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id' AND INDEX_NAME<>'PRIMARY'`,
schema)
if err != nil {
return err
}
for _, idx := range indexes {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(idx[0]) + " DROP INDEX " + quoteIdent(idx[1])); err != nil {
return fmt.Errorf("drop index %s.%s: %w", idx[0], idx[1], err)
}
}
rows, err := db.Query(`SELECT table_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id'
ORDER BY table_name`, schema)
if err != nil {
return err
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err = rows.Scan(&table); err != nil {
return err
}
tables = append(tables, table)
}
if err = rows.Err(); err != nil {
return err
}
for _, table := range tables {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(table) + " DROP COLUMN tenant_id"); err != nil {
return fmt.Errorf("drop column %s.tenant_id: %w", table, err)
}
}
return nil
}
func queryPairs(db *sql.DB, query string, args ...any) ([][2]string, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var pairs [][2]string
for rows.Next() {
var first, second string
if err = rows.Scan(&first, &second); err != nil {
return nil, err
}
pairs = append(pairs, [2]string{first, second})
}
return pairs, rows.Err()
}
func quoteIdent(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}

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