Compare commits

..
25 Commits
Author SHA1 Message Date
richjohnrambo 7193ba2316 fix: 完善后台组织内部查询 2026-09-03 17:11:36 +08:00
richjohnrambo db00e994d5 fix: keep group avatar update for legacy groups 2026-09-03 14:25:10 +08:00
richjohnrambo a0bd0b0649 Fix Kafka message seq idempotency 2026-09-03 13:21:26 +08:00
richjohnrambo 256f768d15 fix: allow group avatar uploads 2026-09-03 11:52:06 +08:00
richjohnrambo 871e0ba735 Fix conversation context topic fallback 2026-09-03 11:47:45 +08:00
richjohnrambo 2e4e1f0df3 Fix HTTP message forward delivery 2026-09-03 11:11:48 +08:00
richjohnrambo 54e58763ff Improve message forward compatibility 2026-09-03 10:37:42 +08:00
richjohnrambo 1eee0179f1 fix: return saved group nickname profile 2026-09-03 10:01:24 +08:00
richjohnrambo 8193491686 fix: persist group nickname for legacy groups 2026-09-03 09:58:18 +08:00
richjohnrambo a94f60c254 Add youth mode restriction labels 2026-09-02 13:47:12 +08:00
richjohnrambo 79947a2c6f Improve youth mode action token flow 2026-09-02 11:25:25 +08:00
richjohnrambo 7f6995d8fe Disable sensitive challenge rate limit for test 2026-09-02 10:51:15 +08:00
richjohnrambo ac45223e39 Fix IM report and group permission regressions 2026-09-02 10:01:38 +08:00
richjohnrambo 15a4174ed1 fix: support agora rtc join delivery flow 2026-09-02 01:42:19 +08:00
richjohnrambo dc5e09f2ae feat: add agora rtc token join endpoint 2026-09-02 01:05:15 +08:00
richjohnrambo 318ce04f7d Refine Kafka persistence topic boundaries 2026-09-01 23:23:08 +08:00
richjohnrambo f8a286cec2 fix: reuse active security challenge before rate limit 2026-09-01 23:00:14 +08:00
richjohnrambo 929f847003 Refactor Kafka publishing without DB outbox 2026-09-01 23:00:14 +08:00
richjohnrambo 18237cf3ec docs: add agora call integration flow 2026-09-01 19:01:37 +08:00
richjohnrambo edced2675b feat: add conversation system action messages 2026-09-01 13:12:24 +08:00
richjohnrambo 39e7e019c7 fix: scan attendance appeal idempotency key 2026-08-31 14:36:30 +08:00
richjohnrambo 6c6aa9f4c1 fix: scan attendance shifts from mysql 2026-08-31 14:10:34 +08:00
richjohnrambo 44ad626f63 chore: log attendance API failures 2026-08-31 13:54:32 +08:00
richjohnrambo 61ab313f35 fix: ensure attendance schema for tenant routing 2026-08-31 13:44:47 +08:00
richjohnrambo 79630c26f5 fix: compile attendance appeal insert 2026-08-31 13:30:44 +08:00
48 changed files with 4315 additions and 929 deletions
+5 -5
View File
@@ -155,17 +155,17 @@
"time_window_seconds": 300,
"services": {
"hotgo-admin": {
"secret": "8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b",
"secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"]
}
}
},
"admin_internal_api": {
"enabled": true,
"base_url": "http://im-admin:8000",
"service_name": "im-server",
"secret": "b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097",
"timeout_ms": 3000
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET",
"timeout_ms": $ADMIN_INTERNAL_API_TIMEOUT_MS
},
"acc_validation": {
+33 -44
View File
@@ -170,13 +170,12 @@ Admin 内部配置 API 只返回 IM 运行需要的字段,不返回后台管
| `im.admin.command.result` | IM 回传命令执行结果。 |
| `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 |
| `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 |
| `im.message.persist` | 私聊、群聊消息异步落库。 |
| `im.message.persist` | 私聊消息异步落库。 |
| `im.group.message.persist` | 群聊消息异步落库。 |
| `im.message.push` | 离线 Push 任务。 |
| `im.file.security` | 文件内容安全、恶意文件检测任务。 |
| `im.file.lifecycle` | 文件引用绑定、释放、回收、合规删除。 |
| `im.workbench.event` | 会议、日报、月报、信箱、工作台红点事件。 |
| `im.cache.invalidate` | 跨节点缓存失效。 |
| `im.audit.event` | 审计事件。 |
| `im.audit.event` | IM 与 Admin/HotGo 的跨系统审计事件。 |
| `im.dead.letter` | 消费失败死信。 |
首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。
@@ -418,19 +417,19 @@ IM 调 Admin 配置接口时,Admin 只校验服务身份和接口白名单,
### 9.1 文件
文件上传下载不走 Kafka,OSS 是文件字节流主链路。Kafka 只处理上传完成后的事件:
文件上传下载不走 Kafka,OSS 是文件字节流主链路。当前只有消息落库使用 Kafka;文件生命周期改为同步处理:
1. `im.file.security`:内容安全、恶意文件检测。
2. `im.file.lifecycle`:业务引用绑定、释放、临时文件回收、合规删除。
3. `im.business.event`:文件审核失败、合规处置等 C 端通知。
1. `im.file.security`:仍可用于内容安全、恶意文件检测。
2. 消息附件引用绑定在消息落库 consumer 内同步执行。
3. 群文件登记/删除由对应 HTTP 业务接口同步落库。
### 9.2 工作台
工作台查询不走 Kafka,状态变化和提醒走 Kafka:
工作台查询不走 Kafka,状态变化和提醒当前也改为同步处理:
1. 会议创建、更新、取消、提醒、响应:`im.workbench.event`。
2. 日报/月报提交、撤回、重开、催办:`im.workbench.event`。
3. 总经理信箱提交、处理结果:`im.workbench.event` + `im.audit.event`。
1. 会议创建、更新、取消、提醒、响应:业务接口同步生成通知/红点事件。
2. 日报/月报提交、撤回、重开、催办:业务接口同步生成通知/红点事件。
3. 总经理信箱提交、处理结果:业务接口同步处理。
4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。
## 10. 首批落地建议
@@ -490,8 +489,8 @@ IM Server 侧只访问租户业务库,负责执行 Admin Command、维护租
| 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 |
| 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 |
| 内部只读 API | 提供 `/internal/admin-query/*`。 | Admin 实时查询租户业务数据。 |
| 文件任务 | 上传完成后生产/消费 `im.file.security`、`im.file.lifecycle`。 | 文件审核、引用绑定、回收。 |
| 消息异步落库 | 接入 `im.message.persist`。 | 私聊/群聊消息削峰落库。 |
| 文件任务 | 上传完成、附件绑定、群文件登记/删除同步处理。 | 文件审核、引用绑定、回收。 |
| 消息异步落库 | 接入 `im.message.persist`、`im.group.message.persist`。 | 私聊/群聊消息削峰落库。 |
| Push 任务 | 生产 `im.message.push`。 | 离线 Push 异步投递。 |
| 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 |
| 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 |
@@ -722,14 +721,14 @@ IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终
当前处理方式:
1. WS/HTTP 发送入口先同步完成权限校验、业务幂等、`seq_id` 分配和 Topic 游标占用。
2. Kafka 开启时,发送入口先写租户库 `im_kafka_outbox`,由 Outbox worker 可靠生产 `im.message.persist`,客户端仍立即拿到已分配的 `seq_id`。
1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。
2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
4. 消费者按 `message_kind` 区分私聊与群聊:
- `p2p` 写入 `im_messages`。
- `group` 写入 `im_group_messages`。
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
6. 失败按统一退避重试,超过上限写入 `im_kafka_dead_letters` 并投递 `im.dead.letter`。
6. 失败按统一退避重试 3 次,超过上限先投递 `im.dead.letter`,再由 dead letter consumer 写入 `im_kafka_dead_letters`。
### 11.10 IM Server 已接入的缓存失效消费
@@ -833,7 +832,7 @@ IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin
3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。
4. 全端登出只踢在线连接,不修改用户状态。
5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。
6. 命令执行成功后统一回传 `im.admin.command.result`,并写入 `im.audit.event`。
6. 命令执行成功后统一回传 `im.admin.command.result`;跨系统审计通过 `im.audit.event` 投递给 Admin/HotGo 同步落表。
7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。
@@ -874,43 +873,35 @@ IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin
}
```
### 11.9 IM Server 已接入的 Kafka Outbox Worker
### 11.9 IM Server 已移除 Kafka DB Outbox
IM Server 已接入租户库 `im_kafka_outbox` 通用发送 worker,用于避免“业务库写成功但 Kafka 未发送”的事件丢失。
当前能力:
1. 启动后按 active 租户扫描每个租户库的 `im_kafka_outbox`。
2. 扫描 `pending/failed` 且已到 `next_retry_at` 的事件。
3. 发送成功后标记 `sent`、写入 `sent_at`。
4. 发送失败后标记 `failed`,按 Kafka 配置的 `retry_backoff_ms` 写入下一次重试时间。
5. 首个接入方为 `im.message.persist`,后续文件生命周期、工作台事件、审计事件可复用同一 outbox。
IM Server 不再维护租户库 `im_kafka_outbox`,Kafka 事件统一由发送入口直接生产。生产侧使用 Kafka writer 的 `max_retry_count` 控制发送重试;消费侧使用 `im_kafka_consume_log` 做处理幂等和失败重试,超过上限后进入 `im.dead.letter`。
本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。
### 11.10 IM Server 已接入的文件生命周期事件
### 11.10 IM Server 文件生命周期同步处理
IM Server 已接入 `im.file.lifecycle` 的首批生产与消费,用于把文件上传、附件绑定、群文件登记/删除纳入可靠事件链路。
IM Server 不再生产或消费 `im.file.lifecycle`。文件相关的小流量动作改为同步执行。
当前事件:
| `event_type` | 生产时机 | 消费行为 |
| --- | --- | --- |
| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前作为可靠生命周期事实记录,文件安全任务仍由上传完成事务创建。 |
| `file.attachments.bind` | `im.message.persist` 消费成功后,如果消息携带附件。 | 私聊写 `im_filemsglinks`;群聊写 `im_group_files`。 |
| `group.file.created` | 群文件 API 登记成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 |
| `group.file.deleted` | 群文件 API 删除成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 |
| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前无需 Kafka 消费。 |
| `file.attachments.bind` | 消息落库 consumer 成功后,如果消息携带附件。 | 同步写 `im_filemsglinks` 或 `im_group_files`。 |
| `group.file.created` | 群文件 API 登记成功后。 | HTTP 业务接口同步完成。 |
| `group.file.deleted` | 群文件 API 删除成功后。 | HTTP 业务接口同步完成。 |
说明:
1. Kafka 开启时,消息附件绑定从消息落库消费者中拆出,改由 `im.file.lifecycle` 消费者完成。
2. Kafka 关闭时,仍沿用原同步绑定路径,兼容本地环境。
1. 消息附件绑定在消息落库 consumer 内同步完成。
2. Kafka 关闭时,仍沿用原同步消息落库与附件绑定路径,兼容本地环境。
3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。
4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。
### 11.11 IM Server 已接入的工作台事件
### 11.11 IM Server 工作台事件同步处理
IM Server 已接入 `im.workbench.event` 的首批生产与消费。
IM Server 不再生产或消费 `im.workbench.event`。工作台小流量事件由业务接口同步创建系统通知和 `im_business_events_outbox`。
当前生产范围:
@@ -923,14 +914,12 @@ IM Server 已接入 `im.workbench.event` 的首批生产与消费。
当前消费行为:
1. 消费 `im.workbench.event`。
2. 按 `target_user_ids` 生成系统通知。
3. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
4. 使用 `im_kafka_consume_log` 做消费幂等,失败按统一重试和死信规则处理。
1. 按 `target_user_ids` 同步生成系统通知。
2. 同步写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
### 11.12 IM Server 已接入的审计事件
IM Server 已接入 `im.audit.event` 的可靠生产,审计事件写入租户库 `im_kafka_outbox` 后由 Kafka Outbox worker 发送。
IM Server 对跨系统审计仍生产 `im.audit.event`。该 topic 由 Admin/HotGo 消费并同步写审计表;IM Server 本地不消费该 topic,也不通过它做内部异步落库。
当前审计范围:
@@ -941,7 +930,7 @@ IM Server 已接入 `im.audit.event` 的可靠生产,审计事件写入租户
| Kafka 死信 | `kafka.dead_letter.created`。 |
| 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 |
审计事件当前不在 IM 内部落审计表,预期由 Admin/HotGo 消费 `im.audit.event` 后写入 `hotgo` 审计库或审计表。
审计事件当前不在 IM 内部通过 Kafka 落审计表,只作为 IM -> Admin/HotGo 的跨系统写事件。
## 12. 风险与约束
+49 -66
View File
@@ -6,9 +6,9 @@
## 1. 联调目标
1. 验证 Kafka 基础生产、消费、幂等、重试、死信、Outbox 能正常工作。
1. 验证 Kafka 基础生产、消费、幂等、重试、死信能正常工作。
2. 验证 Admin 后台操作通过 `im.admin.command` 投递到 IM Server,并通过 `im.admin.command.result` 返回最终状态。
3. 验证 IM Server 消息异步落库、文件生命周期、工作台事件、审计事件、缓存失效、死信处理、用户治理命令全部可用。
3. 验证 IM Server 私聊/群聊消息异步落库、缓存失效、死信处理、用户治理命令全部可用;文件生命周期和工作台事件同步落库。
4. 验证 Admin 与 IM Server 不跨库直连:Admin 查询租户业务数据走 IM 内部 API;IM 查询 hotgo 配置走 Admin 内部 API。
5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。
@@ -21,7 +21,7 @@
| MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 |
| Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 |
| Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 |
| OSS | 文件上传下载仍走 OSS,Kafka 只处理文件元数据和生命周期事件。 |
| OSS | 文件上传下载仍走 OSS,文件元数据和生命周期小流量动作同步处理。 |
| Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 |
| IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 |
@@ -33,10 +33,9 @@
| `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 |
| `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 |
| `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 |
| `im.message.persist` | IM -> IM | 私聊、群聊消息异步落库。 |
| `im.file.lifecycle` | IM -> IM | 文件上传完成、附件绑定、群文件登记/删除。 |
| `im.workbench.event` | Admin/IM -> IM | 工作台事件、红点、系统通知。 |
| `im.audit.event` | IM -> Admin/审计消费者 | 审计事件。 |
| `im.message.persist` | IM -> IM | 私聊消息异步落库。 |
| `im.group.message.persist` | IM -> IM | 群聊消息异步落库。 |
| `im.audit.event` | IM -> Admin/HotGo | 跨系统审计事件。 |
| `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 |
说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。
@@ -54,7 +53,6 @@ SOURCE docs/sql/IM-Kafka基础表SQL.sql;
| 表 | 用途 |
| --- | --- |
| `im_kafka_consume_log` | 消费幂等、重试状态。 |
| `im_kafka_outbox` | IM 侧可靠生产 Kafka 事件。 |
| `im_kafka_dead_letters` | 死信记录与人工处理状态。 |
| `im_messages` | 私聊消息存储。 |
| `im_group_messages` | 群聊消息存储。 |
@@ -70,7 +68,6 @@ Admin/HotGo 侧需具备:
| `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 |
| `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 |
| `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 |
| `im_audit_events` | Admin 消费 `im.audit.event` 后落 HotGo,用于 Kafka 审计事件列表。 |
| Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 |
Admin/HotGo 侧需执行:
@@ -95,7 +92,7 @@ SOURCE server/storage/data/generate/im_kafka_audit_menu.sql;
"client_id": "im-server",
"consumer_group": "im-server",
"allow_auto_topic_creation": false,
"max_retry_count": 5
"max_retry_count": 3
},
"internal_api": {
"enabled": true,
@@ -121,8 +118,8 @@ SOURCE server/storage/data/generate/im_kafka_audit_menu.sql;
Admin 需要:
1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.workbench.event`。
2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`、`im.audit.event`。
1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`。
2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`。
3. 内部接口客户端调用 IM `/internal/admin-query/*`。如果 IM API 前缀为 `/v1`,Admin 配置 `imInternalApi.baseUrl` 应包含 `/v1`,例如 `http://im-server:6060/v1`。
4. 内部接口服务端提供 IM `/internal/im-config/*`。
5. 内部接口签名与 IM 一致。
@@ -151,7 +148,7 @@ METHOD + "\n" + RequestURI + "\n" + BodySHA256Hex + "\n" + Timestamp + "\n" + No
| 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 |
| IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 |
| Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 |
| 审计 | 消费 `im.audit.event` 并落 HotGo 审计表。 | 关键业务动作生产审计事件。 |
| 审计 | 消费 `im.audit.event` 并同步落 HotGo 审计表。 | 关键业务动作生产跨系统审计事件,不在 IM 内部消费落库。 |
### 2.7 IM 内部查询接口矩阵
@@ -598,23 +595,22 @@ Payload:
### TC-KAFKA-016 文件上传完成事件
目标:验证文件上传完成后产生 `im.file.lifecycle` 事实事件。
目标:验证文件上传完成后不再产生 `im.file.lifecycle` Kafka 事件。
步骤:
1. 客户端上传文件到 OSS。
2. 上传完成后查询租户库文件记录。
3. 检查 Kafka outbox 和 `im.file.lifecycle`。
3. 确认 Kafka topic 中没有 `im.file.lifecycle` 依赖。
预期:
1. 文件字节流只进入 OSS。
2. Kafka 只包含文件元数据,不包含文件内容。
3. 上传完成事件可被消费并审计。
2. 上传完成后的文件记录已同步落库。
### TC-KAFKA-017 消息附件绑定
目标:验证消息落库后附件引用由 `im.file.lifecycle` 绑定。
目标:验证消息落库后附件引用同步绑定。
步骤:
@@ -632,7 +628,7 @@ Payload:
### TC-KAFKA-018 群文件登记与删除
目标:验证群文件业务事件接入 `im.file.lifecycle`。
目标:验证群文件业务由 HTTP 接口同步落库。
步骤:
@@ -661,13 +657,11 @@ Payload:
预期:
1. 每个动作产生 `im.workbench.event`。
2. 消费后写 `im_system_notifications`。
3. 写 `im_business_events_outbox`。
4. 在线 C 端收到系统通知或红点刷新。
5. 审计事件已投递。
1. 每个动作同步写 `im_system_notifications`。
2. 同步写 `im_business_events_outbox`。
3. 在线 C 端收到系统通知或红点刷新。
Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作台测试事件」,或调用 `POST /admin/imKafkaCommand/workbenchEvent`,验证 Admin 具备生产 `im.workbench.event` 的能力。真实会议、日报/月报、信箱业务结果仍以 IM Server 对应业务用例为准。
Admin 连通性补充:工作台事件不再走 Kafka,真实会议、日报/月报、信箱业务结果以 IM Server 对应业务用例为准。
### TC-KAFKA-020 日报/月报事件
@@ -682,9 +676,8 @@ Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作
预期:
1. 对应事件进入 `im.workbench.event`。
2. 通知目标用户正确。
3. 工作台红点或系统通知可被客户端看到。
1. 通知目标用户正确。
2. 工作台红点或系统通知可被客户端看到。
### TC-KAFKA-021 总经理信箱事件
@@ -693,9 +686,8 @@ Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作
步骤:
1. 用户提交信箱内容。
2. 检查 `im.workbench.event`。
3. 检查 `im.audit.event`。
4. Admin 后台消费后生成处理单。
2. 检查同步生成的通知/业务事件。
3. Admin 后台生成处理单。
预期:
@@ -867,7 +859,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
### TC-KAFKA-029 审计事件
目标:验证关键后台和敏感操作都有审计事件。
目标:确认 IM Server 对跨系统审计仍生产 `im.audit.event`,由 Admin/HotGo 消费后同步落审计表。
覆盖:
@@ -879,28 +871,19 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
预期:
1. `im.audit.event` 收到事件。
2. 事件包含 `tenant_code`、`actor`、`biz_type`、`biz_id`、`event_type`、`occurred_at`。
3. Admin 审计消费者可落 HotGo `im_audit_events`。
4. Admin 可在「IM管理 -> Kafka审计事件」查看审计事件列表和详情。
1. IM Server 生产 `im.audit.event`。
2. Kafka consumer 启动日志中没有 IM 侧审计事件 consumer。
3. Admin/HotGo 消费审计事件并同步写入审计表。
### TC-KAFKA-030 Outbox 可靠投递
### TC-KAFKA-030 IM 侧 Kafka 生产失败
目标:验证 IM 侧 Kafka Outbox 防止业务成功但 Kafka 丢事件。
步骤:
1. 临时停止 Kafka。
2. 执行会产生 outbox 的操作,如发送消息、提交工作台事件。
3. 查询 `im_kafka_outbox`。
4. 恢复 Kafka。
5. 等待 outbox worker 投递。
目标:验证 IM 侧 Kafka 直接生产失败时不会产生 DB outbox,调用方收到发送失败。
预期:
1. Kafka 停止时业务 outbox 记录为 `pending/failed`。
2. 恢复后记录变为 `sent`。
3. 下游消费者最终收到事件。
1. Kafka 停止时 IM 不写 `im_kafka_outbox`,消息发送返回失败。
2. 恢复后客户端可重新发送。
3. 重复发送依赖 `client_mid` 和消费日志保持幂等。
4. 不重复产生业务副作用。
## 5. Admin 侧联调检查清单
@@ -912,7 +895,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| Command 生产 | `event_id/idempotency_key/tenant_code/biz_id` 稳定可追踪。 |
| Result 消费 | `im.admin.command.result` 能更新操作单状态。 |
| 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 |
| 审计消费 | `im.audit.event` 能落 HotGo `im_audit_events`,并可在 Kafka审计事件页面查看。 |
| 审计 | 消费 `im.audit.event`,同步落 HotGo 审计表。 |
| 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 |
| 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 |
| 页面状态 | 后台页面显示 pending/running/succeeded/failed/dead,不把 Kafka 发送成功当业务成功。 |
@@ -921,7 +904,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 检查项 | 验收标准 |
| --- | --- |
| Kafka 启动 | producer、consumer、outbox worker 正常启动。 |
| Kafka 启动 | producer、consumer 正常启动,IM 侧无 DB outbox worker。 |
| 租户路由 | `tenant_code` 能路由到正确租户库。 |
| 消费幂等 | 重复 `event_id` 不重复执行业务。 |
| 重试 | 可重试错误按退避时间重试。 |
@@ -931,9 +914,9 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 |
| 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 |
| 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 |
| 文件生命周期 | 附件引用绑定到正确业务表。 |
| 工作台事件 | 生成系统通知和 WS 业务事件。 |
| 审计 | 关键动作进入 `im.audit.event`。 |
| 文件生命周期 | 文件记录、附件引用、群文件同步落到正确业务表。 |
| 工作台事件 | 同步生成系统通知和 WS 业务事件。 |
| 审计 | IM 生产 `im.audit.event` 给 Admin/HotGo,IM 本地不消费审计 topic。 |
## 7. 回归用例
@@ -958,14 +941,14 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 |
| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 |
| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 |
| 消息未落库 | 查 `im_kafka_outbox` 是否 sent,`im.message.persist` 是否消费成功。 |
| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 |
| 消息未落库 | 私聊查 `im.message.persist`,群聊查 `im.group.message.persist` 是否消费成功,以及 `im_kafka_consume_log.last_reason/last_error`。 |
| 附件未绑定 | 查消息是否带附件,以及同步绑定逻辑是否返回错误。 |
| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 |
| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 |
## 9. 联调完成标准
1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期、工作台、缓存失效、死信。
1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期同步处理、工作台同步处理、缓存失效、死信。
2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。
3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。
4. 死信可查询、可忽略、可解决、可重放。
@@ -994,13 +977,13 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 用例/链路 | 测试内容 | 结果 | 证据 |
| --- | --- | --- | --- |
| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.file.lifecycle`、`im.workbench.event` consumer started。 |
| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.group.message.persist` consumer started。 |
| TC-KAFKA-002 | Admin -> IM 内部查询 HMAC。 | 通过 | `GET /internal/admin-query/tenants/default/users?page=1&per_page=1` 返回 `code=0`,`total=29`。 |
| TC-KAFKA-004 | IM -> Admin 内部配置 HMAC。 | 通过 | `GET /internal/im-config/tenants?limit=1000&state=active` 返回 `code=0` 和 default 租户。 |
| TC-KAFKA-008/011 | `user.logout_all` 后台用户治理命令。 | 通过 | `tinode.im_kafka_consume_log` 中 `local-logout-fixed-*` 状态为 `succeeded`。 |
| TC-KAFKA-023 | `im.cache.invalidate` 通用缓存失效。 | 通过 | `local-cache-*` 写入 `im_kafka_consume_log`,状态 `succeeded`,IM 日志显示 `kafka cache invalidate applied`。 |
| TC-KAFKA-025 | 非法 `user.logout_all` 进入死信。 | 通过 | `local-logout-*` 状态为 `dead`,`im_kafka_dead_letters` 写入 `MALFORMED_COMMAND`。 |
| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | Admin 消费 `im.audit.event` 链路已验证;正式落表应为 `hotgo.im_audit_events`。 |
| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | IM 生产 `im.audit.event`,Admin/HotGo 消费后同步落审计表。 |
本轮发现并处理:
@@ -1011,7 +994,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| IM 启动缺少 UID key | IM 报 `crypto/xtea: invalid key size 0`。 | Docker 启动显式传入 `UID_ENCRYPTION_KEY`、`API_KEY_SALT`、`AUTH_TOKEN_KEY`。 |
| HotGo 表前缀不一致 | Admin 内部配置接口错误查询 `hg_im_tenant`,但 IM 业务/配置表正式表名应保持 `im_tenant`。 | 联调时暴露的是 Admin 查询/ORM 表名前缀问题;正式方案必须修 Admin 侧表名映射或 DAO 前缀逻辑,不能新增 `hg_im_*` 兼容表。 |
| Admin result 消费格式不兼容 | Admin 消费 `im.admin.command.result` 报 `IM命令结果缺少command_id`。 | Admin consumer 已兼容 Kafka Envelope + payload 格式。 |
| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | 正式方案必须修 Admin 侧审计模型/DAO 表名映射,所有 `im_*` 业务表不加 `hg_` 前缀。 |
| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 后错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | Admin/HotGo 同步写审计表时,仍需保证所有 `im_*` 业务表不加 `hg_` 前缀。 |
本轮未覆盖,后续继续:
@@ -1023,7 +1006,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 |
| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 |
| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 |
| Outbox | Kafka 停止/恢复时 `im_kafka_outbox`、Admin outbox 的可靠投递。 |
| Kafka 生产失败 | Kafka 停止/恢复时,确认 IM 不写 `im_kafka_outbox`,接口/WS 返回发送失败,恢复后可重新发送。 |
| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 |
### 10.2 2026-08-27 完整后端链路联调
@@ -1055,11 +1038,11 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| TC-KAFKA-011 | 全端登出。 | 通过 | `user.logout_all` 消费 `succeeded`。 |
| TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 |
| TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 |
| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `message.persist.group` 消费 `succeeded`,`im_group_messages` 写入测试群消息。 |
| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | `file.upload.completed` 消费 `succeeded`。 |
| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `im.group.message.persist` 消费 `message.persist.group`,`im_group_messages` 写入测试群消息。 |
| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | 文件上传完成不再走 Kafka。 |
| TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 |
| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | `group.file.created/group.file.deleted` 消费 `succeeded`。 |
| TC-KAFKA-019 | 工作台会议事件。 | 通过 | `workbench.meeting.created` 消费 `succeeded`,写入 `im_system_notifications` 和 `im_business_events_outbox`。 |
| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | 群文件登记/删除同步落库。 |
| TC-KAFKA-019 | 工作台会议事件。 | 通过 | 同步写入 `im_system_notifications` 和 `im_business_events_outbox`。 |
| TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 |
| TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 |
| TC-KAFKA-024 | 群热消息缓存失效。 | 通过 | `cache.group_hot_messages.invalidate` 消费 `succeeded`。 |
@@ -1067,7 +1050,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| TC-KAFKA-026 | 死信查询。 | 通过 | `/internal/admin-query/tenants/default/kafka/dead-letters` 和详情接口返回 200。 |
| TC-KAFKA-027 | 死信忽略与解决。 | 通过 | `kafka.dead_letter.ignore/resolve` 消费 `succeeded`,死信状态变为 `ignored/resolved`。 |
| TC-KAFKA-028 | 死信重放。 | 通过 | `kafka.dead_letter.replay` 消费 `succeeded`,死信状态变为 `replayed`,`replay_count=1`。 |
| TC-KAFKA-029 | 审计事件。 | 通过 | 用户治理、群治理、死信、文件等事件投递 `im.audit.event`;正式落表应为 `hotgo.im_audit_events`。 |
| TC-KAFKA-029 | 审计事件。 | 通过 | `im.audit.event` 投递给 Admin/HotGo,消费侧同步落审计表。 |
| TC-KAFKA-030 | IM Kafka Outbox 可靠投递。 | 通过 | Kafka 停止时 outbox `pending -> failed`;Kafka 恢复并重启 IM 后 `failed -> sent`,下游消费成功。 |
本轮消费统计:
+13 -12
View File
@@ -20,15 +20,16 @@ Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败
| Topic | 用途 | 典型生产方 | 典型消费方 |
| --- | --- | --- | --- |
| `im.message.persist` | 私聊、群聊消息异步落库 | IM WS 节点 | 消息持久化消费者 |
| `im.message.persist` | 私聊消息异步落库 | IM WS 节点 | 消息持久化消费者 |
| `im.group.message.persist` | 群聊消息异步落库 | IM WS 节点 | 群消息持久化消费者 |
| `im.message.push` | 离线 Push 提醒 | 消息落库消费者、业务事件生产方 | 极光/厂商 Push 消费者 |
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 | IM HTTP、Admin 后台 | IM 节点事件投递消费者 |
| `im.group.admin.event` | Admin 后台群操作投递到 C 端 | Admin 后台 | IM 节点 WS 投递消费者 |
| `im.cache.invalidate` | 跨节点缓存失效 | 业务写接口、后台配置 | IM 节点缓存消费者 |
| `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 |
| `im.file.lifecycle` | 孤立文件回收、引用绑定、合规删除 | 文件服务、业务写接口 | 文件生命周期消费者 |
| `im.audit.event` | 审计日志 | 所有敏感写链路 | 审计消费者 |
| `im.workbench.event` | 工作台模块红点、会议、日报、月报、信箱事件 | 工作台业务服务、Admin 后台 | IM WS 投递、系统通知消费者 |
| `im.file.lifecycle` | 已不采用 | 文件生命周期小流量动作同步处理 | 无 |
| `im.audit.event` | 跨系统审计事件 | IM Server、Admin 后台 | Admin/HotGo 审计消费者 |
| `im.workbench.event` | 已不采用 | 工作台小流量动作同步处理 | 无 |
| `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 |
## 3. 幂等与重试
@@ -61,16 +62,16 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、
1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。
2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。
3. 消息发送、收藏、群文件登记、日报/月报/反馈/举报提交成功后投递 `im.file.lifecycle`,建立业务引用和 ACL。
4. 业务撤回、删除、合规处置或临时文件 TTL 到期后投递 `im.file.lifecycle`,执行引用释放、禁用下载或 OSS 删除。
3. 消息附件引用绑定、群文件登记等小流量动作同步落库,不再投递 `im.file.lifecycle`。
4. 业务撤回、删除、合规处置或临时文件 TTL 回收如需异步化,另行评估专门任务队列。
## 6. 工作台链路结论
工作台“查询类接口”不需要 Kafka,但工作台“状态变化和提醒类事件”适合 Kafka:
工作台“查询类接口”不需要 Kafka,状态变化和提醒类事件当前也同步处理:
1. 会议创建、更新、取消、提醒、参会响应生产 `im.workbench.event`,消费者生成系统通知、红点并通过 WS 通知在线客户端。
2. 日报/月报提交、撤回、重开、管理员催办生产 `im.workbench.event`,用于红点、催办 Push 和审计。
3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。
1. 会议创建、更新、取消、提醒、参会响应同步生成系统通知、红点并通过 WS 通知在线客户端。
2. 日报/月报提交、撤回、重开、管理员催办同步生成红点、催办 Push 和审计。
3. 总经理信箱提交同步处理,后台处理结果再回推 C 端。
4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。
## 7. 已补充:缓存失效与 Admin 配置查询
@@ -85,6 +86,6 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、
1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。
2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。
3. 接入 `im.message.persist`,将消息异步落库和幂等重试闭环。
3. 接入 `im.message.persist` 和 `im.group.message.persist`,将私聊、群聊消息在 IM 内部异步落库和幂等重试闭环。
4. 接入 `im.file.security`,把 OSS 上传完成后的文件安全检测异步化。
5. 接入 `im.workbench.event`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。
5. 工作台事件保持 IM 内部同步处理;跨系统审计仍通过 `im.audit.event` 投递给 Admin/HotGo。
+64 -21
View File
@@ -314,6 +314,38 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` |
| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 |
##### 0.5.4.3.1 会话内系统提示和可点击动作消息
截图中的灰色提示、入群邀请说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。
客户端不得主动发送 `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_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` 渲染 |
`content` 字段约定:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `text` | string | 是 | 降级展示文案;客户端可直接展示,也可按 `template` 重新排版 |
| `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` |
| `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 |
| `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 |
| `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作 |
| `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 |
`actors[].click` 和 `actions[]` 的 `type` 首版白名单:
| 类型 | 参数 | 行为 |
| --- | --- | --- |
| `user_profile` | `target_id` | 打开用户资料页 |
| `group_profile` | `target_id` | 打开群资料页 |
| `open_group_invite_settings` | `target_id` | 打开群邀请确认设置 |
| `open_group_join_requests` | `target_id` | 打开群入群审批列表 |
| `open_message` | `topic,seq` | 定位到指定消息 |
##### 0.5.4.4 回执、正在输入、删除和清空记录
| 场景 | 客户端发送 | 服务端返回/下行示例 |
@@ -453,7 +485,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。
消息类型首版固定为:`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 幂等、重试和安全
@@ -727,10 +759,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`,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"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。
- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 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`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
- 成功返回 `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 落普通存储。
### 1.8 实名认证.png
@@ -769,8 +803,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。
- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。`action_token` 为一次性 token,会在设置密码时消费;设置成功后响应会返回新的 `enable_action_token`,用于下一步开启青少年模式,前端不要复用设置密码时提交的 token。
- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。
- 青少年模式完整开启和关闭流程见 `docs/IM-青少年模式前后端对接文档.md`。
- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。
- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。
@@ -1159,7 +1194,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。
- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。
- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。P2P 目标可以传转发目标列表返回的 `topic`,也可以传对方 `usr...` 用户 ID,服务端会归一化为当前用户与对方的 P2P topic。
### 4.18 转发-多选.png
@@ -1168,7 +1203,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。
- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。
- 确认前只保存目标 Topic 字符串或对方 `usr...` 用户 ID;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。
### 4.19 转发—逐条转发.png
@@ -1176,7 +1211,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}`。
- `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`。
- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。
@@ -1186,7 +1221,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 调用 4.19 接口,`mode:"bundle"`;服务端生成 `forward_bundle`,内容含 `bundle_id,title,item_count,preview[]`,完整内容通过 `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=` 分页详情。
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
@@ -1301,7 +1336,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。
- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。`{topic}` 必须是群聊 topic(`grp...`);单聊、名片或普通用户资料页使用 `GET /v1/im/users/{identifier}/profile`,不要把 `usr...` 用户 ID 放到 groups 路径。
- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。
- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。
@@ -1698,7 +1733,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `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"}` |
| `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/{user_id}/profile` | Path topic/user | `{"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}/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}}` |
| `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_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/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
@@ -1903,7 +1938,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `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":[]}` |
| `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":["p2p..."],"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"}],"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"}],"failed":[]}` |
| `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"}` |
@@ -2034,9 +2070,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- |
| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` |
| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` |
| `POST /v1/im/calls/rtc-join` | `{"topic":"usr_peer 或 p2p...","call_id":"call_1","seq":12,"call_type":"audio","role":"caller"}` | `{"provider":"agora","auth_mode":"token","app_id":"声网 App ID","channel_name":"call_call_1","uid":"usr...","token":"007eJx...","token_expires_at":"2026-09-02T10:00:00Z","call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","role":"caller"}` |
| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` |
| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
声网模式下,新客户端在发起方收到 `ctrl.params.seq` 后、被叫方点击接听后,分别调用 `POST /v1/im/calls/rtc-join` 获取 `app_id/channel_name/uid/token`。`topic` 可直接传当前会话里看到的 P2P 别名,例如发起方传被叫 `usr...`、被叫方传发起方 `usr...`;后端会归一到真实 `p2p...` 并在响应中返回。当前按正式 `token` 模式接入,后端使用 `im_shengwang` 分组中的 `agora_app_certificate` 生成 RTC Token。客户端不接触 `app_certificate`、`customer_id`、`customer_secret`,也不再通过 IM WebSocket 交换 SDP/ICE,音视频媒体协商和传输由声网 RTC SDK 完成。
## 9. 企业工作台与办公协同
### 9.1 工作台.png
@@ -2364,8 +2403,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。
- `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` 用于明确表达这一点。
- 点击开启先展示 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`。
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
### 10.15 青少年模式提示(安卓).png
@@ -2374,7 +2414,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:弹窗纯客户端;文案由服务端配置。**
- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。
- 使用 10.14 返回的 `restriction_items` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。`restrictions` 仅作为兼容旧客户端的 code 数组。
- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。
### 10.16 青少年模式已开启(安卓).png
@@ -2384,7 +2424,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。
- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
### 10.17 关闭青少年模式(安卓).png
@@ -2453,13 +2493,16 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","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,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"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"],"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"}` |
| `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"}` |
| `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/youth-mode/enable` | `{"policy_version":1,"action_token":"action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"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":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/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":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges/{id}/verify` | `{"code":"123456"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05: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":"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` 限流 | 同上 | HTTP `429`:`{"code":30020,"reason":"VERIFICATION_RATE_LIMITED","message":"verification rate limited","details":{"challenge_id":"sec_chg_1","retry_after_seconds":45}}`;小时限流时 `retry_after_seconds` 可能为数百/数千秒且可不返回 `challenge_id` |
| `POST /v1/im/security/challenges/{id}/verify` 开启青少年模式 | `{"code":"123456"}` | `{"action_token":"set_password_token","action":"set_independent_password","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}` |
| `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"}` |
@@ -0,0 +1,386 @@
# IM 青少年模式前后端对接文档
## 1. 基本约定
青少年模式相关接口均为登录后接口。客户端需要携带用户登录 token、普通 API Key 和协议版本头。
公共 Header:
```http
Authorization: Bearer <access_token>
X-MeetingIM-Auth: Bearer <access_token>
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Request-ID: <uuid>
X-Tenant-Code: default
```
所有写接口还需要携带:
```http
Idempotency-Key: <uuid>
```
测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。
当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;业务接口层的深夜消息免打扰、内容过滤、支付拦截尚未实际强制执行,响应中的 `restriction_items[].enforced=false` 用于明确表达这一点。后续某条规则真正接入后台策略和业务拦截后,应把对应项改为 `true`。
## 2. 开启青少年模式
### 2.1 查询当前状态
```http
GET /v1/im/settings/youth-mode
```
成功响应:
```json
{
"code": 0,
"data": {
"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"
}
}
```
前端优先使用 `restriction_items` 渲染开启确认页,`restrictions` 保留为兼容旧客户端的 code 数组。最终开启时需要提交用户确认过的 `policy_version`。
### 2.2 发起短信身份验证
```http
POST /v1/im/security/challenges
```
请求体:
```json
{
"scene": "set_independent_password",
"channel": "sms"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"challenge_id": "sec_chg_1",
"phone_mask": "+861****000",
"expires_in_seconds": 300,
"resend_after_seconds": 60
}
}
```
手机号由服务端从当前登录用户读取,客户端不提交目标手机号。
### 2.3 校验短信验证码
```http
POST /v1/im/security/challenges/{challenge_id}/verify
```
请求体:
```json
{
"code": "123456"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"action": "set_independent_password",
"action_token": "set_password_token",
"expires_at": "2026-08-20T09:05:00Z"
}
}
```
这里拿到的 `action_token` 只用于设置独立密码,不能直接用于开启青少年模式。
### 2.4 设置独立密码
```http
POST /v1/im/settings/independent-password
```
请求体:
```json
{
"password": "6439",
"purpose": "youth_mode",
"action_token": "set_password_token"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"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"
}
}
```
服务端会消费 `set_password_token`,并返回新的 `enable_action_token`。前端下一步必须使用 `enable_action_token`,不要复用 `set_password_token`。
独立密码规则:
- 必须是 4 位数字。
- 不能是明显连续或重复组合。
- 服务端只保存强哈希,不返回明文密码。
- 前端离开页面后应清空输入内容,不落普通存储。
### 2.5 开启青少年模式
```http
POST /v1/im/settings/youth-mode/enable
```
请求体:
```json
{
"policy_version": 1,
"action_token": "enable_token"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"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"
}
}
```
开启成功后,客户端应刷新本地设置页和 `bootstrap` 缓存。服务端会持久化状态,前端不能只依赖本地标志位。
## 3. 关闭青少年模式
### 3.1 校验独立密码
```http
POST /v1/im/settings/independent-password/verify
```
请求体:
```json
{
"password": "6439",
"purpose": "youth_mode",
"action": "close_youth_mode"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"action": "close_youth_mode",
"action_token": "close_token",
"expires_at": "2026-08-20T09:05:00Z"
}
}
```
### 3.2 关闭模式
```http
POST /v1/im/settings/youth-mode/disable
X-Action-Token: close_token
```
请求体:
```json
{}
```
成功响应:
```json
{
"code": 0,
"data": {
"enabled": false,
"policy_version": 1,
"password_set": true,
"updated_at": "2026-08-20T09:00:00Z"
}
}
```
关闭接口的 `action_token` 通过 Header `X-Action-Token` 传递,不放在 Body 中。
## 4. Token 使用规则
| 来源 | action | 用途 | 传递方式 | 是否单次消费 |
| --- | --- | --- | --- | --- |
| `security/challenges/{id}/verify` | `set_independent_password` | 设置独立密码 | Body `action_token` | 是 |
| `independent-password` | `enable_youth_mode` | 开启青少年模式 | Body `action_token` | 是 |
| `independent-password/verify` | `close_youth_mode` | 关闭青少年模式 | Header `X-Action-Token` | 是 |
常见错误:
- `ACTION_TOKEN_INVALID`:token 已消费、过期、action 不匹配、target 不匹配或用户不匹配。
- `CODE_INVALID`:短信验证码错误。
- `CHALLENGE_EXPIRED`:短信 challenge 已过期。
- `TOO_MANY_ATTEMPTS`:独立密码或验证码错误次数过多。
- `VERIFICATION_RATE_LIMITED`:发码过于频繁,按 `retry_after_seconds` 倒计时。
## 5. curl 示例
开启流程:
```bash
curl -sS 'https://api.118im.com/v1/im/security/challenges' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"scene":"set_independent_password","channel":"sms"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/security/challenges/<challenge_id>/verify' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"code":"123456"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/settings/independent-password' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"password":"6439","purpose":"youth_mode","action_token":"<set_password_token>"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/settings/youth-mode/enable' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"policy_version":1,"action_token":"<enable_action_token>"}'
```
关闭流程:
```bash
curl -sS 'https://api.118im.com/v1/im/settings/independent-password/verify' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
--data '{"password":"6439","purpose":"youth_mode","action":"close_youth_mode"}'
```
```bash
curl -sS 'https://api.118im.com/v1/im/settings/youth-mode/disable' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-MeetingIM-Auth: Bearer <access_token>' \
-H 'X-MeetingIM-APIKey: <api_key>' \
-H 'X-IM-Schema: 1' \
-H 'X-Tenant-Code: default' \
-H 'X-Request-ID: <uuid>' \
-H 'Idempotency-Key: <uuid>' \
-H 'X-Action-Token: <close_token>' \
--data '{}'
```
+4
View File
@@ -0,0 +1,4 @@
-- Remove the deprecated Kafka DB outbox table from a tenant database.
-- Run once per tenant business database after deploying the direct Kafka publish path.
DROP TABLE IF EXISTS im_kafka_outbox;
-23
View File
@@ -29,29 +29,6 @@ CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
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_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(96) NOT NULL,
topic VARCHAR(128) NOT NULL,
event_type VARCHAR(96) NOT NULL,
biz_type VARCHAR(64) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
key_value VARCHAR(256) NOT NULL DEFAULT '',
envelope JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
sent_at DATETIME(3),
last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) 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_kafka_outbox_event(event_id),
INDEX idx_im_kafka_outbox_pending(status, next_retry_at, id),
INDEX idx_im_kafka_outbox_biz(biz_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT,
dead_id VARCHAR(96) NOT NULL,
@@ -0,0 +1,317 @@
# IM 声网音视频通话完整调用流程
更新时间:2026-09-01
## 1. 流程目标
本文用于说明 IM 系统接入声网 RTC 后,一次音视频通话从后台配置、客户端发起、服务端校验、声网入会到挂断结束的完整调用链路。
首期调试环境只使用声网 `app_id`,客户端入会时 `token` 为空。正式环境启用 App 证书后,由 IM Server 使用 `app_certificate` 生成 RTC Token,再返回给客户端。
## 2. 整体链路
```text
后台配置声网参数
-> 客户端 A 发起通话
-> IM Server 校验业务权限并生成 call_id/channel/uid
-> IM WebSocket 通知客户端 B
-> A、B 分别向 IM Server 获取声网入会参数
-> A、B 使用声网 SDK 加入同一频道
-> 音视频媒体流走声网 RTC
-> 挂断/拒接/超时通过 IM 信令结束
-> IM Server 记录通话状态和通话记录
```
## 3. 后台配置流程
### 3.1 调试模式
调试模式只需要准备声网项目的 `app_id`。
后台配置建议:
| 配置项 | 示例 | 说明 |
| --- | --- | --- |
| `agora_enabled` | `true` | 是否启用声网音视频。 |
| `agora_auth_mode` | `token` | 正式模式,服务端生成 RTC Token。 |
| `agora_app_id` | `xxxxxxxx` | 声网项目 App ID,允许返回给客户端。 |
| `agora_channel_prefix` | `call_` | 服务端生成声网频道名前缀。 |
| `agora_uid_mode` | `string` | 建议使用 `usr...` 作为声网 user account,避免 BIGINT 用户 ID 超过声网数字 UID 范围。 |
### 3.2 正式模式
正式模式需要启用 App 证书,由后端生成 RTC Token。
后台配置建议:
| 配置项 | 说明 |
| --- | --- |
| `agora_auth_mode=token` | 开启 Token 鉴权模式。 |
| `agora_app_certificate` | 声网 App 证书,只能后端保存和使用。 |
| `agora_token_ttl_seconds` | Token 有效期,建议 3600 秒。 |
### 3.3 声网控制台 REST API 配置
声网 REST API 的 `customer_id/customer_secret` 只用于后端调用声网控制台 API,例如创建项目、查询项目、启用/禁用项目、启用/重置证书。它不是客户端入会参数,也不是每次通话必须调用的接口。
后台配置建议:
| 配置项 | 说明 |
| --- | --- |
| `agora_rest_customer_id` | 声网 REST API 客户 ID。 |
| `agora_rest_customer_secret` | 声网 REST API 客户密钥,必须脱敏展示。 |
| `agora_rest_primary_domain` | 国内建议 `https://api.sd-rtn.com`。 |
| `agora_rest_backup_domain` | 备用建议 `https://api.agora.io`。 |
## 4. 后端和声网 API 对接流程
### 4.1 调试阶段
调试阶段后端不需要请求声网服务端 API。IM Server 只负责读取后台配置,并把客户端入会需要的参数返回给当前登录用户。
返回给客户端的字段:
| 字段 | 说明 |
| --- | --- |
| `provider` | 固定 `agora`。 |
| `auth_mode` | 调试模式为 `app_id`。 |
| `app_id` | 声网 App ID。 |
| `channel_name` | 服务端生成的频道名。 |
| `uid` | 当前 IM 用户映射到声网的 UID。 |
| `token` | 调试模式为空字符串。 |
| `token_expires_at` | 调试模式为 `null`。 |
### 4.2 正式 Token 阶段
正式阶段后端仍然不需要每次请求声网 REST API。IM Server 使用本地保存的 `app_id`、`app_certificate`、`channel_name`、`uid` 和有效期生成 RTC Token。
生成完成后只把 Token 返回给当前通话参与人。`app_certificate` 不允许返回客户端,不允许写入普通日志。
### 4.3 控制台 REST API 阶段
只有后台需要自动管理声网项目时,才调用声网控制台 REST API。
常用接口:
| 场景 | 声网 API |
| --- | --- |
| 创建项目 | `POST /dev/v1/project` |
| 查询指定项目 | `GET /dev/v1/project?id=&name=` |
| 查询全部项目 | `GET /dev/v1/projects` |
| 启用/禁用项目 | `POST /dev/v1/project_status` |
| 启用/禁用主要 App 证书 | `POST /dev/v1/signkey` |
| 重置主要 App 证书 | `POST /dev/v1/reset_signkey` |
声网 REST API 使用 Basic Auth:
```text
Authorization: Basic base64(customer_id + ":" + customer_secret)
Content-Type: application/json
```
## 5. 前端和后端对接流程
### 5.1 发起方流程
```text
用户 A 点击语音/视频通话
-> 客户端 A 生成 call_id
-> 客户端 A 通过 IM WebSocket 发送 call started
-> IM Server 校验 A 和 B 的关系、黑名单、账号状态、租户状态
-> IM Server 写入或更新通话记录
-> IM Server 返回 Topic seq
-> 客户端 A 调用入会接口获取声网参数
-> 客户端 A 使用声网 SDK 加入频道
-> IM Server 通过 WebSocket 把来电消息推送给客户端 B
```
WebSocket 发起通话示例:
```json
{
"pub": {
"id": "call-pub-1",
"topic": "p2p...",
"head": {
"mime": "application/json",
"webrtc": "started",
"x-im-type": "call",
"x-im-client-mid": "mid_call_1"
},
"content": {
"callId": "call_01K...",
"callType": "video",
"audio": true,
"video": true,
"provider": "agora"
}
}
}
```
### 5.2 被叫方流程
```text
客户端 B 收到 IM WebSocket 来电消息
-> 展示来电页面
-> 用户 B 点击接听
-> 客户端 B 调用入会接口获取声网参数
-> IM Server 校验 B 是否为本次通话参与人
-> IM Server 返回同一个 channel_name 和 B 自己的 uid/token
-> 客户端 B 使用声网 SDK 加入频道
-> 客户端 B 通过 IM WebSocket 发送 call accept
-> 双方开始音视频通话
```
### 5.3 获取声网入会参数
建议接口:
```http
POST /v1/im/calls/rtc-join
Authorization: Bearer <IM token>
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Request-ID: <request_id>
Idempotency-Key: <idempotency_key>
Content-Type: application/json
```
请求 Body:
```json
{
"topic": "p2p...",
"call_id": "call_01K...",
"seq": 30,
"call_type": "video",
"role": "caller"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"provider": "agora",
"auth_mode": "app_id",
"app_id": "声网 App ID",
"channel_name": "call_call_01K...",
"uid": 1565659909440147456,
"token": "",
"token_expires_at": null,
"call_id": "call_01K...",
"topic": "p2p...",
"seq": 30
},
"request_id": "req_01K...",
"server_time": "2026-09-01T16:00:00+08:00"
}
```
正式 Token 模式下,`auth_mode` 返回 `token`,`token` 不为空,`token_expires_at` 返回具体过期时间。
### 5.4 客户端加入声网频道
客户端拿到后端返回参数后调用声网 RTC SDK:
```text
初始化 RTC SDK: app_id
加入频道: channel_name
用户标识: uid
入会凭证: token
```
调试模式下 `token` 为空。正式环境下 `token` 必须由后端生成并返回。
音视频媒体流不经过 IM Server,客户端加入声网频道后由声网 RTC 负责音视频传输、音频路由、摄像头采集、网络切换和断线重连等能力。
## 6. 通话结束流程
### 6.1 主动挂断
```text
任意一方点击挂断
-> 客户端离开声网频道
-> 客户端通过 IM WebSocket 发送 hang-up
-> IM Server 更新通话记录状态和通话时长
-> IM Server 推送通话结束消息给另一方
-> 另一方客户端关闭通话页面并离开声网频道
```
挂断信令示例:
```json
{
"note": {
"topic": "p2p...",
"what": "call",
"event": "hang-up",
"seq": 30,
"payload": {
"callId": "call_01K...",
"reason": "finished"
}
}
}
```
### 6.2 取消、拒接和超时
| 场景 | 处理 |
| --- | --- |
| 发起方取消 | IM Server 标记为 `cancelled` 或 `missed`,被叫端关闭来电页面。 |
| 被叫方拒接 | IM Server 标记为 `declined`,发起方关闭等待页面。 |
| 被叫方超时未接 | IM Server 标记为 `missed`,双方客户端退出通话状态。 |
| 网络异常断开 | 客户端根据声网 SDK 回调提示重连;业务最终状态以 IM Server 的结束事件为准。 |
## 7. Token 续期流程
正式环境启用 Token 后,客户端需要监听声网 SDK 的 Token 即将过期或已过期回调。
```text
声网 SDK 提示 Token 即将过期
-> 客户端调用 /v1/im/calls/rtc-join 或续期接口
-> IM Server 校验当前通话仍有效
-> IM Server 重新生成 RTC Token
-> 客户端调用声网 SDK renewToken
```
调试模式不涉及 Token 续期。
## 8. 安全边界
| 数据 | 是否返回客户端 | 说明 |
| --- | --- | --- |
| `app_id` | 是 | 客户端初始化声网 SDK 必须使用。 |
| `channel_name` | 是 | 服务端生成,客户端不能自定义任意频道。 |
| `uid` | 是 | 当前用户对应的声网 UID。 |
| `token` | 是 | 正式环境返回,仅当前通话参与人可获取。 |
| `app_certificate` | 否 | 后端生成 RTC Token 使用。 |
| `customer_id` | 否 | 后端调用声网控制台 REST API 使用。 |
| `customer_secret` | 否 | 后端调用声网控制台 REST API 使用。 |
## 9. 异常处理
| reason | 场景 |
| --- | --- |
| `AGORA_DISABLED` | 后台未启用声网通话。 |
| `AGORA_CONFIG_MISSING` | 缺少 `app_id`,或 Token 模式缺少 `app_certificate`。 |
| `CALL_NOT_FOUND` | `call_id/topic/seq` 不匹配或通话不存在。 |
| `CALL_NOT_ALLOWED` | 当前用户不是通话参与人,或被权限策略拦截。 |
| `CALL_STATE_INVALID` | 通话已结束、已超时或当前状态不允许入会。 |
| `VALIDATION_FAILED` | 请求参数非法。 |
## 10. 联调顺序建议
1. 后台配置 `agora_enabled=true`、`agora_auth_mode=token`、`agora_app_id`、`agora_app_certificate`。
2. 客户端集成声网 RTC SDK,并使用后端返回的 RTC Token 入会。
3. A 发起语音通话,确认 B 能收到 IM 来电信令。
4. A、B 分别调用 `/v1/im/calls/rtc-join`,可直接传各自会话中看到的对端 `usr...` topic,确认返回同一个 `channel_name` 和真实 `p2p...` topic。
5. A、B 使用各自 `uid` 加入声网频道,确认音频可通。
6. 切换视频通话,确认视频流可通。
7. 测试取消、拒接、挂断、超时、网络断开。
8. 验证 Token 入会和 Token 续期。
9. 后续再接入声网控制台 REST API 的项目管理能力。
@@ -0,0 +1,320 @@
# IM 声网音视频通话接入文档
更新时间:2026-09-01
## 1. 接入目标
本次接入目标是把 IM 的一对一语音/视频通话媒体能力从原 WebRTC 直连方案切到声网 RTC。IM Server 继续负责账号、租户、好友/黑名单、通话状态、WebSocket 信令和通话记录;声网只负责音视频媒体频道、音视频流传输和后续可选的媒体质量能力。
首期按声网调试账号接入,只使用 `app_id`,不生成 RTC Token。正式上线时再启用 App 证书,由 IM Server 按频道和用户生成 RTC Token。
## 2. 系统边界
| 模块 | 责任 |
| --- | --- |
| IM Server | 校验登录态、租户、好友/黑名单和通话权限;生成 `call_id`、声网频道名、声网 UID;维护通话状态和通话记录;通过 WebSocket 下发邀请、接听、挂断等信令。 |
| 前端客户端 | 集成声网 RTC SDK;从 IM Server 获取声网入会配置;使用 `app_id/channel_name/uid/token` 加入声网频道;通过 IM WebSocket 发送通话业务事件。 |
| HotGo/Admin | 保存声网第三方配置;后续可提供项目、证书、开关等管理入口。 |
| 声网 RTC | 提供音视频实时媒体通道。 |
| 声网控制台 REST API | 可选,用于服务端创建项目、查询项目、启停项目、启停或重置主要 App 证书。首期不强依赖。 |
## 3. 声网账号与配置准备
### 3.1 调试模式必须准备
| 配置 | 说明 |
| --- | --- |
| `agora_app_id` | 声网项目 App ID,客户端初始化 RTC SDK 使用。 |
| `agora_auth_mode` | 当前建议填 `token`,由服务端生成 RTC Token。 |
| `agora_enabled` | 是否启用声网通话,建议测试环境先设为 `true`。 |
| `agora_uid_mode` | 建议用 `string`,客户端加入频道时使用 `usr...` 作为声网 user account,避免 BIGINT 用户 ID 超过声网数字 UID 范围。 |
| `agora_channel_prefix` | 建议 `call_`,最终频道名为 `call_{call_id}`。 |
### 3.2 正式模式后续准备
| 配置 | 说明 |
| --- | --- |
| `agora_app_certificate` | 声网 App 证书,服务端生成 RTC Token 使用,禁止下发客户端。 |
| `agora_token_ttl_seconds` | RTC Token 有效期,建议 3600 秒。 |
| `agora_rest_customer_id` | 声网 RESTful API 客户 ID,用于控制台 API。 |
| `agora_rest_customer_secret` | 声网 RESTful API 客户密钥,用于控制台 API,必须脱敏保存和展示。 |
| `agora_rest_primary_domain` | 国内建议 `https://api.sd-rtn.com`。 |
| `agora_rest_backup_domain` | 备用建议 `https://api.agora.io`。 |
配置建议存放在 HotGo `hg_sys_config` 的 `im_shengwang` 分组,IM Server 通过 `store.ThirdPartyConfig.Get("im_shengwang", ...)` 读取,并受 Kafka `third_party.updated` 或 `cache.third_party.invalidate` 事件清理进程内缓存。
## 4. 后端与声网 API 对接
### 4.1 首期不调用声网 REST API
只使用 `app_id` 的调试模式下,IM Server 不需要向声网发起服务端请求。后端只需要读取后台配置,把 `app_id`、频道名、UID 和空 Token 返回给客户端。
首期后端返回:
```json
{
"provider": "agora",
"auth_mode": "app_id",
"app_id": "声网 App ID",
"channel_name": "call_call_01K...",
"uid": 1565659909440147456,
"token": "",
"token_expires_at": null
}
```
### 4.2 正式 Token 模式
正式模式启用 App 证书后,IM Server 使用 `agora_app_id + agora_app_certificate + channel_name + uid + ttl` 在本地生成 RTC Token。Token 只返回给当前登录用户,不能存日志,不能返回给非通话参与人。
正式后端返回:
```json
{
"provider": "agora",
"auth_mode": "token",
"app_id": "声网 App ID",
"channel_name": "call_call_01K...",
"uid": 1565659909440147456,
"token": "007eJx...",
"token_expires_at": "2026-09-01T17:00:00+08:00"
}
```
客户端收到声网 SDK 的 Token 即将过期回调后,重新调用 IM Server 获取新 Token,再调用声网 SDK 的 `renewToken` 或等价接口更新。
### 4.3 声网控制台 REST API
控制台 REST API 是后台运维能力,不是每通电话的实时链路。需要自动化管理声网项目时再接入。
声网 REST API 鉴权:
```text
Authorization: Basic base64(customer_id + ":" + customer_secret)
Content-Type: application/json
```
常用接口:
| 能力 | 方法与路径 | 说明 |
| --- | --- | --- |
| 创建项目 | `POST /dev/v1/project` | Body `name, enable_sign_key`;返回 `vendor_key`,即 App ID。 |
| 查询指定项目 | `GET /dev/v1/project?id=&name=` | 按项目 ID 和名称查询。 |
| 查询全部项目 | `GET /dev/v1/projects` | 获取当前声网账号下项目列表。 |
| 启用/禁用项目 | `POST /dev/v1/project_status` | Body `id, status`,`1` 启用,`0` 禁用。 |
| 启用/禁用主要 App 证书 | `POST /dev/v1/signkey` | Body `id, enable`。 |
| 重置主要 App 证书 | `POST /dev/v1/reset_signkey` | Body `id`;证书未启用时会启用主要证书。 |
调用约束:
- 成功 HTTP 状态码为 `200` 或 `201`。
- 失败响应通常包含 `statusCode/error/message`。
- 每个声网账号 REST API QPS 默认 10 次/秒。
- 请求失败时日志记录声网响应 Header `X-Request-ID`、`X-Resource-ID`,但不能记录客户密钥、App 证书或 RTC Token。
- 国内服务优先 `api.sd-rtn.com`,失败时按退避策略切换 `api.agora.io` 或区域域名。
## 5. 前端与后端对接
### 5.1 WebSocket 通话信令
现有 IM WebSocket 仍是通话业务信令主链路。客户端发起通话时发布一条 `x-im-type=call` 的 Topic 消息:
```json
{
"pub": {
"id": "call-pub-1",
"topic": "p2p...",
"head": {
"mime": "application/json",
"webrtc": "started",
"x-im-type": "call",
"x-im-client-mid": "mid_call_1"
},
"content": {
"callId": "call_01K...",
"callType": "audio",
"audio": true,
"video": false,
"provider": "agora"
}
}
}
```
服务端返回:
```json
{
"ctrl": {
"id": "call-pub-1",
"code": 202,
"text": "accepted",
"params": {
"seq": 30
}
}
}
```
后续 `ringing/accept/hang-up` 继续走 `{note what="call"}`。切到声网后,`offer/answer/ice-candidate` 不再需要由客户端发送给 IM Server;媒体协商由声网 SDK 自己完成。为了兼容旧客户端,服务端可以继续接受这些事件,但新客户端应忽略旧 WebRTC SDP/ICE 流程。
### 5.2 获取声网入会配置
建议新增产品接口:
```http
POST /v1/im/calls/rtc-join
Authorization: Bearer <IM token>
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Request-ID: <request_id>
Idempotency-Key: <idempotency_key>
Content-Type: application/json
```
请求 Body:
```json
{
"topic": "p2p...",
"call_id": "call_01K...",
"seq": 30,
"call_type": "audio",
"role": "caller"
}
```
字段说明:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `topic` | 是 | P2P Topic。可传客户端当前会话看到的 `usr...` 别名,也可传真实 `p2p...`;首期只支持一对一通话。 |
| `call_id` | 是 | 通话 ID,必须和 WebSocket call 消息一致。 |
| `seq` | 建议 | 通话消息 seq。发起方可在 `{pub}` 返回后传入;接听方从收到的 call 消息中取得。 |
| `call_type` | 是 | `audio` 或 `video`。 |
| `role` | 是 | `caller` 或 `callee`,用于审计和风控。 |
成功响应:
```json
{
"code": 0,
"data": {
"provider": "agora",
"auth_mode": "app_id",
"app_id": "声网 App ID",
"channel_name": "call_call_01K...",
"uid": 1565659909440147456,
"token": "",
"token_expires_at": null,
"call_id": "call_01K...",
"topic": "p2p...",
"seq": 30
},
"request_id": "req_01K...",
"server_time": "2026-09-01T16:00:00+08:00"
}
```
失败原因建议:
| reason | 场景 |
| --- | --- |
| `AGORA_DISABLED` | 后台未开启声网。 |
| `AGORA_CONFIG_MISSING` | 缺少 App ID,或正式模式缺少 App 证书。 |
| `CALL_NOT_FOUND` | `call_id/topic/seq` 不存在或不匹配。 |
| `CALL_NOT_ALLOWED` | 用户不是通话参与人,或被好友/黑名单/租户策略限制。 |
| `CALL_STATE_INVALID` | 通话已结束、已超时或状态不允许入会。 |
| `VALIDATION_FAILED` | 请求字段非法。 |
### 5.3 客户端发起通话流程
```text
用户点击语音/视频通话
-> 客户端生成 call_id
-> WS pub call started
-> 收到 ctrl 202 和 seq
-> 调 POST /v1/im/calls/rtc-join 获取声网入会参数
-> 使用声网 SDK joinChannel
-> 等待对方 ringing/accept
-> 通话中通过 IM note hang-up 结束业务状态
```
### 5.4 客户端接听通话流程
```text
收到 Topic call started data
-> 展示来电 UI
-> 用户点击接听
-> 调 POST /v1/im/calls/rtc-join 获取声网入会参数
-> 使用声网 SDK joinChannel
-> WS note call accept
-> 通话中通过 IM note hang-up 结束业务状态
```
### 5.5 客户端挂断/取消/拒接
客户端通过 IM WebSocket 发送:
```json
{
"note": {
"topic": "p2p...",
"what": "call",
"event": "hang-up",
"seq": 30,
"payload": {
"callId": "call_01K...",
"reason": "cancelled"
}
}
}
```
服务端按当前状态把原通话消息替换为 `finished/missed/declined/disconnected`,并更新 `im_call_records`。客户端以服务端下发的替换消息为准,不相信本地计算的最终时长。
## 6. 数据与安全要求
1. `agora_app_certificate`、`agora_rest_customer_secret`、RTC Token 都是敏感信息,不写普通日志、不下发给无关用户、不进入埋点。
2. 调试模式可以只用 App ID,但生产环境建议启用 App 证书和 RTC Token。
3. `channel_name` 必须由服务端规则生成,建议 `call_{call_id}`,客户端不能自定义任意声网频道。
4. `uid` 必须稳定,首期建议使用当前 IM 用户数据库 BIGINT ID;如果客户端 SDK 使用字符串 UID,后端和客户端必须同时切到 string 模式。
5. 入会接口必须校验当前用户属于该 P2P 通话双方之一。
6. 被拉黑、非好友、账号禁用、租户禁用、青少年模式限制等场景必须拒绝发起或接听。
## 7. 联调清单
| 编号 | 场景 | 预期 |
| --- | --- | --- |
| AGORA-01 | 后台配置 `agora_app_id`、`agora_app_certificate` 和 `agora_auth_mode=token` | `/v1/im/calls/rtc-join` 返回 App ID、频道名、UID 和 RTC Token。 |
| AGORA-02 | A 发起语音通话,B 接听 | 双方使用同一 `channel_name` 加入声网频道,IM 通话状态变为 `accepted`。 |
| AGORA-03 | A 发起视频通话,B 接听 | 双方可看到/听到媒体流,IM 通话记录为 `video`。 |
| AGORA-04 | B 拒接 | IM 替换原通话消息为 `declined`,双方退出声网频道。 |
| AGORA-05 | A 取消 | IM 替换原通话消息为 `missed` 或取消态,B 来电 UI 关闭。 |
| AGORA-06 | 通话中任一方挂断 | IM 以服务端计时生成 `finished` 和 `duration_ms`。 |
| AGORA-07 | 非参与人请求入会配置 | 返回 `CALL_NOT_ALLOWED`。 |
| AGORA-08 | 关闭 `agora_enabled` | 入会接口返回 `AGORA_DISABLED`。 |
| AGORA-09 | 正式 Token 模式缺少 App 证书 | 入会接口返回 `AGORA_CONFIG_MISSING`。 |
| AGORA-10 | Token 即将过期刷新 | 正式模式下客户端重新获取 Token 并成功 `renewToken`。 |
## 8. 后续增强
1. 接入 RTC Token 生成器,支持 App 证书正式模式。
2. 后台增加声网配置页和敏感字段脱敏。
3. 接入声网控制台 REST API,实现项目查询、证书启停和重置。
4. 记录声网频道、UID、入会时间、离会时间和错误码,便于排障。
5. 采集通话质量指标:建连耗时、首帧耗时、丢包、延迟、卡顿、断线重连。
6. 后续如需多人通话,单独设计会议房间和 SFU/RTC 房间状态,不复用 P2P `call_id` 状态机。
## 9. 参考文档
- 声网创建项目:https://doc.shengwang.cn/doc/console/general/console/operations/post-dev-v1-project
- 声网获取指定项目信息:https://doc.shengwang.cn/doc/console/general/console/operations/get-dev-v1-project
- 声网获取所有项目信息:https://doc.shengwang.cn/doc/console/general/console/operations/get-dev-v1-projects
- 声网启用或禁用项目:https://doc.shengwang.cn/doc/console/general/console/operations/post-dev-v1-project_status
- 声网启用或禁用主要 App 证书:https://doc.shengwang.cn/doc/console/general/console/operations/post-dev-v1-signkey
- 声网重置主要 App 证书:https://doc.shengwang.cn/doc/console/general/console/operations/post-dev-v1-reset_signkey
- 声网响应状态码:https://doc.shengwang.cn/doc/console/general/api/response-code
- 声网配额限制:https://doc.shengwang.cn/doc/console/general/api/quota
- 声网 REST 高可用:https://doc.shengwang.cn/doc/console/general/api/guarantee-rest
- 声网 RTC Token 鉴权:https://doc.shengwang.cn/doc/rtc/android/basic-features/token-authentication
@@ -33,6 +33,21 @@ INSERT INTO hg_sys_dict_type
SELECT 0, 'IM 文件审核状态', 'im_file_review_status', 240, 'IM 文件审核任务状态字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_status');
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM RTC 服务商', 'im_rtc_provider', 250, 'IM 音视频通话 RTC provider 字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_rtc_provider');
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM RTC 鉴权模式', 'im_rtc_auth_mode', 260, 'IM 音视频通话 RTC 鉴权模式字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_rtc_auth_mode');
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM RTC UID 模式', 'im_rtc_uid_mode', 270, 'IM 音视频通话 RTC UID 模式字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_rtc_uid_mode');
-- 2. 后台字典数据
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
@@ -84,6 +99,31 @@ INSERT INTO hg_sys_dict_data
SELECT '失败', 'failed', 'string', 'im_file_review_status', 'warning', 1, 50, '不可重试失败或超过重试次数', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'failed');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '声网 RTC', 'agora', 'string', 'im_rtc_provider', 'success', 1, 10, '声网实时音视频 RTC', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_provider' AND `value` = 'agora');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '仅 App ID', 'app_id', 'string', 'im_rtc_auth_mode', 'warning', 1, 10, '调试模式,客户端入会 Token 为空', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_auth_mode' AND `value` = 'app_id');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 'RTC Token', 'token', 'string', 'im_rtc_auth_mode', 'success', 0, 20, '正式模式,后端使用 App Certificate 生成 Token', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_auth_mode' AND `value` = 'token');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '数字 UID', 'int', 'string', 'im_rtc_uid_mode', 'success', 1, 10, '客户端使用数字 UID 加入 RTC 频道', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_uid_mode' AND `value` = 'int');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '字符串 UID', 'string', 'string', 'im_rtc_uid_mode', 'info', 0, 20, '客户端使用字符串 UID 加入 RTC 频道', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_uid_mode' AND `value` = 'string');
-- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
@@ -175,6 +215,66 @@ INSERT INTO hg_sys_config
SELECT 'im_third_party', '阿里云 AccessKey Secret', 'string', 'aliyun_access_key_secret', '', '', 91, '内容安全和恶意文件检测共用的敏感配置,后台和日志必须脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_access_key_secret');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网启用状态', 'bool', 'agora_enabled', '0', '0', 100, '是否启用声网 RTC 音视频通话', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', 'RTC 服务商', 'string', 'agora_provider', 'agora', 'agora', 101, '取值来自字典 im_rtc_provider,当前支持 agora', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_provider');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网鉴权模式', 'string', 'agora_auth_mode', 'token', 'token', 102, '取值来自字典 im_rtc_auth_mode;调试 app_id,正式 token', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_auth_mode');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 App ID', 'string', 'agora_app_id', '', '', 103, '声网项目 App ID,可返回客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_app_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 App Certificate', 'string', 'agora_app_certificate', '', '', 104, '敏感配置,仅后端生成 RTC Token 使用,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_app_certificate');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 Token TTL', 'int', 'agora_token_ttl_seconds', '3600', '3600', 105, 'RTC Token 有效秒数,正式 token 模式使用', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_token_ttl_seconds');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网频道名前缀', 'string', 'agora_channel_prefix', 'call_', 'call_', 106, '服务端生成频道名时使用,最终为 prefix + call_id', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_channel_prefix');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 UID 模式', 'string', 'agora_uid_mode', 'string', 'string', 107, '取值来自字典 im_rtc_uid_mode;BIGINT 用户 ID 建议 string', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_uid_mode');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 REST Customer ID', 'string', 'agora_rest_customer_id', '', '', 108, '声网控制台 REST API 客户 ID,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_customer_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 REST Customer Secret', 'string', 'agora_rest_customer_secret', '', '', 109, '声网控制台 REST API 客户密钥,必须脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_customer_secret');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 REST 主域名', 'string', 'agora_rest_primary_domain', 'https://api.sd-rtn.com', 'https://api.sd-rtn.com', 110, '国内控制台 REST API 主域名', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_primary_domain');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', '声网 REST 备用域名', 'string', 'agora_rest_backup_domain', 'https://api.agora.io', 'https://api.agora.io', 111, '控制台 REST API 备用域名', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_backup_domain');
USE tinode;
-- 4. 文件安全审核任务表
+1
View File
@@ -44,6 +44,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
+2
View File
@@ -28,6 +28,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 h1:4dCykConaK6HYW3wQowoUg+p0F7i58WiLhkrjtqvgBw=
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556/go.mod h1:4bXIK0ntDk9CqAXobmomWd7dedbfNv/aaIpmpqqzt+A=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
-4
View File
@@ -263,10 +263,6 @@ type TenantHTTPInfrastructureAdapter interface {
TenantKafkaDeadLetterGet(t.TenantID, string) (*t.KafkaDeadLetter, error)
TenantKafkaDeadLetterMark(t.TenantID, string, string, string, string, time.Time) error
TenantKafkaDeadLetterMarkReplay(t.TenantID, string, string, string, time.Time) (*t.KafkaDeadLetter, error)
TenantKafkaOutboxCreate(t.TenantID, *t.KafkaOutboxRecord) error
TenantKafkaOutboxListPending(t.TenantID, int, time.Time) ([]t.KafkaOutboxRecord, error)
TenantKafkaOutboxMarkSent(t.TenantID, string, time.Time) error
TenantKafkaOutboxMarkFailed(t.TenantID, string, string, string, time.Time, time.Time) error
}
// TenantRegistrationInviteAdapter stores invitation definitions in the
+40 -3
View File
@@ -278,6 +278,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMOrganizationVisibilityTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure organization visibility schema: " + err.Error())
}
if err = ensureIMAttendanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error())
}
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
}
@@ -514,6 +517,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMAttendanceTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close()
return nil, nil, err
@@ -1236,6 +1243,32 @@ func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error {
}
func createIMAttendanceTables(tx *sql.Tx) error {
for _, stmt := range imAttendanceTableStatements(false) {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return seedIMAttendanceDefaults(tx)
}
func ensureIMAttendanceTables(db *sqlx.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, stmt := range imAttendanceTableStatements(true) {
if _, err = tx.Exec(stmt); err != nil {
return err
}
}
if err = seedIMAttendanceDefaults(tx); err != nil {
return err
}
return tx.Commit()
}
func imAttendanceTableStatements(ifNotExists bool) []string {
statements := []string{
`CREATE TABLE im_attendance_rules(
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1336,11 +1369,15 @@ func createIMAttendanceTables(tx *sql.Tx) error {
INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC)
)`,
}
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return err
if ifNotExists {
for i, stmt := range statements {
statements[i] = strings.Replace(stmt, "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)
}
}
return statements
}
func seedIMAttendanceDefaults(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO im_attendance_rules(rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed)
VALUES('att_rule_default','默认考勤规则',1,0,0,1,1)
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
+2 -2
View File
@@ -22,8 +22,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 12 {
t.Fatalf("created %d group tables, want 12", len(exec.statements))
if len(exec.statements) != 13 {
t.Fatalf("created %d group tables, want 13", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
-14
View File
@@ -44,20 +44,6 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
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_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(96) NOT NULL,
topic VARCHAR(128) NOT NULL, event_type VARCHAR(96) NOT NULL,
biz_type VARCHAR(64) NOT NULL DEFAULT '', biz_id VARCHAR(128) NOT NULL DEFAULT '',
key_value VARCHAR(256) NOT NULL DEFAULT '', envelope JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), sent_at DATETIME(3), last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) 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_kafka_outbox_event(event_id),
INDEX idx_im_kafka_outbox_pending(status, next_retry_at, id),
INDEX idx_im_kafka_outbox_biz(biz_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT, dead_id VARCHAR(96) NOT NULL,
event_id VARCHAR(96) NOT NULL, parent_event_id VARCHAR(96) NOT NULL DEFAULT '',
@@ -12,15 +12,14 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 8 {
t.Fatalf("created %d infrastructure tables, want 8", len(exec.statements))
if len(exec.statements) != 7 {
t.Fatalf("created %d infrastructure tables, want 7", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{
"im_http_idempotency",
"im_business_events_outbox",
"im_kafka_consume_log",
"im_kafka_outbox",
"im_kafka_dead_letters",
"im_realname_appeals",
"im_conversation_settings",
@@ -33,4 +32,7 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
}
if strings.Contains(joined, "im_kafka_outbox") {
t.Fatalf("kafka outbox table must not be created: %s", joined)
}
}
@@ -0,0 +1,56 @@
//go:build mysql
package mysql
import (
"os"
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) {
if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) {
t.Fatal("all visibility should be allowed to use managed report scope")
}
for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} {
if canUseManagedReports(organizationVisibility{Scope: scope}) {
t.Fatalf("scope %q should not be allowed to use managed report scope", scope)
}
}
}
func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) {
where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops")
joined := strings.Join(where, " AND ")
if !strings.Contains(joined, "m.department_id=?") {
t.Fatalf("department filter missing from where: %s", joined)
}
if !strings.Contains(joined, "im_friendships") {
t.Fatalf("friend visibility filter missing from where: %s", joined)
}
if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend {
t.Fatalf("unexpected args: %#v", args)
}
}
func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) {
mode := groupAdminAccessMode()
if !mode.IsAdmin() {
t.Fatalf("admin mode should grant admin access: %s", mode.String())
}
if mode.IsOwner() {
t.Fatalf("admin mode must not grant owner access: %s", mode.String())
}
}
func TestMessageSearchDoesNotClearScannedHead(t *testing.T) {
source, err := os.ReadFile("tenant_adapter.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(source), "msg.Head = kvMapFromJSON(msg.Head)") {
t.Fatal("message search must not clear Head after sqlx StructScan has already decoded it")
}
}
+180 -31
View File
@@ -2337,7 +2337,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
return nil, "", err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
@@ -2398,7 +2397,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
return nil, "", err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
@@ -4141,7 +4139,7 @@ func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal
tm := appeal.TargetTime.UTC().Round(time.Millisecond)
appeal.TargetTime = &tm
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at)
_, err = a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, strings.TrimSpace(appeal.AppealID), strings.TrimSpace(appeal.RecordID), decodeUID(uid), strings.TrimSpace(appeal.AppealType),
strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1,
idempotencyKey, now)
@@ -4259,10 +4257,27 @@ func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.Atte
defer rows.Close()
shifts := make([]t.AttendanceShift, 0, 2)
for rows.Next() {
var shift t.AttendanceShift
if err = rows.StructScan(&shift); err != nil {
row := struct {
ID int64
ShiftID string `db:"shift_id"`
RuleID string `db:"rule_id"`
Name string `db:"name"`
WorkStart string `db:"work_start"`
WorkEnd string `db:"work_end"`
CheckinStart string `db:"checkin_start"`
CheckoutEnd string `db:"checkout_end"`
SortOrder int `db:"sort_order"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
shift := t.AttendanceShift{
ID: row.ID, ShiftID: row.ShiftID, RuleID: row.RuleID, Name: row.Name, WorkStart: row.WorkStart,
WorkEnd: row.WorkEnd, CheckinStart: row.CheckinStart, CheckoutEnd: row.CheckoutEnd, SortOrder: row.SortOrder,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}
shifts = append(shifts, shift)
}
if err = rows.Err(); err != nil {
@@ -4486,6 +4501,7 @@ func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) {
AttachmentFileIDs []byte `db:"attachment_file_ids"`
Status string
Version int
IdempotencyKey string `db:"idempotency_key"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
@@ -4575,12 +4591,24 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope != "managed" {
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if from != "" {
@@ -4604,10 +4632,6 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_daily_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -4751,14 +4775,14 @@ func (a *adapter) TenantDailyReportSubmit(id t.TenantID, uid t.Uid, reportID str
return a.TenantDailyReportGet(id, uid, reportID)
}
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, _ string) (*t.DailyReportStats, error) {
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, departmentID string) (*t.DailyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportStats(id, uid, date, "")
return tenantAdp.TenantDailyReportStats(id, uid, date, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
@@ -4768,11 +4792,22 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim
if cancel != nil {
defer cancel()
}
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports WHERE report_date=? AND status=?`, date.Format("2006-01-02"), t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil {
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_date=?", "r.status=?"}
submittedArgs := []any{date.Format("2006-01-02"), t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
@@ -4782,20 +4817,114 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim
return stats, nil
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, _ time.Time, userIDs []t.Uid, _ string) ([]t.Uid, error) {
func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) {
memberWhere := []string{"m.employment_state='active'"}
memberArgs := []any{}
if strings.TrimSpace(departmentID) != "" {
memberWhere = append(memberWhere, "m.department_id=?")
memberArgs = append(memberArgs, strings.TrimSpace(departmentID))
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
memberWhere = append(memberWhere, visibilitySQL)
memberArgs = append(memberArgs, visibilityArgs...)
}
return memberWhere, memberArgs
}
func canUseManagedReports(visibility organizationVisibility) bool {
return visibility.Scope == orgVisibilityAll
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportRemind(id, uid, time.Time{}, userIDs, "")
return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
return userIDs, nil
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if !canUseManagedReports(visibility) {
return nil, t.ErrPermissionDenied
}
targetIDs := make([]int64, 0, len(userIDs))
seen := map[int64]struct{}{}
for _, target := range userIDs {
if target.IsZero() {
return nil, t.ErrMalformed
}
decoded := decodeUID(target)
if decoded <= 0 {
return nil, t.ErrMalformed
}
if _, ok := seen[decoded]; ok {
continue
}
seen[decoded] = struct{}{}
targetIDs = append(targetIDs, decoded)
}
query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m
WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS (
SELECT 1 FROM im_daily_reports AS r
WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=?
)`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted)
if err != nil {
return nil, err
}
query = a.db.Rebind(query)
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
reminded := make([]t.Uid, 0, len(targetIDs))
for rows.Next() {
var raw int64
if err = rows.Scan(&raw); err != nil {
return nil, err
}
target := store.EncodeUid(raw)
reminded = append(reminded, target)
}
if err = rows.Err(); err != nil {
return nil, err
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for _, target := range reminded {
eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey))
if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{
"date": date.Format("2006-01-02"),
}); err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
committed = true
return reminded, nil
}
func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) {
@@ -4851,12 +4980,24 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope != "managed" {
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if fromMonth != "" {
@@ -4880,10 +5021,6 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_monthly_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -5058,14 +5195,14 @@ func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID s
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ string) (*t.MonthlyReportStats, error) {
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, departmentID string) (*t.MonthlyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportStats(id, uid, month, "")
return tenantAdp.TenantMonthlyReportStats(id, uid, month, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
@@ -5075,11 +5212,22 @@ func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ st
if cancel != nil {
defer cancel()
}
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports WHERE report_month=? AND status=?`, month, t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil {
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_month=?", "r.status=?"}
submittedArgs := []any{month, t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
@@ -8617,8 +8765,9 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"fs.user_id=?", "fs.status=?"}
args := []any{decodeUID(uid), t.RelationshipFriend}
uidID := decodeUID(uid)
where := []string{"fs.user_id=?", "fs.status=?", "fs.peer_id<>?"}
args := []any{uidID, t.RelationshipFriend, uidID}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
+9 -5
View File
@@ -1170,8 +1170,8 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), now, topic, decodeUID(target))
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target))
if err != nil {
return err
}
@@ -1194,6 +1194,10 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return tx.Commit()
}
func groupAdminAccessMode() t.AccessMode {
return t.ModeCPublic | t.ModeApprove
}
func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid,
version int, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
@@ -1214,10 +1218,10 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
uid t.Uid
mode t.AccessMode
role string
}{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
}{{previousOwner, groupAdminAccessMode(), t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), now, topic, decodeUID(update.uid))
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid))
if execErr != nil {
return execErr
}
-134
View File
@@ -512,140 +512,6 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
return a.TenantKafkaDeadLetterGet(id, deadID)
}
func (a *adapter) TenantKafkaOutboxCreate(id t.TenantID, record *t.KafkaOutboxRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.EventID == "" || record.Topic == "" || record.EventType == "" || len(record.Envelope) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxCreate(id, record)
}
now := t.TimeNow()
_, err = a.db.Exec(`INSERT INTO im_kafka_outbox
(event_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,next_retry_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
record.EventID, record.Topic, record.EventType, record.BizType, record.BizID, record.Key,
record.Envelope, t.KafkaOutboxPending, record.NextRetryAt, now, now)
return err
}
func (a *adapter) TenantKafkaOutboxListPending(id t.TenantID, limit int, now time.Time) ([]t.KafkaOutboxRecord, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 100
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxListPending(id, limit, now)
}
rows := []struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
Topic string `db:"topic"`
EventType string `db:"event_type"`
BizType string `db:"biz_type"`
BizID string `db:"biz_id"`
Key string `db:"key_value"`
Envelope []byte `db:"envelope"`
Status string `db:"status"`
Attempts int `db:"attempts"`
NextRetryAt sql.NullTime `db:"next_retry_at"`
SentAt sql.NullTime `db:"sent_at"`
LastReason string `db:"last_reason"`
LastError string `db:"last_error"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err = a.db.Select(&rows, `SELECT id,event_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,attempts,
next_retry_at,sent_at,last_reason,last_error,created_at,updated_at
FROM im_kafka_outbox
WHERE status IN (?,?) AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id ASC LIMIT ?`, t.KafkaOutboxPending, t.KafkaOutboxFailed, now, limit)
if err != nil {
return nil, err
}
out := make([]t.KafkaOutboxRecord, 0, len(rows))
for _, row := range rows {
rec := t.KafkaOutboxRecord{
ID: row.ID,
EventID: row.EventID,
Topic: row.Topic,
EventType: row.EventType,
BizType: row.BizType,
BizID: row.BizID,
Key: row.Key,
Envelope: row.Envelope,
Status: row.Status,
Attempts: row.Attempts,
LastReason: row.LastReason,
LastError: row.LastError,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.NextRetryAt.Valid {
rec.NextRetryAt = &row.NextRetryAt.Time
}
if row.SentAt.Valid {
rec.SentAt = &row.SentAt.Time
}
out = append(out, rec)
}
return out, nil
}
func (a *adapter) TenantKafkaOutboxMarkSent(id t.TenantID, eventID string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxMarkSent(id, eventID, now)
}
res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,sent_at=?,attempts=attempts+1,last_reason='',
last_error='',updated_at=? WHERE event_id=? AND status<>?`,
t.KafkaOutboxSent, now, now, eventID, t.KafkaOutboxSent)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantKafkaOutboxMarkFailed(id t.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxMarkFailed(id, eventID, reason, message, nextRetryAt, now)
}
res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,attempts=attempts+1,next_retry_at=?,
last_reason=?,last_error=?,updated_at=? WHERE event_id=? AND status<>?`,
t.KafkaOutboxFailed, nextRetryAt, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000),
now, eventID, t.KafkaOutboxSent)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
var row struct {
ID int64 `db:"id"`
+1 -1
View File
@@ -195,7 +195,7 @@ func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error
}
}
var rows []identityValue
query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " ORDER BY " + orderBy
query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " WHERE u.username IS NULL OR u.username='' ORDER BY " + orderBy
if err = conn.SelectContext(ctx, &rows, query); err != nil {
return err
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"time"
rtctokenbuilder "github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src/rtctokenbuilder2"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
imReasonAgoraDisabled = "AGORA_DISABLED"
imReasonAgoraConfigMissing = "AGORA_CONFIG_MISSING"
imReasonCallNotFound = "CALL_NOT_FOUND"
imReasonCallNotAllowed = "CALL_NOT_ALLOWED"
imReasonCallStateInvalid = "CALL_STATE_INVALID"
)
type imCallRTCJoinRequest struct {
Topic string `json:"topic"`
CallID string `json:"call_id"`
Seq int64 `json:"seq"`
CallType string `json:"call_type"`
Role string `json:"role"`
}
type imAgoraConfig struct {
Enabled bool
AuthMode string
AppID string
AppCertificate string
TokenTTLSeconds int
ChannelPrefix string
UIDMode string
}
func serveIMCallRTCJoin(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imCallRTCJoinRequest
if err := json.NewDecoder(http.MaxBytesReader(wrt, req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
body.Topic = strings.TrimSpace(body.Topic)
body.CallID = strings.TrimSpace(body.CallID)
body.CallType = strings.ToLower(strings.TrimSpace(body.CallType))
body.Role = strings.ToLower(strings.TrimSpace(body.Role))
if body.Topic == "" || body.CallID == "" || body.Seq <= 0 ||
(body.CallType != constCallTypeAudio && body.CallType != constCallTypeVideo) ||
(body.Role != "caller" && body.Role != "callee") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
normalizedTopic, _ := normalizeP2PConversationTopic(body.Topic, principal.Uid)
if normalizedTopic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
body.Topic = normalizedTopic
config, err := loadIMAgoraConfig()
if err != nil {
if errors.Is(err, types.ErrNotFound) || errors.Is(err, types.ErrMalformed) {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !config.Enabled {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30061, imReasonAgoraDisabled, "agora disabled", nil)
return
}
sub, err := store.Subs.Get(principal.TenantID, body.Topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil)
return
}
record, err := findCallRecordForRTCJoin(principal.TenantID, principal.Uid, body)
if err != nil {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30063, imReasonCallNotFound, "call not found", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
return
}
if !callStateAllowsRTCJoin(record.Status) {
writeIMError(wrt, req, http.StatusConflict, 30064, imReasonCallStateInvalid, "call state invalid", map[string]any{"status": record.Status})
return
}
channelName := config.ChannelPrefix + record.CallID
uid, err := agoraUIDForUser(config, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil)
return
}
token := ""
var expiresAt any
if config.AuthMode == "token" {
token, expiresAt, err = buildAgoraRTCToken(config, channelName, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora rtc token generation failed", nil)
return
}
}
writeIMSuccess(wrt, req, map[string]any{
"provider": "agora",
"auth_mode": config.AuthMode,
"app_id": config.AppID,
"channel_name": channelName,
"uid": uid,
"token": token,
"token_expires_at": expiresAt,
"call_id": record.CallID,
"topic": record.Topic,
"seq": record.SeqID,
"call_type": record.CallType,
"role": body.Role,
})
}
func loadIMAgoraConfig() (imAgoraConfig, error) {
values, err := store.ThirdPartyConfig.Get("im_shengwang",
"agora_enabled",
"agora_auth_mode",
"agora_app_id",
"agora_app_certificate",
"agora_token_ttl_seconds",
"agora_channel_prefix",
"agora_uid_mode",
)
if err != nil {
return imAgoraConfig{}, err
}
config := imAgoraConfig{
Enabled: imTruthy(values["agora_enabled"]),
AuthMode: strings.ToLower(strings.TrimSpace(values["agora_auth_mode"])),
AppID: strings.TrimSpace(values["agora_app_id"]),
AppCertificate: strings.TrimSpace(values["agora_app_certificate"]),
TokenTTLSeconds: 3600,
ChannelPrefix: strings.TrimSpace(values["agora_channel_prefix"]),
UIDMode: strings.ToLower(strings.TrimSpace(values["agora_uid_mode"])),
}
if config.AuthMode == "" {
config.AuthMode = "app_id"
}
if config.ChannelPrefix == "" {
config.ChannelPrefix = "call_"
}
if config.UIDMode == "" {
config.UIDMode = "string"
}
if ttl := strings.TrimSpace(values["agora_token_ttl_seconds"]); ttl != "" {
parsed, err := strconv.Atoi(ttl)
if err != nil || parsed <= 0 {
return imAgoraConfig{}, types.ErrMalformed
}
config.TokenTTLSeconds = parsed
}
if !config.Enabled {
return config, nil
}
if config.AppID == "" || (config.AuthMode != "app_id" && config.AuthMode != "token") {
return imAgoraConfig{}, types.ErrMalformed
}
if config.AuthMode == "token" && config.AppCertificate == "" {
return imAgoraConfig{}, types.ErrMalformed
}
return config, nil
}
func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) {
if store.Calls == nil {
return nil, types.ErrUnsupported
}
records, _, err := store.Calls.List(tenantID, uid, body.Topic, "", 100)
if err != nil {
return nil, err
}
for i := range records {
record := &records[i]
if record.CallID != body.CallID || record.Topic != body.Topic || record.SeqID != body.Seq || record.CallType != body.CallType {
continue
}
if record.CallerID != uid && record.CalleeID != uid {
return nil, types.ErrPermissionDenied
}
if body.Role == "caller" && record.CallerID != uid {
return nil, types.ErrPermissionDenied
}
if body.Role == "callee" && record.CalleeID != uid {
return nil, types.ErrPermissionDenied
}
return record, nil
}
return nil, types.ErrNotFound
}
func callStateAllowsRTCJoin(status string) bool {
switch status {
case types.CallStatusStarted, types.CallStatusAccepted:
return true
default:
return false
}
}
func agoraUIDForUser(config imAgoraConfig, uid types.Uid) (any, error) {
switch config.UIDMode {
case "int":
decoded := store.DecodeUid(uid)
if decoded <= 0 || decoded > math.MaxUint32 {
return nil, types.ErrMalformed
}
return decoded, nil
case "string":
return uid.UserId(), nil
default:
return nil, types.ErrMalformed
}
}
func buildAgoraRTCToken(config imAgoraConfig, channelName string, uid types.Uid) (string, string, error) {
ttl := uint32(config.TokenTTLSeconds)
if config.UIDMode == "int" {
decoded := store.DecodeUid(uid)
if decoded <= 0 || decoded > math.MaxUint32 {
return "", "", types.ErrMalformed
}
token, err := rtctokenbuilder.BuildTokenWithUid(config.AppID, config.AppCertificate, channelName, uint32(decoded), rtctokenbuilder.RolePublisher, ttl, ttl)
if err != nil {
return "", "", err
}
return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil
}
account := uid.UserId()
if account == "" {
return "", "", types.ErrMalformed
}
token, err := rtctokenbuilder.BuildTokenWithUserAccount(config.AppID, config.AppCertificate, channelName, account, rtctokenbuilder.RolePublisher, ttl, ttl)
if err != nil {
return "", "", err
}
return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil
}
func imTruthy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func agoraTokenExpiresAt(ttlSeconds int) string {
return time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339Nano)
}
+559 -82
View File
@@ -57,19 +57,20 @@ const (
)
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
imQRCodeDefaultExpiresInSeconds = 86400
imQRCodeMaxExpiresInSeconds = 7 * 86400
imPublicUploadMaxBytes = 10 << 20
groupMaxAdminCount = 10
reportPreviewMaxRunes = 120
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imSensitiveChallengeRateLimitEnabled = false
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
imQRCodeDefaultExpiresInSeconds = 86400
imQRCodeMaxExpiresInSeconds = 7 * 86400
imPublicUploadMaxBytes = 10 << 20
groupMaxAdminCount = 10
reportPreviewMaxRunes = 120
)
type imHTTPResponse struct {
@@ -424,13 +425,37 @@ type imFavoritesForwardRequest struct {
type imMessagesForwardRequest struct {
Mode string `json:"mode"`
Sources []struct {
Topic string `json:"topic"`
Seq int64 `json:"seq,string"`
Topic string `json:"topic"`
Seq imForwardSeq `json:"seq"`
} `json:"sources"`
TargetTopics []string `json:"target_topics"`
Comment string `json:"comment,omitempty"`
}
type imForwardSeq int64
func (s *imForwardSeq) UnmarshalJSON(data []byte) error {
var number int64
if err := json.Unmarshal(data, &number); err == nil {
*s = imForwardSeq(number)
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
parsed, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return err
}
*s = imForwardSeq(parsed)
return nil
}
type imFriendRequestCreateRequest struct {
TargetUserID string `json:"target_user_id"`
Message string `json:"message,omitempty"`
@@ -551,6 +576,18 @@ type imGroupMuteBatchRequest struct {
Reason string `json:"reason,omitempty"`
}
type imSystemMessageActor struct {
UserID string `json:"user_id,omitempty"`
Name string `json:"name,omitempty"`
Click map[string]any `json:"click,omitempty"`
}
type imSystemMessageAction struct {
Type string `json:"type"`
Label string `json:"label,omitempty"`
TargetID string `json:"target_id,omitempty"`
}
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -1880,6 +1917,7 @@ func serveIMAttendanceConfig(wrt http.ResponseWriter, req *http.Request) {
}
config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date)
if err != nil {
logs.Warn.Println("im attendance config failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
@@ -1911,6 +1949,7 @@ func serveIMAttendanceRecords(wrt http.ResponseWriter, req *http.Request) {
Limit: limit,
})
if err != nil {
logs.Warn.Println("im attendance records failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
@@ -1971,6 +2010,7 @@ func serveIMAttendanceCheckins(wrt http.ResponseWriter, req *http.Request) {
IdempotencyKey: idempotencyKey,
})
if err != nil {
logs.Warn.Println("im attendance checkin failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
@@ -1997,6 +2037,7 @@ func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) {
Limit: limit,
})
if err != nil {
logs.Warn.Println("im attendance appeals failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
@@ -2039,6 +2080,7 @@ func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) {
AttachmentFileIDs: body.AttachmentFileIDs,
}, idempotencyKey)
if err != nil {
logs.Warn.Println("im attendance appeal create failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
@@ -2364,8 +2406,8 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
"permissions": map[string]any{
"can_create": true,
"makeup_allowed_days": 7,
"can_view_managed": true,
"can_remind_unsubmitted": true,
"can_view_managed": false,
"can_remind_unsubmitted": false,
},
})
case http.MethodPost:
@@ -3003,7 +3045,7 @@ func serveIMYouthModeEnable(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "enable_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
@@ -3511,8 +3553,8 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
if mode == "" {
mode = "separate"
}
if mode != "separate" {
writeIMError(wrt, req, http.StatusNotImplemented, 30004, "NOT_IMPLEMENTED", "forward bundle is not implemented", map[string]any{"field": "mode"})
if mode != "separate" && mode != "bundle" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "mode"})
return
}
if len(body.Sources) == 0 || len(body.Sources) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
@@ -3524,10 +3566,16 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
items := make([]map[string]any, 0, len(body.TargetTopics)*len(body.Sources))
failed := make([]map[string]any, 0)
for _, rawTarget := range body.TargetTopics {
target := strings.TrimSpace(rawTarget)
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
if target == "" {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, source.Seq, target, imReasonMalformedRequest))
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), strings.TrimSpace(rawTarget), imReasonMalformedRequest))
}
continue
}
if !targetOK {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), target, imReasonMalformedRequest))
}
continue
}
@@ -3538,14 +3586,50 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
}
if targetErr != nil {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, source.Seq, target, relationErrorReason(targetErr)))
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), target, relationErrorReason(targetErr)))
}
continue
}
if mode == "bundle" {
bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, body.Comment)
if len(sourceFailures) > 0 {
failed = append(failed, sourceFailures...)
continue
}
clientMid := "fwd_" + store.Store.GetUidString()
forwarded := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: forwardBundleMessageHead(clientMid),
Content: bundle,
}
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
continue
}
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
if err != nil {
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
continue
}
forwarded.SeqId = saved.SeqId
broadcastHTTPForwardMessage(principal.TenantID, forwarded)
items = append(items, map[string]any{
"sources": bundle["source_refs"],
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
"status": "sent",
"mode": "bundle",
"bundle_id": bundle["bundle_id"],
})
continue
}
for _, source := range body.Sources {
sourceTopic := strings.TrimSpace(source.Topic)
if sourceTopic == "" || source.Seq <= 0 {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, imReasonMalformedRequest))
sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
continue
}
readErr, ok := sourceReadable[sourceTopic]
@@ -3554,20 +3638,20 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
sourceReadable[sourceTopic] = readErr
}
if readErr != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(readErr)))
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr)))
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, source.Seq)
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq))
if err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err)))
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
if msg == nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, "NOT_FOUND"))
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND"))
continue
}
if messageAvailability(*msg) != "available" {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, "MESSAGE_UNAVAILABLE"))
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE"))
continue
}
clientMid := "fwd_" + store.Store.GetUidString()
@@ -3578,13 +3662,19 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
Head: forwardedMessageHead(msg, clientMid),
Content: forwardedMessageContent(msg, body.Comment),
}
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
if err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err)))
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
if err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
forwarded.SeqId = saved.SeqId
broadcastHTTPForwardMessage(principal.TenantID, forwarded)
items = append(items, map[string]any{
"source": map[string]any{"topic": sourceTopic, "seq": source.Seq},
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
@@ -3865,6 +3955,7 @@ func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRecallRequest
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
if body.Version <= 0 {
@@ -3916,6 +4007,7 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRevisionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
@@ -3996,7 +4088,11 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
topic, peer := normalizeP2PConversationTopic(topic, principal.Uid)
topic, peer, err := resolveConversationContextTopic(principal, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
@@ -4164,6 +4260,9 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
if item.Friendship.PeerID == principal.Uid {
continue
}
payload = append(payload, friendListItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
@@ -4581,12 +4680,18 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return
}
patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version}
settingsUpdated := true
if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil {
if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
if errors.Is(err, types.ErrNotFound) {
settingsUpdated = false
logs.Warn.Printf("group[%s]: business settings missing while patching group profile; kept topic public update", topic)
} else {
if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
updated, err := store.Topics.Get(principal.TenantID, topic)
@@ -4595,8 +4700,10 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return
}
payload := groupDetailPayload(updated, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
if settingsUpdated {
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
}
}
writeIMSuccess(wrt, req, payload)
default:
@@ -4665,7 +4772,7 @@ func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topi
}
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPut {
if req.Method != http.MethodPut && req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
@@ -4673,6 +4780,19 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
if !ok {
return
}
private := groupPrivateMap(selfSub.Private)
currentVersion := intFromAny(private["profile_version"])
if currentVersion <= 0 {
currentVersion = 1
}
if req.Method == http.MethodGet {
writeIMSuccess(wrt, req, map[string]any{
"nickname": stringFromAny(private["nickname"]),
"version": currentVersion,
"updated_at": stringFromAny(private["profile_updated_at"]),
})
return
}
var body imGroupMemberProfileUpdateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
@@ -4682,11 +4802,6 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
return
}
private := groupPrivateMap(selfSub.Private)
currentVersion := intFromAny(private["profile_version"])
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
@@ -4700,6 +4815,12 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
return
}
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil {
if errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
return
}
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back member profile: %v", topic, rollbackErr)
}
@@ -4929,6 +5050,16 @@ func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request)
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, inv.Topic,
"system_action", "group_member_joined", "有成员通过邀请加入了群聊。",
[]imSystemMessageActor{
{UserID: inv.InviterUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.InviterUserID.UserId()}},
{UserID: inv.TargetUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.TargetUserID.UserId()}},
},
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: inv.Topic}},
map[string]any{"invitation_id": inv.InvitationID, "source": "invitation"})
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupInvitationPayload(inv))
}
@@ -5043,6 +5174,16 @@ func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, t
writeIMRelationError(wrt, req, err)
return
}
if updated.Status == types.GroupJoinRequestStatusAccepted && imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_action", "group_member_joined", "有成员通过审批加入了群聊。",
[]imSystemMessageActor{
{UserID: updated.ApplicantUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantUserID.UserId()}},
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
},
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}},
map[string]any{"request_id": updated.RequestID, "source": "approval"})
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
}
@@ -5171,6 +5312,21 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st
writeIMRelationError(wrt, req, err)
return
}
if body.JoinApproval != nil && imReliableHTTPEventsEnabled(req) {
template := "group_invite_confirm_disabled"
text := "群聊邀请确认已关闭,成员邀请好友可直接进群。"
if *body.JoinApproval {
template = "group_invite_confirm_enabled"
text = "群聊邀请确认已启用,群成员邀请好友进群需要群主或管理员确认。"
}
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_action", template, text,
[]imSystemMessageActor{
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
},
[]imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic}},
map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version})
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
default:
@@ -5239,7 +5395,7 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail
}
item["user_id"] = uid.UserId()
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
types.GroupMemberRoleAdmin, types.ModeCPublic|types.ModeApprove, time.Now().UTC().Round(time.Millisecond)); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
@@ -5383,8 +5539,16 @@ func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp || memberID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
"field": "topic",
"expected": "group topic, e.g. grp...",
"actual": topic,
})
return
}
if memberID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
@@ -5789,6 +5953,14 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, updated.ApplicantID.P2PName(updated.TargetID),
"system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。",
[]imSystemMessageActor{
{UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}},
{UserID: updated.TargetID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.TargetID.UserId()}},
}, nil, map[string]any{"request_id": updated.RequestID})
}
dispatchIMBusinessEvents(principal.TenantID)
if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status
@@ -6015,7 +6187,19 @@ func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose})
enableToken, enableExpiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, "enable_youth_mode", purpose)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"status": "ok",
"password_set": true,
"purpose": purpose,
"enable_action_token": enableToken,
"enable_action_expires_at": enableExpiresAt.Format(time.RFC3339Nano),
"next_action": "enable_youth_mode",
})
}
func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
@@ -6180,29 +6364,31 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
if imSensitiveChallengeRateLimitEnabled {
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
})
return
}
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
@@ -6270,7 +6456,7 @@ func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target s
func defaultIMActionTarget(action, fallback string) string {
switch action {
case "set_independent_password", "close_youth_mode", "reset_independent_password":
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
return "youth_mode"
default:
return fallback
@@ -8086,12 +8272,13 @@ func appReleasePayload(release *types.AppRelease) map[string]any {
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
data := map[string]any{
"enabled": settings.Enabled,
"policy_version": settings.PolicyVersion,
"restrictions": settings.Restrictions,
"password_set": settings.PasswordSet,
"can_enable": settings.CanEnable,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"enabled": settings.Enabled,
"policy_version": settings.PolicyVersion,
"restrictions": settings.Restrictions,
"restriction_items": youthModeRestrictionItems(settings.Restrictions),
"password_set": settings.PasswordSet,
"can_enable": settings.CanEnable,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if settings.EnabledAt != nil {
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
@@ -8099,6 +8286,47 @@ func youthModePayload(settings *types.YouthModeSettings) map[string]any {
return data
}
func youthModeRestrictionItems(restrictions []string) []map[string]any {
catalog := map[string]map[string]any{
"night_quiet": {
"code": "night_quiet",
"title": "深夜免打扰",
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
"enforced": false,
},
"content_filter": {
"code": "content_filter",
"title": "内容过滤",
"description": "对不适合青少年的内容进行识别和限制展示。",
"enforced": false,
},
"payment_guard": {
"code": "payment_guard",
"title": "支付守护",
"description": "限制或确认支付、充值等敏感消费行为。",
"enforced": false,
},
}
items := make([]map[string]any, 0, len(restrictions))
for _, code := range restrictions {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if item, ok := catalog[code]; ok {
items = append(items, item)
continue
}
items = append(items, map[string]any{
"code": code,
"title": code,
"description": "",
"enforced": false,
})
}
return items
}
func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
return map[string]any{
"device_id": token.DeviceID,
@@ -8655,6 +8883,152 @@ func forwardFailurePayload(topic string, seq int64, target, reason string) map[s
}
}
func normalizeIMForwardTopic(viewer types.Uid, topic string) (string, bool) {
topic = strings.TrimSpace(topic)
if topic == "" {
return "", false
}
if len(topic) < 3 {
return "", false
}
switch types.GetTopicCat(topic) {
case types.TopicCatMe:
peer := types.ParseUserId(topic)
if peer.IsZero() {
return "", false
}
p2p := viewer.P2PName(peer)
return p2p, p2p != ""
case types.TopicCatP2P, types.TopicCatGrp, types.TopicCatSlf:
return topic, true
default:
return "", false
}
}
func buildForwardBundle(principal *imPrincipal, sources []struct {
Topic string `json:"topic"`
Seq imForwardSeq `json:"seq"`
}, target string, sourceReadable map[string]error, comment string) (map[string]any, []map[string]any) {
items := make([]map[string]any, 0, len(sources))
preview := make([]map[string]any, 0, min(len(sources), 3))
sourceRefs := make([]map[string]any, 0, len(sources))
failures := make([]map[string]any, 0)
for _, source := range sources {
sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
continue
}
readErr, ok := sourceReadable[sourceTopic]
if !ok {
readErr = requireTopicReader(principal, sourceTopic)
sourceReadable[sourceTopic] = readErr
}
if readErr != nil {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr)))
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq))
if err != nil {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
if msg == nil {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND"))
continue
}
if messageAvailability(*msg) != "available" {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE"))
continue
}
snapshot := favoriteSnapshot(msg)
items = append(items, snapshot)
ref := map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}
sourceRefs = append(sourceRefs, ref)
if len(preview) < 3 {
preview = append(preview, map[string]any{
"from": msg.From,
"type": imMessageType(msg),
"summary": textSnippet(messageText(*msg), ""),
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
})
}
}
if len(failures) > 0 {
return nil, failures
}
bundleID := "fbundle_" + store.Store.GetUidString()
return map[string]any{
"bundle_id": bundleID,
"title": "聊天记录",
"item_count": len(items),
"preview": preview,
"items": items,
"source_refs": sourceRefs,
"comment": strings.TrimSpace(comment),
"forwarded": true,
}, nil
}
func prepareHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) error {
if msg == nil || msg.TenantID != tenantID || strings.TrimSpace(msg.Topic) == "" {
return types.ErrMalformed
}
if msg.SeqId > 0 {
return nil
}
if !kafkaEnabled() && globals.hub == nil {
return nil
}
if store.Topics == nil {
return types.ErrMalformed
}
topicRecord, err := store.Topics.Get(tenantID, msg.Topic)
if err != nil {
return err
}
if topicRecord == nil {
return types.ErrNotFound
}
msg.SeqId = topicRecord.SeqId + 1
if msg.CreatedAt.IsZero() {
msg.ObjHeader.CreatedAt = time.Now().UTC().Round(time.Millisecond)
}
if msg.UpdatedAt.IsZero() {
msg.ObjHeader.UpdatedAt = msg.CreatedAt
}
return nil
}
func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) {
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
return
}
timestamp := msg.CreatedAt
if timestamp.IsZero() {
timestamp = time.Now().UTC().Round(time.Millisecond)
}
select {
case globals.hub.routeSrv <- &ServerComMessage{
TenantID: tenantID,
RcptTo: msg.Topic,
AsUser: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
Data: &MsgServerData{
Topic: msg.Topic,
From: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
SeqId: msg.SeqId,
Head: msg.Head,
Content: msg.Content,
},
}:
default:
logs.Warn.Printf("im message forward: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
}
}
func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
head := types.KVMap{}
if msg != nil {
@@ -8675,6 +9049,15 @@ func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
return head
}
func forwardBundleMessageHead(clientMid string) types.KVMap {
return types.KVMap{
"type": "forward_bundle",
"x-im-type": "forward_bundle",
"x-im-forwarded": true,
"x-im-client-mid": clientMid,
}
}
func forwardedMessageContent(msg *types.Message, comment string) any {
if strings.TrimSpace(comment) == "" {
if msg == nil {
@@ -9108,7 +9491,8 @@ func cleanStringList(values []string, limit int) []string {
func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback:
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeGroupAvatar:
return true
default:
return false
@@ -9127,6 +9511,10 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool {
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")
case types.PublicUploadPurposeGroupAvatar:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")
default:
return false
}
@@ -9243,6 +9631,64 @@ func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string
return nil
}
func publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType, template, text string, actors []imSystemMessageActor, actions []imSystemMessageAction, extra map[string]any) {
if tenantID == types.ZeroTenantID || strings.TrimSpace(topic) == "" || store.Store == nil || !store.Store.IsOpen() || store.Topics == nil || store.Messages == nil {
return
}
msgType = strings.TrimSpace(msgType)
if msgType == "" {
msgType = "system_notice"
}
if msgType != "system_notice" && msgType != "system_action" {
logs.Warn.Printf("im system message: unsupported type %q for topic %s", msgType, topic)
return
}
template = strings.TrimSpace(template)
text = strings.TrimSpace(text)
if template == "" || text == "" {
return
}
topicRecord, err := store.Topics.Get(tenantID, topic)
if err != nil || topicRecord == nil {
if err != nil {
logs.Warn.Printf("im system message: failed to load topic %s: %v", topic, err)
}
return
}
content := map[string]any{
"text": text,
"template": template,
"level": "normal",
}
if len(actors) > 0 {
content["actors"] = actors
}
if len(actions) > 0 {
content["actions"] = actions
}
if len(extra) > 0 {
content["extra"] = extra
}
now := time.Now().UTC().Round(time.Millisecond)
_, err = store.Messages.Save(tenantID, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
SeqId: topicRecord.SeqId + 1,
Topic: topic,
From: types.ZeroUid.String(),
Head: types.KVMap{
"x-im-schema": 1,
"x-im-type": msgType,
"x-im-template": template,
"x-im-from": "system",
},
Content: content,
}, nil, false)
if err != nil && !errors.Is(err, types.ErrDuplicate) {
logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err)
}
}
func systemNotificationIDFromPath(path string) string {
const marker = "/v1/im/system-notifications/"
idx := strings.Index(path, marker)
@@ -9567,6 +10013,37 @@ func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.U
return topic, peer
}
func resolveConversationContextTopic(principal *imPrincipal, topic string) (string, types.Uid, error) {
normalized, peer := normalizeP2PConversationTopic(topic, principal.Uid)
if normalized != "" && !peer.IsZero() {
return normalized, peer, nil
}
topic = strings.TrimSpace(topic)
if types.GetTopicCat(topic) != types.TopicCatP2P {
return "", types.ZeroUid, nil
}
if _, _, err := types.ParseP2P(topic); err != nil {
return "", types.ZeroUid, nil
}
cursor := ""
for {
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid, "", "", cursor, 100)
if err != nil {
return "", types.ZeroUid, err
}
for _, item := range items {
if strings.TrimSpace(item.Friendship.Topic) == topic && !item.Friendship.PeerID.IsZero() {
return topic, item.Friendship.PeerID, nil
}
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
return "", types.ZeroUid, nil
}
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized
+993 -37
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -118,14 +118,14 @@ var internalOutboxResourceSpecs = map[string]*internalQuerySpec{
var internalOrgResourceSpecs = map[string]*internalQuerySpec{
"departments": {
Select: "SELECT id,department_id,parent_id,name,sort_order,state,created_at,updated_at",
From: "FROM im_org_departments", DefaultSort: "ORDER BY sort_order ASC,id ASC",
Filters: []internalQueryFilter{{"parent_id", "parent_id", "=", "string"}, {"state", "state", "=", "string"}},
Select: "SELECT d.id,d.department_id,d.parent_id,d.name,d.sort_order,d.version,d.state,d.created_at,d.updated_at,(SELECT COUNT(*) FROM im_org_departments child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count,(SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id) AS member_count",
From: "FROM im_org_departments d", DefaultSort: "ORDER BY d.sort_order ASC,d.id ASC",
Filters: []internalQueryFilter{{"department_id", "d.department_id", "=", "string"}, {"parent_id", "d.parent_id", "=", "string"}, {"name", "d.name", "LIKE", "string"}, {"state", "d.state", "=", "string"}},
},
"members": {
Select: "SELECT id,department_id,user_id,display_name,title,mobile,email,employment_state,sort_order,created_at,updated_at",
From: "FROM im_org_members", DefaultSort: "ORDER BY sort_order ASC,id ASC",
Filters: []internalQueryFilter{{"department_id", "department_id", "=", "string"}, {"user_id", "user_id", "=", "uid"}, {"employment_state", "employment_state", "=", "string"}},
Select: "SELECT m.id,m.department_id,COALESCE(d.name,'') AS department_name,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at",
From: "FROM im_org_members m LEFT JOIN im_org_departments d ON d.department_id=m.department_id", DefaultSort: "ORDER BY m.sort_order ASC,m.id ASC",
Filters: []internalQueryFilter{{"department_id", "m.department_id", "=", "string"}, {"user_id", "m.user_id", "=", "uid"}, {"q", "m.display_name", "LIKE", "string"}, {"employment_state", "m.employment_state", "=", "string"}},
},
}
+11 -13
View File
@@ -38,6 +38,8 @@ const (
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
)
var publishKafkaDeadLetter = publishKafkaEnvelope
type adminCommandPayload struct {
CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"`
@@ -482,6 +484,13 @@ func kafkaTenantID(tenantCode string) (types.TenantID, error) {
return tenant.ID, nil
}
func kafkaTenantIDFromEnvelope(envelope kafkaEnvelope) (types.TenantID, error) {
if !envelope.TenantID.IsZero() {
return envelope.TenantID, nil
}
return kafkaTenantID(envelope.TenantCode)
}
func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope kafkaEnvelope, consume *types.KafkaConsumeRecord, reason, message string) error {
encoded, err := json.Marshal(envelope)
if err != nil {
@@ -512,26 +521,15 @@ func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope
Status: types.KafkaDeadStatusDead,
}
now := time.Now().UTC().Round(time.Millisecond)
if err = store.HTTPInfrastructure.MarkKafkaConsumeDead(tenantID, dead, now); err != nil {
return err
}
if auditErr := enqueueKafkaAuditEvent(ctx, tenantID, "system", types.ZeroUid, "kafka.dead_letter.created", "kafka_event", envelope.EventID, "dead", reason, map[string]any{
"dead_id": deadID,
"event_type": envelope.EventType,
"biz_type": envelope.BizType,
"biz_id": envelope.BizID,
"consumer_group": kafkaBus.config.ConsumerGroup,
}); auditErr != nil {
logs.Warn.Printf("kafka dead letter audit enqueue failed: event=%s dead=%s err=%v", envelope.EventID, deadID, auditErr)
}
deadPayload, err := json.Marshal(dead)
if err != nil {
return err
}
return publishKafkaEnvelope(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
return publishKafkaDeadLetter(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
EventID: deadID,
TraceID: envelope.TraceID,
TenantCode: envelope.TenantCode,
TenantID: tenantID,
SourceService: "im-server",
TargetService: "hotgo-admin",
EventType: "kafka.dead_letter.created",
+47
View File
@@ -6,10 +6,57 @@ import (
"strings"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func startKafkaDeadLetterConsumer() {
startKafkaConsumer("dead_letter", handleKafkaDeadLetter)
}
func handleKafkaDeadLetter(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
var dead types.KafkaDeadLetter
if err := json.Unmarshal(envelope.Payload, &dead); err != nil {
return err
}
if dead.DeadID == "" {
dead.DeadID = envelope.EventID
}
tenantID, err := tenantIDFromDeadLetter(envelope, &dead)
if err != nil {
return err
}
now := time.Now().UTC().Round(time.Millisecond)
if err = store.HTTPInfrastructure.MarkKafkaConsumeDead(tenantID, &dead, now); err != nil {
return err
}
if auditErr := enqueueKafkaAuditEvent(ctx, tenantID, "system", types.ZeroUid, "kafka.dead_letter.created", "kafka_event", dead.EventID, "dead", dead.LastReason, map[string]any{
"dead_id": dead.DeadID,
"event_type": dead.EventType,
"biz_type": dead.BizType,
"biz_id": dead.BizID,
"consumer_group": dead.ConsumerGroup,
}); auditErr != nil {
logs.Warn.Printf("kafka dead letter audit enqueue failed: event=%s dead=%s err=%v", dead.EventID, dead.DeadID, auditErr)
}
return nil
}
func tenantIDFromDeadLetter(envelope kafkaEnvelope, dead *types.KafkaDeadLetter) (types.TenantID, error) {
if !envelope.TenantID.IsZero() {
return envelope.TenantID, nil
}
if dead != nil && len(dead.Envelope) > 0 {
var original kafkaEnvelope
if err := json.Unmarshal(dead.Envelope, &original); err == nil && !original.TenantID.IsZero() {
return original.TenantID, nil
}
}
return kafkaTenantID(envelope.TenantCode)
}
func replayKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, deadID string, actor types.Uid, note string) (*types.KafkaDeadLetter, error) {
now := time.Now().UTC().Round(time.Millisecond)
dead, err := store.HTTPInfrastructure.MarkKafkaDeadLetterReplay(tenantID, deadID, actor.UserId(), note, now)
+5 -77
View File
@@ -2,14 +2,11 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -39,12 +36,8 @@ type kafkaFileLifecyclePayload struct {
OccurredAt time.Time `json:"occurred_at"`
}
func startKafkaFileLifecycleConsumer() {
startKafkaConsumer("file_lifecycle", handleKafkaFileLifecycle)
}
func enqueueKafkaFileUploaded(ctx context.Context, tenantID types.TenantID, fd *types.FileDef) error {
if !kafkaEnabled() || fd == nil || fd.TenantID != tenantID || fd.Status != types.UploadCompleted {
if fd == nil || fd.TenantID != tenantID || fd.Status != types.UploadCompleted {
return nil
}
payload := kafkaFileLifecyclePayload{
@@ -63,7 +56,7 @@ func enqueueKafkaFileUploaded(ctx context.Context, tenantID types.TenantID, fd *
}
func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string) error {
if !kafkaEnabled() || msg == nil || msg.TenantID != tenantID || len(attachmentURLs) == 0 {
if msg == nil || msg.TenantID != tenantID || len(attachmentURLs) == 0 {
return nil
}
payload := kafkaFileLifecyclePayload{
@@ -80,7 +73,7 @@ func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantI
}
func enqueueKafkaGroupFileLifecycle(ctx context.Context, tenantID types.TenantID, eventType string, file *types.GroupFile, actor types.Uid) error {
if !kafkaEnabled() || file == nil || file.Topic == "" || file.FileID == "" {
if file == nil || file.Topic == "" || file.FileID == "" {
return nil
}
occurredAt := file.UpdatedAt
@@ -104,76 +97,11 @@ func enqueueKafkaGroupFileLifecycle(ctx context.Context, tenantID types.TenantID
return enqueueKafkaFileLifecycle(ctx, tenantID, eventType, firstNonEmpty(file.GroupFileID, file.FileID), payload)
}
func enqueueKafkaFileLifecycle(_ context.Context, tenantID types.TenantID, eventType, bizID string, payload kafkaFileLifecyclePayload) error {
tenantCode := ""
if store.Tenants != nil {
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
tenantCode = tenant.Code
} else {
logs.Warn.Printf("kafka file lifecycle: failed to resolve tenant code for tenant %d: %v", tenantID, err)
}
}
func enqueueKafkaFileLifecycle(ctx context.Context, tenantID types.TenantID, eventType, bizID string, payload kafkaFileLifecyclePayload) error {
if payload.OccurredAt.IsZero() {
payload.OccurredAt = time.Now().UTC().Round(time.Millisecond)
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
eventID := stableIMEventID("file-lifecycle", tenantID, eventType, bizID, payload.SeqID, strings.Join(payload.AttachmentURLs, ","))
err = enqueueKafkaOutbox(tenantID, "file_lifecycle", bizID, kafkaEnvelope{
EventID: eventID,
TenantCode: tenantCode,
SourceService: "im-server",
TargetService: "im-server",
EventType: eventType,
BizType: "file",
BizID: bizID,
OccurredAt: payload.OccurredAt,
Payload: encoded,
})
if err != nil {
return err
}
actor := types.ParseUserId(payload.UserID)
if auditErr := enqueueKafkaAuditEvent(context.Background(), tenantID, "user", actor, eventType, "file", bizID, "succeeded", "", map[string]any{
"file_id": payload.FileID,
"file_ids": payload.FileIDs,
"topic": payload.Topic,
"group_file_id": payload.GroupFileID,
"seq_id": payload.SeqID,
}); auditErr != nil {
logs.Warn.Printf("kafka file lifecycle: failed to enqueue audit event type=%s biz=%s: %v", eventType, bizID, auditErr)
}
return nil
}
func handleKafkaFileLifecycle(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "file_lifecycle")
if err != nil || !claimed {
return err
}
var payload kafkaFileLifecyclePayload
if err = json.Unmarshal(envelope.Payload, &payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if err = executeKafkaFileLifecycle(tenantID, payload); err != nil {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
return executeKafkaFileLifecycle(tenantID, payload)
}
func executeKafkaFileLifecycle(tenantID types.TenantID, payload kafkaFileLifecyclePayload) error {
+74 -23
View File
@@ -20,36 +20,68 @@ type kafkaMessagePersistPayload struct {
MessageKind string `json:"message_kind"`
}
var publishKafkaMessagePersist = publishKafkaEnvelope
func startKafkaMessagePersistConsumer() {
startKafkaConsumer("message_persist", handleKafkaMessagePersist)
startKafkaConsumer("group_message_persist", handleKafkaGroupMessagePersist)
}
func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
if !kafkaEnabled() {
return store.Messages.Save(tenantID, msg, attachmentURLs, readBySender)
}
saveResult, err := store.PrepareMessagePersist(tenantID, msg, attachmentURLs, readBySender)
if err != nil || saveResult == nil || saveResult.Duplicate {
return saveResult, err
if msg == nil || msg.TenantID != tenantID || msg.Topic == "" || msg.SeqId <= 0 {
return nil, types.ErrMalformed
}
if readBySender && !types.ParseUid(msg.From).IsZero() {
saveResult.MarkedReadBySender = true
}
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
clientMsgID, err := store.NormalizeClientMsgID(msg.Head)
if err != nil {
return nil, err
}
return saveResult, nil
}
func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
tenantCode := ""
if store.Tenants != nil {
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
tenantCode = tenant.Code
} else {
logs.Warn.Printf("kafka message persist: failed to resolve tenant code for tenant %d: %v", tenantID, err)
msg.ClientMsgID = clientMsgID
fromUid := types.ParseUid(msg.From)
if clientMsgID != "" {
claim, claimed, runtimeReady, claimErr := store.ClaimMessageUnique(tenantID, msg.Topic, fromUid, clientMsgID)
if claimErr != nil {
return nil, claimErr
}
if runtimeReady && !claimed {
if claim != nil && claim.Seq > 0 {
msg.SeqId = claim.Seq
return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: true}, nil
}
return nil, types.ErrDuplicate
}
}
if seq, runtimeReady, seqErr := store.ReserveMessageSeq(tenantID, msg.Topic, msg.SeqId-1); seqErr != nil {
if clientMsgID != "" {
_ = store.MarkMessageUniqueFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
return nil, seqErr
} else if runtimeReady {
msg.SeqId = seq
}
msg.InitTimes()
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
if clientMsgID != "" {
_ = store.MarkMessageUniqueFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
return nil, err
}
if clientMsgID != "" {
if err = store.MarkMessageUniqueDone(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId, ""); err != nil {
logs.Warn.Printf("kafka message persist: failed to mark message unique key done topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
}
}
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
if cacheErr := store.CachePendingHotMessage(tenantID, msg); cacheErr != nil {
logs.Warn.Printf("kafka message persist: failed to cache pending hot message after publish topic=%s seq=%d: %v", msg.Topic, msg.SeqId, cacheErr)
}
}
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: readBySender && !fromUid.IsZero()}, nil
}
func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
kind := "p2p"
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
kind = "group"
@@ -66,7 +98,7 @@ func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg
eventID := fmt.Sprintf("msg.persist.%d.%s.%d", tenantID, msg.Topic, msg.SeqId)
envelope := kafkaEnvelope{
EventID: eventID,
TenantCode: tenantCode,
TenantID: tenantID,
SourceService: "im-server",
TargetService: "im-server",
EventType: "message.persist." + kind,
@@ -78,15 +110,27 @@ func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg
Actor: &kafkaActor{Type: "user", ID: types.ParseUid(msg.From).UserId()},
Payload: payload,
}
return enqueueKafkaOutbox(tenantID, "message_persist", msg.Topic, envelope)
return publishKafkaMessagePersist(ctx, kafkaMessagePersistTopicName(msg.Topic), msg.Topic, envelope)
}
func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
return handleKafkaMessagePersistFromTopic(ctx, envelope, "message_persist")
}
func handleKafkaGroupMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
return handleKafkaMessagePersistFromTopic(ctx, envelope, "group_message_persist")
}
func handleKafkaMessagePersistFromTopic(ctx context.Context, envelope kafkaEnvelope, sourceTopicName string) error {
tenantID := envelope.TenantID
var err error
if tenantID.IsZero() {
tenantID, err = kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
}
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist")
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, sourceTopicName)
if err != nil || !claimed {
return err
}
@@ -132,6 +176,13 @@ func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ ka
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func kafkaMessagePersistTopicName(topic string) string {
if types.GetTopicCat(topic) == types.TopicCatGrp {
return "group_message_persist"
}
return "message_persist"
}
func validateKafkaMessageKind(payload kafkaMessagePersistPayload) error {
if payload.Message.Topic == "" || payload.Message.SeqId <= 0 {
return types.ErrMalformed
-126
View File
@@ -1,126 +0,0 @@
package main
import (
"context"
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
kafkaOutboxPollInterval = 2 * time.Second
kafkaOutboxTenantLimit = 1000
kafkaOutboxBatchLimit = 100
)
func startKafkaOutboxPoller() {
if !kafkaEnabled() {
return
}
kafkaBus.wg.Add(1)
go func() {
defer kafkaBus.wg.Done()
ticker := time.NewTicker(kafkaOutboxPollInterval)
defer ticker.Stop()
for {
select {
case <-kafkaBus.ctx.Done():
return
default:
drainKafkaOutbox(kafkaBus.ctx)
}
select {
case <-kafkaBus.ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func enqueueKafkaOutbox(tenantID types.TenantID, topicName, key string, envelope kafkaEnvelope) error {
if envelope.OccurredAt.IsZero() {
envelope.OccurredAt = time.Now().UTC()
}
raw, err := json.Marshal(envelope)
if err != nil {
return err
}
return store.HTTPInfrastructure.CreateKafkaOutbox(tenantID, &types.KafkaOutboxRecord{
EventID: envelope.EventID,
Topic: topicName,
EventType: envelope.EventType,
BizType: envelope.BizType,
BizID: envelope.BizID,
Key: key,
Envelope: raw,
Status: types.KafkaOutboxPending,
NextRetryAt: nil,
})
}
func drainKafkaOutbox(ctx context.Context) {
if store.Tenants == nil || store.HTTPInfrastructure == nil {
return
}
tenants, err := store.Tenants.ListActive(kafkaOutboxTenantLimit)
if err != nil {
logs.Warn.Printf("kafka outbox: list active tenants: %v", err)
return
}
now := time.Now().UTC().Round(time.Millisecond)
for _, tenant := range tenants {
if tenant.ID.IsZero() {
continue
}
records, err := store.HTTPInfrastructure.ListPendingKafkaOutbox(tenant.ID, kafkaOutboxBatchLimit, now)
if err != nil {
logs.Warn.Printf("kafka outbox: list tenant=%d: %v", tenant.ID, err)
continue
}
for _, record := range records {
if err = publishKafkaOutboxRecord(ctx, record); err != nil {
nextRetryAt := now.Add(kafkaOutboxRetryDelay(record.Attempts + 1))
if markErr := store.HTTPInfrastructure.MarkKafkaOutboxFailed(tenant.ID, record.EventID,
"KAFKA_PUBLISH_FAILED", err.Error(), nextRetryAt, now); markErr != nil {
logs.Warn.Printf("kafka outbox: mark failed event=%s tenant=%d: %v", record.EventID, tenant.ID, markErr)
}
continue
}
if err = store.HTTPInfrastructure.MarkKafkaOutboxSent(tenant.ID, record.EventID, time.Now().UTC().Round(time.Millisecond)); err != nil {
logs.Warn.Printf("kafka outbox: mark sent event=%s tenant=%d: %v", record.EventID, tenant.ID, err)
}
}
}
}
func publishKafkaOutboxRecord(ctx context.Context, record types.KafkaOutboxRecord) error {
topic := kafkaTopic(record.Topic)
if topic == "" {
topic = record.Topic
}
return kafkaBus.writer.WriteMessages(ctx, kafka.Message{
Topic: topic,
Key: []byte(record.Key),
Value: record.Envelope,
Time: time.Now().UTC(),
})
}
func kafkaOutboxRetryDelay(attempt int) time.Duration {
if attempt <= 0 {
attempt = 1
}
if kafkaBus == nil || len(kafkaBus.config.RetryBackoffMS) == 0 {
return time.Duration(attempt) * 10 * time.Second
}
index := attempt - 1
if index >= len(kafkaBus.config.RetryBackoffMS) {
index = len(kafkaBus.config.RetryBackoffMS) - 1
}
return time.Duration(kafkaBus.config.RetryBackoffMS[index]) * time.Millisecond
}
+26 -25
View File
@@ -13,21 +13,21 @@ import (
"github.com/segmentio/kafka-go/sasl"
"github.com/segmentio/kafka-go/sasl/scram"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store/types"
)
const (
kafkaTopicAdminCommand = "im.admin.command"
kafkaTopicAdminCommandResult = "im.admin.command.result"
kafkaTopicConfigEvent = "im.config.event"
kafkaTopicBusinessEvent = "im.business.event"
kafkaTopicMessagePersist = "im.message.persist"
kafkaTopicMessagePush = "im.message.push"
kafkaTopicFileSecurity = "im.file.security"
kafkaTopicFileLifecycle = "im.file.lifecycle"
kafkaTopicWorkbenchEvent = "im.workbench.event"
kafkaTopicCacheInvalidate = "im.cache.invalidate"
kafkaTopicAuditEvent = "im.audit.event"
kafkaTopicDeadLetter = "im.dead.letter"
kafkaTopicAdminCommand = "im.admin.command"
kafkaTopicAdminCommandResult = "im.admin.command.result"
kafkaTopicConfigEvent = "im.config.event"
kafkaTopicBusinessEvent = "im.business.event"
kafkaTopicMessagePersist = "im.message.persist"
kafkaTopicGroupMessagePersist = "im.group.message.persist"
kafkaTopicMessagePush = "im.message.push"
kafkaTopicFileSecurity = "im.file.security"
kafkaTopicCacheInvalidate = "im.cache.invalidate"
kafkaTopicAuditEvent = "im.audit.event"
kafkaTopicDeadLetter = "im.dead.letter"
)
type kafkaConfig struct {
@@ -55,6 +55,7 @@ type kafkaEnvelope struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id,omitempty"`
TenantCode string `json:"tenant_code,omitempty"`
TenantID types.TenantID `json:"tenant_id,omitempty"`
SourceService string `json:"source_service,omitempty"`
TargetService string `json:"target_service,omitempty"`
EventType string `json:"event_type"`
@@ -132,6 +133,7 @@ func initKafkaRuntime(raw json.RawMessage) error {
AllowAutoTopicCreation: cfg.AllowAutoTopic,
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
MaxAttempts: cfg.MaxRetryCount,
}
if err := rt.ping(context.Background()); err != nil {
@@ -164,7 +166,7 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
cfg.RequiredAcks = int(kafka.RequireOne)
}
if cfg.MaxRetryCount <= 0 {
cfg.MaxRetryCount = 5
cfg.MaxRetryCount = 3
}
if len(cfg.RetryBackoffMS) == 0 {
cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000}
@@ -196,18 +198,17 @@ func kafkaSASLMechanism(cfg kafkaConfig) (sasl.Mechanism, error) {
func kafkaTopics(overrides map[string]string) map[string]string {
topics := map[string]string{
"admin_command": kafkaTopicAdminCommand,
"admin_command_result": kafkaTopicAdminCommandResult,
"config_event": kafkaTopicConfigEvent,
"business_event": kafkaTopicBusinessEvent,
"message_persist": kafkaTopicMessagePersist,
"message_push": kafkaTopicMessagePush,
"file_security": kafkaTopicFileSecurity,
"file_lifecycle": kafkaTopicFileLifecycle,
"workbench_event": kafkaTopicWorkbenchEvent,
"cache_invalidate": kafkaTopicCacheInvalidate,
"audit_event": kafkaTopicAuditEvent,
"dead_letter": kafkaTopicDeadLetter,
"admin_command": kafkaTopicAdminCommand,
"admin_command_result": kafkaTopicAdminCommandResult,
"config_event": kafkaTopicConfigEvent,
"business_event": kafkaTopicBusinessEvent,
"message_persist": kafkaTopicMessagePersist,
"group_message_persist": kafkaTopicGroupMessagePersist,
"message_push": kafkaTopicMessagePush,
"file_security": kafkaTopicFileSecurity,
"cache_invalidate": kafkaTopicCacheInvalidate,
"audit_event": kafkaTopicAuditEvent,
"dead_letter": kafkaTopicDeadLetter,
}
for key, value := range overrides {
if value != "" {
+18 -60
View File
@@ -5,9 +5,6 @@ import (
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -35,15 +32,10 @@ type kafkaAuditPayload struct {
OccurredAt time.Time `json:"occurred_at"`
}
func startKafkaWorkbenchEventConsumer() {
startKafkaConsumer("workbench_event", handleKafkaWorkbenchEvent)
}
var publishKafkaAuditEventEnvelope = publishKafkaEnvelope
func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string,
targets []types.Uid, title, summary string, data map[string]any) error {
if !kafkaEnabled() {
return nil
}
targetUserIDs := make([]string, 0, len(targets))
for _, target := range targets {
if !target.IsZero() {
@@ -61,7 +53,14 @@ func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, ac
Data: data,
OccurredAt: time.Now().UTC().Round(time.Millisecond),
}
return enqueueKafkaTypedEvent(ctx, tenantID, "workbench_event", "workbench."+action, "workbench", resourceID, actor, payload)
envelope := kafkaEnvelope{
EventID: stableIMEventID("workbench-sync", tenantID, "workbench."+action, resourceID, payload.OccurredAt),
TenantID: tenantID,
EventType: "workbench." + action,
BizType: "workbench",
BizID: resourceID,
}
return executeKafkaWorkbenchEvent(tenantID, envelope, payload)
}
func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorType string, actor types.Uid,
@@ -84,66 +83,25 @@ func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorT
Data: data,
OccurredAt: time.Now().UTC().Round(time.Millisecond),
}
return enqueueKafkaTypedEvent(ctx, tenantID, "audit_event", "audit."+action, "audit", resource+":"+resourceID, actor, payload)
}
func enqueueKafkaTypedEvent(_ context.Context, tenantID types.TenantID, topicName, eventType, bizType, bizID string, actor types.Uid, payload any) error {
tenantCode := ""
if store.Tenants != nil {
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
tenantCode = tenant.Code
} else {
logs.Warn.Printf("kafka event: failed to resolve tenant code for tenant %d: %v", tenantID, err)
}
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded)
envelope := kafkaEnvelope{
EventID: eventID,
TenantCode: tenantCode,
EventID: stableIMEventID("audit_event", tenantID, "audit."+action, resource+":"+resourceID, encoded),
TenantID: tenantID,
SourceService: "im-server",
TargetService: "im-server",
EventType: eventType,
BizType: bizType,
BizID: bizID,
OccurredAt: time.Now().UTC(),
TargetService: "hotgo-admin",
EventType: "audit." + action,
BizType: "audit",
BizID: resource + ":" + resourceID,
OccurredAt: payload.OccurredAt,
Payload: encoded,
}
if !actor.IsZero() {
envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()}
envelope.Actor = &kafkaActor{Type: actorType, ID: actorID}
}
return enqueueKafkaOutbox(tenantID, topicName, bizID, envelope)
}
func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "workbench_event")
if err != nil || !claimed {
return err
}
var payload kafkaWorkbenchPayload
if err = json.Unmarshal(envelope.Payload, &payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if err = executeKafkaWorkbenchEvent(tenantID, envelope, payload); err != nil {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
return publishKafkaAuditEventEnvelope(ctx, "audit_event", envelope.BizID, envelope)
}
func executeKafkaWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaWorkbenchPayload) error {
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
func TestKafkaTopicsKeepOnlyMessageInternalAsyncTopics(t *testing.T) {
topics := kafkaTopics(nil)
if topics["message_persist"] != "im.message.persist" {
t.Fatalf("unexpected private message persist topic: %q", topics["message_persist"])
}
if topics["group_message_persist"] != "im.group.message.persist" {
t.Fatalf("unexpected group message persist topic: %q", topics["group_message_persist"])
}
if topics["audit_event"] != "im.audit.event" {
t.Fatalf("unexpected audit event topic: %q", topics["audit_event"])
}
for _, removed := range []string{"file_lifecycle", "workbench_event"} {
if _, ok := topics[removed]; ok {
t.Fatalf("internal sync topic %q must not be configured", removed)
}
}
}
func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) {
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaAuditEventEnvelope
defer func() {
kafkaBus = oldKafkaBus
publishKafkaAuditEventEnvelope = oldPublisher
}()
kafkaBus = &kafkaRuntime{}
var publishedTopic string
var publishedKey string
var publishedEnvelope kafkaEnvelope
publishKafkaAuditEventEnvelope = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
publishedTopic = topicName
publishedKey = key
publishedEnvelope = envelope
return nil
}
err := enqueueKafkaAuditEvent(context.Background(), 7, "admin", types.Uid(42),
"group.update", "group", "grp-test", "succeeded", "", map[string]any{"topic": "grp-test"})
if err != nil {
t.Fatalf("enqueueKafkaAuditEvent returned error: %v", err)
}
if publishedTopic != "audit_event" {
t.Fatalf("unexpected audit publish topic: %q", publishedTopic)
}
if publishedKey != "group:grp-test" {
t.Fatalf("unexpected audit publish key: %q", publishedKey)
}
if publishedEnvelope.TargetService != "hotgo-admin" || publishedEnvelope.EventType != "audit.group.update" {
t.Fatalf("unexpected audit envelope: %#v", publishedEnvelope)
}
if publishedEnvelope.Actor == nil || publishedEnvelope.Actor.Type != "admin" || publishedEnvelope.Actor.ID != types.Uid(42).UserId() {
t.Fatalf("unexpected audit actor: %#v", publishedEnvelope.Actor)
}
var payload kafkaAuditPayload
if err = json.Unmarshal(publishedEnvelope.Payload, &payload); err != nil {
t.Fatalf("invalid audit payload: %v", err)
}
if payload.Resource != "group" || payload.ResourceID != "grp-test" || payload.Result != "succeeded" {
t.Fatalf("unexpected audit payload: %#v", payload)
}
}
func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldKafkaBus := kafkaBus
oldFiles := store.Files
defer func() {
kafkaBus = oldKafkaBus
store.Files = oldFiles
}()
kafkaBus = &kafkaRuntime{}
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
msgID := types.Uid(101)
msg := &types.Message{
ObjHeader: types.ObjHeader{Id: msgID.String(), CreatedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)},
TenantID: 7,
Topic: "usr42",
SeqId: 3,
From: types.Uid(42).String(),
Head: types.KVMap{"x-im-type": "image"},
}
attachments := []string{"https://api.118im.com/v1/im/files/file_001"}
files.EXPECT().LinkAttachments(types.TenantID(7), "", msgID, attachments).Return(nil)
if err := enqueueKafkaFileAttachmentsBind(context.Background(), 7, msg, attachments); err != nil {
t.Fatalf("enqueueKafkaFileAttachmentsBind returned error: %v", err)
}
}
func TestEnqueueKafkaWorkbenchEventRunsSynchronously(t *testing.T) {
oldKafkaBus := kafkaBus
oldInfrastructure := store.HTTPInfrastructure
oldSecurity := store.Security
defer func() {
kafkaBus = oldKafkaBus
store.HTTPInfrastructure = oldInfrastructure
store.Security = oldSecurity
}()
kafkaBus = &kafkaRuntime{}
infrastructure := &testHTTPInfrastructureStore{}
security := &testSecurityStore{}
store.HTTPInfrastructure = infrastructure
store.Security = security
err := enqueueKafkaWorkbenchEvent(context.Background(), 7, types.Uid(42), "meeting", "meeting.created", "meeting-1",
[]types.Uid{42, 43}, "会议通知", "周会", map[string]any{"meeting_id": "meeting-1"})
if err != nil {
t.Fatalf("enqueueKafkaWorkbenchEvent returned error: %v", err)
}
if len(security.notifications) != 1 {
t.Fatalf("expected one notification for non-actor target, got %#v", security.notifications)
}
if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" {
t.Fatalf("unexpected notification: %#v", security.notifications[0])
}
if len(infrastructure.events) != 2 {
t.Fatalf("expected business events for both targets, got %#v", infrastructure.events)
}
}
+2 -3
View File
@@ -696,9 +696,7 @@ func main() {
startKafkaConfigEventConsumer()
startKafkaCacheInvalidateConsumer()
startKafkaMessagePersistConsumer()
startKafkaFileLifecycleConsumer()
startKafkaWorkbenchEventConsumer()
startKafkaOutboxPoller()
startKafkaDeadLetterConsumer()
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents()
@@ -862,6 +860,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource)
mux.HandleFunc(config.ApiPath+"v1/im/calls/rtc-join", serveIMCallRTCJoin)
mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
+3 -4
View File
@@ -95,7 +95,7 @@
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"pending_ttl_seconds": 300,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
@@ -121,7 +121,7 @@
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 5,
"max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"dead_letter_retention_seconds": 7776000,
"topics": {
@@ -130,10 +130,9 @@
"config_event": "im.config.event",
"business_event": "im.business.event",
"message_persist": "im.message.persist",
"group_message_persist": "im.group.message.persist",
"message_push": "im.message.push",
"file_security": "im.file.security",
"file_lifecycle": "im.file.lifecycle",
"workbench_event": "im.workbench.event",
"cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event",
"dead_letter": "im.dead.letter"
+72 -2
View File
@@ -39,6 +39,11 @@ type groupMessageUniqueRecord struct {
CreatedAt time.Time `json:"created_at"`
}
type MessageUniqueClaim struct {
Status string
Seq int64
}
type groupFanoutEnvelope struct {
OriginNodeID string `json:"origin_node_id"`
Message json.RawMessage `json:"message"`
@@ -51,6 +56,7 @@ type groupMessageRuntime interface {
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
CachePendingHotMessage(tenantID types.TenantID, msg *types.Message) error
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
@@ -75,6 +81,39 @@ type groupMessageRuntime interface {
var groupMsgRuntime groupMessageRuntime
func ClaimMessageUnique(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*MessageUniqueClaim, bool, bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || from.IsZero() || clientMsgID == "" {
return nil, true, false, nil
}
rec, claimed, err := groupMsgRuntime.Claim(tenantID, topic, from, clientMsgID)
if rec == nil {
return nil, claimed, true, err
}
return &MessageUniqueClaim{Status: rec.Status, Seq: rec.Seq}, claimed, true, err
}
func ReserveMessageSeq(tenantID types.TenantID, topic string, baseline int64) (int64, bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return 0, false, nil
}
seq, err := groupMsgRuntime.NextSeq(tenantID, topic, baseline)
return seq, true, err
}
func MarkMessageUniqueDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || from.IsZero() || clientMsgID == "" {
return nil
}
return groupMsgRuntime.MarkDone(tenantID, topic, from, clientMsgID, seq, msgID)
}
func MarkMessageUniqueFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || from.IsZero() || clientMsgID == "" {
return nil
}
return groupMsgRuntime.MarkFailed(tenantID, topic, from, clientMsgID)
}
type UserPresence struct {
Online bool `json:"online"`
NodeID string `json:"node_id"`
@@ -187,6 +226,27 @@ func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
}
func CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return nil
}
return groupMsgRuntime.CacheHotMessage(tenantID, msg)
}
func CachePendingHotMessage(tenantID types.TenantID, msg *types.Message) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return nil
}
return groupMsgRuntime.CachePendingHotMessage(tenantID, msg)
}
func GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil, false, nil
}
return groupMsgRuntime.GetHotMessages(tenantID, topic, opts, maxLimit)
}
func InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil
@@ -239,7 +299,7 @@ func initGroupMessageRuntime(raw json.RawMessage) error {
cfg.KeyPrefix = "im"
}
if cfg.PendingTTL <= 0 {
cfg.PendingTTL = 120
cfg.PendingTTL = 300
}
if cfg.DoneTTL <= 0 {
cfg.DoneTTL = 86400
@@ -380,15 +440,25 @@ func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic str
}
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
return r.cacheHotMessage(tenantID, msg, time.Duration(r.cfg.HotMsgTTL)*time.Second)
}
func (r *redisGroupMessageRuntime) CachePendingHotMessage(tenantID types.TenantID, msg *types.Message) error {
return r.cacheHotMessage(tenantID, msg, time.Duration(r.cfg.PendingTTL)*time.Second)
}
func (r *redisGroupMessageRuntime) cacheHotMessage(tenantID types.TenantID, msg *types.Message, ttl time.Duration) error {
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return types.ErrMalformed
}
if ttl <= 0 {
return types.ErrMalformed
}
payload, err := json.Marshal(msg)
if err != nil {
return err
}
ctx := context.Background()
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
seq := strconv.FormatInt(msg.SeqId, 10)
pipe := r.client.Pipeline()
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
+178
View File
@@ -0,0 +1,178 @@
package store
import (
"context"
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord
seqs map[string]int64
}
func newTestGroupMessageRuntime() *testGroupMessageRuntime {
return &testGroupMessageRuntime{
claims: make(map[string]*groupMessageUniqueRecord),
seqs: make(map[string]int64),
}
}
func (r *testGroupMessageRuntime) Close() error { return nil }
func (r *testGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) {
key := topic + ":" + from.UserId() + ":" + clientMsgID
if rec := r.claims[key]; rec != nil {
return rec, false, nil
}
rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()}
r.claims[key] = rec
return rec, true, nil
}
func (r *testGroupMessageRuntime) NextSeq(_ types.TenantID, topic string, baseline int64) (int64, error) {
if r.seqs[topic] < baseline {
r.seqs[topic] = baseline
}
r.seqs[topic]++
return r.seqs[topic], nil
}
func (r *testGroupMessageRuntime) MarkDone(_ types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
key := topic + ":" + from.UserId() + ":" + clientMsgID
r.claims[key] = &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()}
return nil
}
func (r *testGroupMessageRuntime) MarkFailed(_ types.TenantID, topic string, from types.Uid, clientMsgID string) error {
key := topic + ":" + from.UserId() + ":" + clientMsgID
r.claims[key] = &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()}
return nil
}
func (r *testGroupMessageRuntime) CacheHotMessage(types.TenantID, *types.Message) error {
return nil
}
func (r *testGroupMessageRuntime) CachePendingHotMessage(types.TenantID, *types.Message) error {
return nil
}
func (r *testGroupMessageRuntime) GetHotMessages(types.TenantID, string, *types.QueryOpt, int) ([]types.Message, bool, error) {
return nil, false, nil
}
func (r *testGroupMessageRuntime) InvalidateHotMessages(types.TenantID, string, []types.Range) error {
return nil
}
func (r *testGroupMessageRuntime) MarkUserOnline(types.TenantID, types.Uid, string, string, string, string) error {
return nil
}
func (r *testGroupMessageRuntime) RefreshUserPresence(types.TenantID, types.Uid, string, string) error {
return nil
}
func (r *testGroupMessageRuntime) MarkUserOffline(types.TenantID, types.Uid, string, string) error {
return nil
}
func (r *testGroupMessageRuntime) GetUserPresence(types.TenantID, types.Uid) (*UserPresence, error) {
return nil, nil
}
func (r *testGroupMessageRuntime) MarkTopicMemberOnline(types.TenantID, string, types.Uid, string) error {
return nil
}
func (r *testGroupMessageRuntime) MarkTopicMemberOffline(types.TenantID, string, types.Uid, string) error {
return nil
}
func (r *testGroupMessageRuntime) GetTopicNodeOnlineMembers(types.TenantID, string, string) ([]types.Uid, error) {
return nil, nil
}
func (r *testGroupMessageRuntime) PublishGroupFanout(string, any) error {
return nil
}
func (r *testGroupMessageRuntime) StartGroupFanoutSubscriber(context.Context, string, func([]byte)) error {
return nil
}
func (r *testGroupMessageRuntime) CacheObject(types.TenantID, string, string, any) error {
return nil
}
func (r *testGroupMessageRuntime) GetCachedObject(types.TenantID, string, string, any) (bool, error) {
return false, nil
}
func (r *testGroupMessageRuntime) DeleteCachedObject(types.TenantID, string, string) error {
return nil
}
func (r *testGroupMessageRuntime) AddCachedSetMember(types.TenantID, string, string, string) error {
return nil
}
func (r *testGroupMessageRuntime) RemoveCachedSetMember(types.TenantID, string, string, string) error {
return nil
}
func (r *testGroupMessageRuntime) CachedSetContains(types.TenantID, string, string, string) (bool, error) {
return false, nil
}
func (r *testGroupMessageRuntime) AllowRate(types.TenantID, string, int, time.Duration) (bool, int, time.Duration, error) {
return true, 0, 0, nil
}
func (r *testGroupMessageRuntime) ClaimShortState(string, string, time.Duration) (bool, error) {
return true, nil
}
func (r *testGroupMessageRuntime) ClaimShortStateValue(string, string, string, time.Duration) (bool, bool, error) {
return true, true, nil
}
func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()
groupMsgRuntime = runtime
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
tenantID := types.TenantID(7)
topic := types.Uid(1).P2PName(types.Uid(2))
from := types.Uid(1)
claim, claimed, ready, err := ClaimMessageUnique(tenantID, topic, from, "mid-1")
if err != nil || !ready || !claimed || claim == nil || claim.Status != "pending" {
t.Fatalf("unexpected first claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err)
}
seq, ready, err := ReserveMessageSeq(tenantID, topic, 184)
if err != nil || !ready || seq != 185 {
t.Fatalf("unexpected first seq: seq=%d ready=%v err=%v", seq, ready, err)
}
if err = MarkMessageUniqueDone(tenantID, topic, from, "mid-1", seq, ""); err != nil {
t.Fatalf("mark done: %v", err)
}
claim, claimed, ready, err = ClaimMessageUnique(tenantID, topic, from, "mid-1")
if err != nil || !ready || claimed || claim == nil || claim.Seq != 185 {
t.Fatalf("unexpected duplicate claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err)
}
claim, claimed, ready, err = ClaimMessageUnique(tenantID, topic, from, "mid-2")
if err != nil || !ready || !claimed || claim == nil {
t.Fatalf("unexpected second claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err)
}
seq, ready, err = ReserveMessageSeq(tenantID, topic, 184)
if err != nil || !ready || seq != 186 {
t.Fatalf("unexpected second seq: seq=%d ready=%v err=%v", seq, ready, err)
}
}
-36
View File
@@ -22,10 +22,6 @@ type HTTPInfrastructurePersistenceInterface interface {
GetKafkaDeadLetter(types.TenantID, string) (*types.KafkaDeadLetter, error)
MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error
MarkKafkaDeadLetterReplay(types.TenantID, string, string, string, time.Time) (*types.KafkaDeadLetter, error)
CreateKafkaOutbox(types.TenantID, *types.KafkaOutboxRecord) error
ListPendingKafkaOutbox(types.TenantID, int, time.Time) ([]types.KafkaOutboxRecord, error)
MarkKafkaOutboxSent(types.TenantID, string, time.Time) error
MarkKafkaOutboxFailed(types.TenantID, string, string, string, time.Time, time.Time) error
}
type httpInfrastructureMapper struct{}
@@ -155,35 +151,3 @@ func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantI
}
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
}
func (httpInfrastructureMapper) CreateKafkaOutbox(tenantID types.TenantID, record *types.KafkaOutboxRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxCreate(tenantID, record)
}
func (httpInfrastructureMapper) ListPendingKafkaOutbox(tenantID types.TenantID, limit int, now time.Time) ([]types.KafkaOutboxRecord, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaOutboxListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkKafkaOutboxSent(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxMarkSent(tenantID, eventID, now)
}
func (httpInfrastructureMapper) MarkKafkaOutboxFailed(tenantID types.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxMarkFailed(tenantID, eventID, reason, message, nextRetryAt, now)
}
+5 -1
View File
@@ -3443,6 +3443,10 @@ func PersistPreparedMessage(tenantID types.TenantID, msg *types.Message, attachm
return Messages.PersistPrepared(tenantID, msg, attachmentURLs, readBySender)
}
func NormalizeClientMsgID(head types.KVMap) (string, error) {
return normalizeClientMsgID(head)
}
func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message) error {
latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
if err != nil {
@@ -3570,7 +3574,7 @@ func validIMMessageHeaderToken(value string, max int) bool {
func validIMMessageType(value string) bool {
switch strings.TrimSpace(value) {
case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card",
"card", "link", "forward_bundle", "sticker", "call", "system", "system_event":
"card", "link", "forward_bundle", "sticker", "call", "system", "system_notice", "system_action", "system_event":
return true
default:
return false
+2
View File
@@ -19,6 +19,8 @@ func TestValidateMessageHeadersProductContract(t *testing.T) {
{"x-im-schema": json.Number("1"), "x-im-type": "voice"},
{"x-im-schema": "1", "x-im-type": "contact_card"},
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"},
{"x-im-schema": 1, "x-im-type": "system_notice"},
{"x-im-schema": 1, "x-im-type": "system_action"},
{"type": "system"},
}
for _, head := range valid {
-24
View File
@@ -23,10 +23,6 @@ const (
KafkaDeadStatusReplayed = "replayed"
KafkaDeadStatusIgnored = "ignored"
KafkaDeadStatusResolved = "resolved"
KafkaOutboxPending = "pending"
KafkaOutboxSent = "sent"
KafkaOutboxFailed = "failed"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
@@ -115,26 +111,6 @@ type KafkaDeadLetterPage struct {
NextCursor string
}
// KafkaOutboxRecord stores a Kafka event waiting for reliable publication.
type KafkaOutboxRecord struct {
ID int64
EventID string
Topic string
EventType string
BizType string
BizID string
Key string
Envelope []byte
Status string
Attempts int
NextRetryAt *time.Time
SentAt *time.Time
LastReason string
LastError string
CreatedAt time.Time
UpdatedAt time.Time
}
// StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string {
hash := sha256.New()
+1
View File
@@ -5,6 +5,7 @@ import "time"
const (
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
PublicUploadPurposeGroupAvatar = "group_avatar"
TenantApplicationStatusReceived = "received"
TenantEntryFeedbackReceived = "received"
+3 -4
View File
@@ -95,7 +95,7 @@
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"pending_ttl_seconds": 300,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
@@ -121,7 +121,7 @@
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 5,
"max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"dead_letter_retention_seconds": 7776000,
"topics": {
@@ -130,10 +130,9 @@
"config_event": "im.config.event",
"business_event": "im.business.event",
"message_persist": "im.message.persist",
"group_message_persist": "im.group.message.persist",
"message_push": "im.message.push",
"file_security": "im.file.security",
"file_lifecycle": "im.file.lifecycle",
"workbench_event": "im.workbench.event",
"cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event",
"dead_letter": "im.dead.letter"
+70 -3
View File
@@ -1060,8 +1060,7 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
legacyType, _ := head["type"].(string)
productType, _ := head["x-im-type"].(string)
if legacyType == "system" || legacyType == "system_event" ||
productType == "system" || productType == "system_event" {
if isSystemGeneratedMessageType(legacyType) || isSystemGeneratedMessageType(productType) {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
@@ -1190,6 +1189,15 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
return nil
}
func isSystemGeneratedMessageType(value string) bool {
switch strings.TrimSpace(value) {
case "system", "system_notice", "system_action", "system_event":
return true
default:
return false
}
}
// handlePubBroadcast fans out {pub} -> {data} messages to recipients in a master topic.
// This is a NON-proxy broadcast.
func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
@@ -2976,11 +2984,20 @@ func (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *M
count := 0
if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() {
// Read messages from DB
messages, err := store.Messages.GetAll(t.tenantID, t.name, asUid, msgOpts2storeOpts(req))
opts := msgOpts2storeOpts(req)
messages, err := store.Messages.GetAll(t.tenantID, t.name, asUid, opts)
if err != nil {
sess.queueOut(ErrUnknownReply(msg, now))
return err
}
if len(messages) == 0 && shouldTryHotMessageFallback(t.cat, t.lastID, opts) {
hotMessages, ok, cacheErr := store.GetHotMessages(t.tenantID, t.name, opts, hotMessageFallbackLimit(opts))
if cacheErr != nil {
logs.Warn.Printf("get.data hot message fallback failed topic=%s user=%s: %v", t.name, asUid.UserId(), cacheErr)
} else if ok {
messages = filterHotMessagesForUser(hotMessages, asUid)
}
}
// Push the list of messages to the client as {data}.
if messages != nil {
@@ -3024,6 +3041,56 @@ func (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *M
return nil
}
func shouldTryHotMessageFallback(cat types.TopicCat, lastID int64, opts *types.QueryOpt) bool {
if cat != types.TopicCatGrp || opts == nil || opts.IfModifiedSince != nil ||
!opts.User.IsZero() || opts.Topic != "" || opts.Since <= 0 || opts.Before > 0 || len(opts.IdRanges) > 0 {
return false
}
limit := hotMessageFallbackLimit(opts)
if limit <= 0 || limit > 10 {
return false
}
floor := lastID - 10
if floor < 1 {
floor = 1
}
return lastID <= 0 || opts.Since >= floor
}
func hotMessageFallbackLimit(opts *types.QueryOpt) int {
if opts != nil && opts.Limit > 0 {
return opts.Limit
}
return 1
}
func filterHotMessagesForUser(messages []types.Message, asUid types.Uid) []types.Message {
if len(messages) == 0 {
return messages
}
filtered := messages[:0]
for i := range messages {
if hotMessageVisibleToUser(&messages[i], asUid) {
filtered = append(filtered, messages[i])
}
}
return filtered
}
func hotMessageVisibleToUser(msg *types.Message, asUid types.Uid) bool {
if msg == nil || msg.DeletedAt != nil || msg.DelId > 0 || asUid.IsZero() {
return msg != nil && msg.DeletedAt == nil && msg.DelId == 0
}
uid := asUid.String()
userID := asUid.UserId()
for _, deleted := range msg.DeletedFor {
if deleted.User == uid || deleted.User == userID {
return false
}
}
return true
}
// replyGetTags returns topics' tags - tokens used for discovery.
func (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error {
now := types.TimeNow()
+232 -35
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
@@ -9,6 +11,7 @@ import (
"time"
"github.com/golang/mock/gomock"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
@@ -789,44 +792,48 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
}
func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
defer helper.tearDown()
for _, msgType := range []string{"system_event", "system_notice", "system_action"} {
t.Run(msgType, func(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
defer helper.tearDown()
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "sys-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"},
Content: "test",
},
sess: helper.sessions[0],
}
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "sys-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"x-im-schema": float64(1), "x-im-type": msgType},
Content: "test",
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
helper.topic.handleClientMsg(msg)
helper.finish()
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if helper.topic.lastID != 0 {
t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID)
}
if len(helper.results[0].messages) != 1 {
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
}
resp := helper.results[0].messages[0].(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != 403 {
t.Fatalf("User 1 expected 403 ctrl, received %#v", resp)
}
if len(helper.results[1].messages) != 0 {
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
}
if len(helper.hubMessages) != 0 {
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if helper.topic.lastID != 0 {
t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID)
}
if len(helper.results[0].messages) != 1 {
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
}
resp := helper.results[0].messages[0].(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != 403 {
t.Fatalf("User 1 expected 403 ctrl, received %#v", resp)
}
if len(helper.results[1].messages) != 0 {
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
}
if len(helper.hubMessages) != 0 {
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
}
})
}
}
@@ -897,6 +904,196 @@ func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, t)
}
func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldKafkaBus := kafkaBus
oldMessages := store.Messages
oldPublisher := publishKafkaMessagePersist
defer func() {
kafkaBus = oldKafkaBus
store.Messages = oldMessages
publishKafkaMessagePersist = oldPublisher
}()
kafkaBus = &kafkaRuntime{}
store.Messages = mock_store.NewMockMessagesPersistenceInterface(ctrl)
var publishedTopic string
var publishedKey string
var publishedEnvelope kafkaEnvelope
publishKafkaMessagePersist = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
publishedTopic = topicName
publishedKey = key
publishedEnvelope = envelope
return nil
}
msg := &types.Message{
TenantID: 1,
Topic: "grp-test",
SeqId: 1,
From: types.Uid(1).String(),
Head: types.KVMap{"x-im-client-mid": "client-1"},
Content: "hello",
}
got, err := saveMessageMaybeKafka(context.Background(), 1, msg, []string{"file1"}, true)
if err != nil {
t.Fatalf("saveMessageMaybeKafka returned error: %v", err)
}
if got == nil || got.SeqId != 1 || !got.MarkedReadBySender {
t.Fatalf("unexpected save result: %#v", got)
}
if msg.ClientMsgID != "client-1" {
t.Fatalf("client message id not normalized: %q", msg.ClientMsgID)
}
if publishedTopic != "group_message_persist" {
t.Fatalf("unexpected kafka topic: %q", publishedTopic)
}
if publishedKey != "grp-test" {
t.Fatalf("unexpected kafka key: %q", publishedKey)
}
if publishedEnvelope.EventType != "message.persist.group" {
t.Fatalf("unexpected event type: %q", publishedEnvelope.EventType)
}
if publishedEnvelope.TenantID != 1 || publishedEnvelope.TenantCode != "" {
t.Fatalf("unexpected tenant routing fields: tenant_id=%d tenant_code=%q", publishedEnvelope.TenantID, publishedEnvelope.TenantCode)
}
if publishedEnvelope.IdempotencyKey != "client-1" {
t.Fatalf("unexpected idempotency key: %q", publishedEnvelope.IdempotencyKey)
}
}
func TestShouldTryHotMessageFallback(t *testing.T) {
if !shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 159, Limit: 1}) {
t.Fatal("expected fallback for latest group seq query")
}
if shouldTryHotMessageFallback(types.TopicCatP2P, 159, &types.QueryOpt{Since: 159, Limit: 1}) {
t.Fatal("did not expect fallback for p2p topic")
}
if shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 1, Limit: 1}) {
t.Fatal("did not expect fallback for old history query")
}
if shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 159, Limit: 50}) {
t.Fatal("did not expect fallback for broad query")
}
}
func TestFilterHotMessagesForUserSkipsDeleted(t *testing.T) {
uid := types.Uid(1)
now := time.Now()
messages := []types.Message{
{SeqId: 1},
{SeqId: 2, DeletedFor: []types.SoftDelete{{User: uid.String()}}},
{SeqId: 3, DeletedFor: []types.SoftDelete{{User: uid.UserId()}}},
{SeqId: 4, DeletedAt: &now},
{SeqId: 5, DelId: 10},
}
filtered := filterHotMessagesForUser(messages, uid)
if len(filtered) != 1 || filtered[0].SeqId != 1 {
t.Fatalf("unexpected filtered messages: %#v", filtered)
}
}
func TestMarkKafkaDeadLetterPublishesBeforeDBWrite(t *testing.T) {
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaDeadLetter
oldInfrastructure := store.HTTPInfrastructure
defer func() {
kafkaBus = oldKafkaBus
publishKafkaDeadLetter = oldPublisher
store.HTTPInfrastructure = oldInfrastructure
}()
kafkaBus = &kafkaRuntime{config: kafkaConfig{ConsumerGroup: "im-server"}}
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
var publishedEnvelope kafkaEnvelope
publishKafkaDeadLetter = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
if topicName != "dead_letter" || key != "event-1" {
t.Fatalf("unexpected dead letter publish target topic=%q key=%q", topicName, key)
}
publishedEnvelope = envelope
return nil
}
original := kafkaEnvelope{
EventID: "event-1",
TenantID: 1,
EventType: "message.persist.group",
BizType: "message",
BizID: "grp-test:1",
}
consume := &types.KafkaConsumeRecord{
EventID: "event-1",
ConsumerGroup: "im-server",
SourceTopic: "im.message.persist",
RetryCount: 3,
}
if err := markKafkaDeadLetter(context.Background(), 1, original, consume, "DB_FAILED", "boom"); err != nil {
t.Fatalf("markKafkaDeadLetter returned error: %v", err)
}
if len(infrastructure.deadLetters) != 0 {
t.Fatalf("dead letter was written before dead_letter topic consumption: %#v", infrastructure.deadLetters)
}
if publishedEnvelope.EventID == "" || publishedEnvelope.TenantID != 1 {
t.Fatalf("dead letter event was not published correctly: %#v", publishedEnvelope)
}
}
func TestHandleKafkaDeadLetterWritesDB(t *testing.T) {
oldKafkaBus := kafkaBus
oldInfrastructure := store.HTTPInfrastructure
defer func() {
kafkaBus = oldKafkaBus
store.HTTPInfrastructure = oldInfrastructure
}()
kafkaBus = nil
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
original := kafkaEnvelope{
EventID: "event-1",
TenantID: 1,
EventType: "message.persist.group",
BizType: "message",
BizID: "grp-test:1",
}
encodedOriginal, _ := json.Marshal(original)
dead := types.KafkaDeadLetter{
DeadID: "dead-1",
EventID: "event-1",
SourceTopic: "im.message.persist",
ConsumerGroup: "im-server",
EventType: "message.persist.group",
BizType: "message",
BizID: "grp-test:1",
Envelope: encodedOriginal,
RetryCount: 3,
LastReason: "DB_FAILED",
LastError: "boom",
Status: types.KafkaDeadStatusDead,
}
payload, _ := json.Marshal(dead)
envelope := kafkaEnvelope{
EventID: "dead-1",
TenantID: 1,
EventType: "kafka.dead_letter.created",
ParentEventID: "event-1",
Payload: payload,
}
if err := handleKafkaDeadLetter(context.Background(), envelope, kafka.Message{}); err != nil {
t.Fatalf("handleKafkaDeadLetter returned error: %v", err)
}
if len(infrastructure.deadLetters) != 1 {
t.Fatalf("expected one dead letter write, got %d", len(infrastructure.deadLetters))
}
if infrastructure.deadLetters[0].DeadID != "dead-1" || infrastructure.deadLetters[0].EventID != "event-1" {
t.Fatalf("unexpected dead letter write: %#v", infrastructure.deadLetters[0])
}
}
func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
+4
View File
@@ -223,6 +223,8 @@ type testSecurityStore struct {
realName *types.RealNameVerification
realNameAppeal *types.RealNameAppeal
actionToken *types.ActionToken
consumedAction string
consumedTarget string
independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings
attempts int
@@ -294,6 +296,8 @@ func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.Act
}
func (s *testSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _ string, action string, target string, _ time.Time) (*types.ActionToken, error) {
s.consumedAction = action
s.consumedTarget = target
if s.err != nil {
return nil, s.err
}