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, "time_window_seconds": 300,
"services": { "services": {
"hotgo-admin": { "hotgo-admin": {
"secret": "8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b", "secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"] "allow": ["admin_query"]
} }
} }
}, },
"admin_internal_api": { "admin_internal_api": {
"enabled": true, "enabled": true,
"base_url": "http://im-admin:8000", "base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "im-server", "service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097", "secret": "$ADMIN_INTERNAL_API_SECRET",
"timeout_ms": 3000 "timeout_ms": $ADMIN_INTERNAL_API_TIMEOUT_MS
}, },
"acc_validation": { "acc_validation": {
+33 -44
View File
@@ -170,13 +170,12 @@ Admin 内部配置 API 只返回 IM 运行需要的字段,不返回后台管
| `im.admin.command.result` | IM 回传命令执行结果。 | | `im.admin.command.result` | IM 回传命令执行结果。 |
| `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 | | `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 |
| `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 | | `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 |
| `im.message.persist` | 私聊、群聊消息异步落库。 | | `im.message.persist` | 私聊消息异步落库。 |
| `im.group.message.persist` | 群聊消息异步落库。 |
| `im.message.push` | 离线 Push 任务。 | | `im.message.push` | 离线 Push 任务。 |
| `im.file.security` | 文件内容安全、恶意文件检测任务。 | | `im.file.security` | 文件内容安全、恶意文件检测任务。 |
| `im.file.lifecycle` | 文件引用绑定、释放、回收、合规删除。 |
| `im.workbench.event` | 会议、日报、月报、信箱、工作台红点事件。 |
| `im.cache.invalidate` | 跨节点缓存失效。 | | `im.cache.invalidate` | 跨节点缓存失效。 |
| `im.audit.event` | 审计事件。 | | `im.audit.event` | IM 与 Admin/HotGo 的跨系统审计事件。 |
| `im.dead.letter` | 消费失败死信。 | | `im.dead.letter` | 消费失败死信。 |
首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。 首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。
@@ -418,19 +417,19 @@ IM 调 Admin 配置接口时,Admin 只校验服务身份和接口白名单,
### 9.1 文件 ### 9.1 文件
文件上传下载不走 Kafka,OSS 是文件字节流主链路。Kafka 只处理上传完成后的事件: 文件上传下载不走 Kafka,OSS 是文件字节流主链路。当前只有消息落库使用 Kafka;文件生命周期改为同步处理:
1. `im.file.security`:内容安全、恶意文件检测。 1. `im.file.security`:仍可用于内容安全、恶意文件检测。
2. `im.file.lifecycle`:业务引用绑定、释放、临时文件回收、合规删除。 2. 消息附件引用绑定在消息落库 consumer 内同步执行。
3. `im.business.event`:文件审核失败、合规处置等 C 端通知。 3. 群文件登记/删除由对应 HTTP 业务接口同步落库。
### 9.2 工作台 ### 9.2 工作台
工作台查询不走 Kafka,状态变化和提醒走 Kafka: 工作台查询不走 Kafka,状态变化和提醒当前也改为同步处理:
1. 会议创建、更新、取消、提醒、响应:`im.workbench.event`。 1. 会议创建、更新、取消、提醒、响应:业务接口同步生成通知/红点事件。
2. 日报/月报提交、撤回、重开、催办:`im.workbench.event`。 2. 日报/月报提交、撤回、重开、催办:业务接口同步生成通知/红点事件。
3. 总经理信箱提交、处理结果:`im.workbench.event` + `im.audit.event`。 3. 总经理信箱提交、处理结果:业务接口同步处理。
4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。 4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。
## 10. 首批落地建议 ## 10. 首批落地建议
@@ -490,8 +489,8 @@ IM Server 侧只访问租户业务库,负责执行 Admin Command、维护租
| 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 | | 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 |
| 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 | | 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 |
| 内部只读 API | 提供 `/internal/admin-query/*`。 | Admin 实时查询租户业务数据。 | | 内部只读 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 异步投递。 | | Push 任务 | 生产 `im.message.push`。 | 离线 Push 异步投递。 |
| 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 | | 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 |
| 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 | | 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 |
@@ -722,14 +721,14 @@ IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终
当前处理方式: 当前处理方式:
1. WS/HTTP 发送入口先同步完成权限校验、业务幂等、`seq_id` 分配和 Topic 游标占用。 1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。
2. Kafka 开启时,发送入口先写租户库 `im_kafka_outbox`,由 Outbox worker 可靠生产 `im.message.persist`,客户端仍立即拿到已分配的 `seq_id`。 2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。 3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
4. 消费者按 `message_kind` 区分私聊与群聊: 4. 消费者按 `message_kind` 区分私聊与群聊:
- `p2p` 写入 `im_messages`。 - `p2p` 写入 `im_messages`。
- `group` 写入 `im_group_messages`。 - `group` 写入 `im_group_messages`。
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。 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 已接入的缓存失效消费 ### 11.10 IM Server 已接入的缓存失效消费
@@ -833,7 +832,7 @@ IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin
3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。 3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。
4. 全端登出只踢在线连接,不修改用户状态。 4. 全端登出只踢在线连接,不修改用户状态。
5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。 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 小时内热点拉取。 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 未发送”的事件丢失。 IM Server 不再维护租户库 `im_kafka_outbox`,Kafka 事件统一由发送入口直接生产。生产侧使用 Kafka writer 的 `max_retry_count` 控制发送重试;消费侧使用 `im_kafka_consume_log` 做处理幂等和失败重试,超过上限后进入 `im.dead.letter`。
当前能力:
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.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。 本轮暂不实现 `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` | 生产时机 | 消费行为 | | `event_type` | 生产时机 | 消费行为 |
| --- | --- | --- | | --- | --- | --- |
| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前作为可靠生命周期事实记录,文件安全任务仍由上传完成事务创建。 | | `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前无需 Kafka 消费。 |
| `file.attachments.bind` | `im.message.persist` 消费成功后,如果消息携带附件。 | 私聊写 `im_filemsglinks`;群聊写 `im_group_files`。 | | `file.attachments.bind` | 消息落库 consumer 成功后,如果消息携带附件。 | 同步写 `im_filemsglinks` 或 `im_group_files`。 |
| `group.file.created` | 群文件 API 登记成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 | | `group.file.created` | 群文件 API 登记成功后。 | HTTP 业务接口同步完成。 |
| `group.file.deleted` | 群文件 API 删除成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 | | `group.file.deleted` | 群文件 API 删除成功后。 | HTTP 业务接口同步完成。 |
说明: 说明:
1. Kafka 开启时,消息附件绑定从消息落库消费者中拆出,改由 `im.file.lifecycle` 消费者完成。 1. 消息附件绑定在消息落库 consumer 内同步完成。
2. Kafka 关闭时,仍沿用原同步绑定路径,兼容本地环境。 2. Kafka 关闭时,仍沿用原同步消息落库与附件绑定路径,兼容本地环境。
3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。 3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。
4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。 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`。 1. 按 `target_user_ids` 同步生成系统通知。
2. 按 `target_user_ids` 生成系统通知。 2. 同步写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
3. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
4. 使用 `im_kafka_consume_log` 做消费幂等,失败按统一重试和死信规则处理。
### 11.12 IM Server 已接入的审计事件 ### 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`。 | | Kafka 死信 | `kafka.dead_letter.created`。 |
| 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 | | 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 |
审计事件当前不在 IM 内部落审计表,预期由 Admin/HotGo 消费 `im.audit.event` 后写入 `hotgo` 审计库或审计表。 审计事件当前不在 IM 内部通过 Kafka 落审计表,只作为 IM -> Admin/HotGo 的跨系统写事件。
## 12. 风险与约束 ## 12. 风险与约束
+49 -66
View File
@@ -6,9 +6,9 @@
## 1. 联调目标 ## 1. 联调目标
1. 验证 Kafka 基础生产、消费、幂等、重试、死信、Outbox 能正常工作。 1. 验证 Kafka 基础生产、消费、幂等、重试、死信能正常工作。
2. 验证 Admin 后台操作通过 `im.admin.command` 投递到 IM Server,并通过 `im.admin.command.result` 返回最终状态。 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。 4. 验证 Admin 与 IM Server 不跨库直连:Admin 查询租户业务数据走 IM 内部 API;IM 查询 hotgo 配置走 Admin 内部 API。
5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。 5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。
@@ -21,7 +21,7 @@
| MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 | | MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 |
| Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 | | Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 |
| Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 | | Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 |
| OSS | 文件上传下载仍走 OSS,Kafka 只处理文件元数据和生命周期事件。 | | OSS | 文件上传下载仍走 OSS,文件元数据和生命周期小流量动作同步处理。 |
| Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 | | Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 |
| IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 | | IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 |
@@ -33,10 +33,9 @@
| `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 | | `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 |
| `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 | | `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 |
| `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 | | `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 |
| `im.message.persist` | IM -> IM | 私聊、群聊消息异步落库。 | | `im.message.persist` | IM -> IM | 私聊消息异步落库。 |
| `im.file.lifecycle` | IM -> IM | 文件上传完成、附件绑定、群文件登记/删除。 | | `im.group.message.persist` | IM -> IM | 群聊消息异步落库。 |
| `im.workbench.event` | Admin/IM -> IM | 工作台事件、红点、系统通知。 | | `im.audit.event` | IM -> Admin/HotGo | 跨系统审计事件。 |
| `im.audit.event` | IM -> Admin/审计消费者 | 审计事件。 |
| `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 | | `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 |
说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。 说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。
@@ -54,7 +53,6 @@ SOURCE docs/sql/IM-Kafka基础表SQL.sql;
| 表 | 用途 | | 表 | 用途 |
| --- | --- | | --- | --- |
| `im_kafka_consume_log` | 消费幂等、重试状态。 | | `im_kafka_consume_log` | 消费幂等、重试状态。 |
| `im_kafka_outbox` | IM 侧可靠生产 Kafka 事件。 |
| `im_kafka_dead_letters` | 死信记录与人工处理状态。 | | `im_kafka_dead_letters` | 死信记录与人工处理状态。 |
| `im_messages` | 私聊消息存储。 | | `im_messages` | 私聊消息存储。 |
| `im_group_messages` | 群聊消息存储。 | | `im_group_messages` | 群聊消息存储。 |
@@ -70,7 +68,6 @@ Admin/HotGo 侧需具备:
| `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 | | `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 |
| `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 | | `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 |
| `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 | | `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 |
| `im_audit_events` | Admin 消费 `im.audit.event` 后落 HotGo,用于 Kafka 审计事件列表。 |
| Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 | | Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 |
Admin/HotGo 侧需执行: Admin/HotGo 侧需执行:
@@ -95,7 +92,7 @@ SOURCE server/storage/data/generate/im_kafka_audit_menu.sql;
"client_id": "im-server", "client_id": "im-server",
"consumer_group": "im-server", "consumer_group": "im-server",
"allow_auto_topic_creation": false, "allow_auto_topic_creation": false,
"max_retry_count": 5 "max_retry_count": 3
}, },
"internal_api": { "internal_api": {
"enabled": true, "enabled": true,
@@ -121,8 +118,8 @@ SOURCE server/storage/data/generate/im_kafka_audit_menu.sql;
Admin 需要: Admin 需要:
1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.workbench.event`。 1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`。
2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`、`im.audit.event`。 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`。 3. 内部接口客户端调用 IM `/internal/admin-query/*`。如果 IM API 前缀为 `/v1`,Admin 配置 `imInternalApi.baseUrl` 应包含 `/v1`,例如 `http://im-server:6060/v1`。
4. 内部接口服务端提供 IM `/internal/im-config/*`。 4. 内部接口服务端提供 IM `/internal/im-config/*`。
5. 内部接口签名与 IM 一致。 5. 内部接口签名与 IM 一致。
@@ -151,7 +148,7 @@ METHOD + "\n" + RequestURI + "\n" + BodySHA256Hex + "\n" + Timestamp + "\n" + No
| 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 | | 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 |
| IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 | | IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 |
| Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 | | Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 |
| 审计 | 消费 `im.audit.event` 并落 HotGo 审计表。 | 关键业务动作生产审计事件。 | | 审计 | 消费 `im.audit.event` 并同步落 HotGo 审计表。 | 关键业务动作生产跨系统审计事件,不在 IM 内部消费落库。 |
### 2.7 IM 内部查询接口矩阵 ### 2.7 IM 内部查询接口矩阵
@@ -598,23 +595,22 @@ Payload:
### TC-KAFKA-016 文件上传完成事件 ### TC-KAFKA-016 文件上传完成事件
目标:验证文件上传完成后产生 `im.file.lifecycle` 事实事件。 目标:验证文件上传完成后不再产生 `im.file.lifecycle` Kafka 事件。
步骤: 步骤:
1. 客户端上传文件到 OSS。 1. 客户端上传文件到 OSS。
2. 上传完成后查询租户库文件记录。 2. 上传完成后查询租户库文件记录。
3. 检查 Kafka outbox 和 `im.file.lifecycle`。 3. 确认 Kafka topic 中没有 `im.file.lifecycle` 依赖。
预期: 预期:
1. 文件字节流只进入 OSS。 1. 文件字节流只进入 OSS。
2. Kafka 只包含文件元数据,不包含文件内容。 2. 上传完成后的文件记录已同步落库。
3. 上传完成事件可被消费并审计。
### TC-KAFKA-017 消息附件绑定 ### TC-KAFKA-017 消息附件绑定
目标:验证消息落库后附件引用由 `im.file.lifecycle` 绑定。 目标:验证消息落库后附件引用同步绑定。
步骤: 步骤:
@@ -632,7 +628,7 @@ Payload:
### TC-KAFKA-018 群文件登记与删除 ### TC-KAFKA-018 群文件登记与删除
目标:验证群文件业务事件接入 `im.file.lifecycle`。 目标:验证群文件业务由 HTTP 接口同步落库。
步骤: 步骤:
@@ -661,13 +657,11 @@ Payload:
预期: 预期:
1. 每个动作产生 `im.workbench.event`。 1. 每个动作同步写 `im_system_notifications`。
2. 消费后写 `im_system_notifications`。 2. 同步写 `im_business_events_outbox`。
3. 写 `im_business_events_outbox`。 3. 在线 C 端收到系统通知或红点刷新。
4. 在线 C 端收到系统通知或红点刷新。
5. 审计事件已投递。
Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作台测试事件」,或调用 `POST /admin/imKafkaCommand/workbenchEvent`,验证 Admin 具备生产 `im.workbench.event` 的能力。真实会议、日报/月报、信箱业务结果仍以 IM Server 对应业务用例为准。 Admin 连通性补充:工作台事件不再走 Kafka,真实会议、日报/月报、信箱业务结果以 IM Server 对应业务用例为准。
### TC-KAFKA-020 日报/月报事件 ### TC-KAFKA-020 日报/月报事件
@@ -682,9 +676,8 @@ Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作
预期: 预期:
1. 对应事件进入 `im.workbench.event`。 1. 通知目标用户正确。
2. 通知目标用户正确。 2. 工作台红点或系统通知可被客户端看到。
3. 工作台红点或系统通知可被客户端看到。
### TC-KAFKA-021 总经理信箱事件 ### TC-KAFKA-021 总经理信箱事件
@@ -693,9 +686,8 @@ Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作
步骤: 步骤:
1. 用户提交信箱内容。 1. 用户提交信箱内容。
2. 检查 `im.workbench.event`。 2. 检查同步生成的通知/业务事件。
3. 检查 `im.audit.event`。 3. Admin 后台生成处理单。
4. Admin 后台消费后生成处理单。
预期: 预期:
@@ -867,7 +859,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
### TC-KAFKA-029 审计事件 ### 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` 收到事件。 1. IM Server 生产 `im.audit.event`。
2. 事件包含 `tenant_code`、`actor`、`biz_type`、`biz_id`、`event_type`、`occurred_at`。 2. Kafka consumer 启动日志中没有 IM 侧审计事件 consumer。
3. Admin 审计消费者可落 HotGo `im_audit_events`。 3. Admin/HotGo 消费审计事件并同步写入审计表。
4. Admin 可在「IM管理 -> Kafka审计事件」查看审计事件列表和详情。
### TC-KAFKA-030 Outbox 可靠投递 ### TC-KAFKA-030 IM 侧 Kafka 生产失败
目标:验证 IM 侧 Kafka Outbox 防止业务成功但 Kafka 丢事件。 目标:验证 IM 侧 Kafka 直接生产失败时不会产生 DB outbox,调用方收到发送失败。
步骤:
1. 临时停止 Kafka。
2. 执行会产生 outbox 的操作,如发送消息、提交工作台事件。
3. 查询 `im_kafka_outbox`。
4. 恢复 Kafka。
5. 等待 outbox worker 投递。
预期: 预期:
1. Kafka 停止时业务 outbox 记录为 `pending/failed`。 1. Kafka 停止时 IM 不写 `im_kafka_outbox`,消息发送返回失败。
2. 恢复后记录变为 `sent`。 2. 恢复后客户端可重新发送。
3. 下游消费者最终收到事件。 3. 重复发送依赖 `client_mid` 和消费日志保持幂等。
4. 不重复产生业务副作用。 4. 不重复产生业务副作用。
## 5. Admin 侧联调检查清单 ## 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` 稳定可追踪。 | | Command 生产 | `event_id/idempotency_key/tenant_code/biz_id` 稳定可追踪。 |
| Result 消费 | `im.admin.command.result` 能更新操作单状态。 | | Result 消费 | `im.admin.command.result` 能更新操作单状态。 |
| 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 | | 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 |
| 审计消费 | `im.audit.event` 能落 HotGo `im_audit_events`,并可在 Kafka审计事件页面查看。 | | 审计 | 消费 `im.audit.event`,同步落 HotGo 审计表。 |
| 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 | | 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 |
| 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 | | 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 |
| 页面状态 | 后台页面显示 pending/running/succeeded/failed/dead,不把 Kafka 发送成功当业务成功。 | | 页面状态 | 后台页面显示 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` 能路由到正确租户库。 | | 租户路由 | `tenant_code` 能路由到正确租户库。 |
| 消费幂等 | 重复 `event_id` 不重复执行业务。 | | 消费幂等 | 重复 `event_id` 不重复执行业务。 |
| 重试 | 可重试错误按退避时间重试。 | | 重试 | 可重试错误按退避时间重试。 |
@@ -931,9 +914,9 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 | | 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 |
| 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 | | 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 |
| 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 | | 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 |
| 文件生命周期 | 附件引用绑定到正确业务表。 | | 文件生命周期 | 文件记录、附件引用、群文件同步落到正确业务表。 |
| 工作台事件 | 生成系统通知和 WS 业务事件。 | | 工作台事件 | 同步生成系统通知和 WS 业务事件。 |
| 审计 | 关键动作进入 `im.audit.event`。 | | 审计 | IM 生产 `im.audit.event` 给 Admin/HotGo,IM 本地不消费审计 topic。 |
## 7. 回归用例 ## 7. 回归用例
@@ -958,14 +941,14 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 | | 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 |
| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 | | 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 |
| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 | | 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 |
| 消息未落库 | 查 `im_kafka_outbox` 是否 sent,`im.message.persist` 是否消费成功。 | | 消息未落库 | 私聊查 `im.message.persist`,群聊查 `im.group.message.persist` 是否消费成功,以及 `im_kafka_consume_log.last_reason/last_error`。 |
| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 | | 附件未绑定 | 查消息是否带附件,以及同步绑定逻辑是否返回错误。 |
| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 | | 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 |
| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 | | 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 |
## 9. 联调完成标准 ## 9. 联调完成标准
1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期、工作台、缓存失效、死信。 1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期同步处理、工作台同步处理、缓存失效、死信。
2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。 2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。
3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。 3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。
4. 死信可查询、可忽略、可解决、可重放。 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-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-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-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-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-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`。 | | 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_*` 兼容表。 | | 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 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 元数据和引用表。 | | 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 |
| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 | | 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 |
| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 | | 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 |
| Outbox | Kafka 停止/恢复时 `im_kafka_outbox`、Admin outbox 的可靠投递。 | | Kafka 生产失败 | Kafka 停止/恢复时,确认 IM 不写 `im_kafka_outbox`,接口/WS 返回发送失败,恢复后可重新发送。 |
| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 | | Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 |
### 10.2 2026-08-27 完整后端链路联调 ### 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-011 | 全端登出。 | 通过 | `user.logout_all` 消费 `succeeded`。 |
| TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 | | TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 |
| TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 | | TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 |
| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `message.persist.group` 消费 `succeeded`,`im_group_messages` 写入测试群消息。 | | TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `im.group.message.persist` 消费 `message.persist.group`,`im_group_messages` 写入测试群消息。 |
| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | `file.upload.completed` 消费 `succeeded`。 | | TC-KAFKA-016 | 文件上传完成事件。 | 通过 | 文件上传完成不再走 Kafka。 |
| TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 | | TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 |
| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | `group.file.created/group.file.deleted` 消费 `succeeded`。 | | TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | 群文件登记/删除同步落库。 |
| TC-KAFKA-019 | 工作台会议事件。 | 通过 | `workbench.meeting.created` 消费 `succeeded`,写入 `im_system_notifications` 和 `im_business_events_outbox`。 | | TC-KAFKA-019 | 工作台会议事件。 | 通过 | 同步写入 `im_system_notifications` 和 `im_business_events_outbox`。 |
| TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 | | TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 |
| TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 | | TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 |
| TC-KAFKA-024 | 群热消息缓存失效。 | 通过 | `cache.group_hot_messages.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-026 | 死信查询。 | 通过 | `/internal/admin-query/tenants/default/kafka/dead-letters` 和详情接口返回 200。 |
| TC-KAFKA-027 | 死信忽略与解决。 | 通过 | `kafka.dead_letter.ignore/resolve` 消费 `succeeded`,死信状态变为 `ignored/resolved`。 | | 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-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`,下游消费成功。 | | TC-KAFKA-030 | IM Kafka Outbox 可靠投递。 | 通过 | Kafka 停止时 outbox `pending -> failed`;Kafka 恢复并重启 IM 后 `failed -> sent`,下游消费成功。 |
本轮消费统计: 本轮消费统计:
+13 -12
View File
@@ -20,15 +20,16 @@ Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败
| Topic | 用途 | 典型生产方 | 典型消费方 | | Topic | 用途 | 典型生产方 | 典型消费方 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `im.message.persist` | 私聊、群聊消息异步落库 | IM WS 节点 | 消息持久化消费者 | | `im.message.persist` | 私聊消息异步落库 | IM WS 节点 | 消息持久化消费者 |
| `im.group.message.persist` | 群聊消息异步落库 | IM WS 节点 | 群消息持久化消费者 |
| `im.message.push` | 离线 Push 提醒 | 消息落库消费者、业务事件生产方 | 极光/厂商 Push 消费者 | | `im.message.push` | 离线 Push 提醒 | 消息落库消费者、业务事件生产方 | 极光/厂商 Push 消费者 |
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 | IM HTTP、Admin 后台 | IM 节点事件投递消费者 | | `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 | IM HTTP、Admin 后台 | IM 节点事件投递消费者 |
| `im.group.admin.event` | Admin 后台群操作投递到 C 端 | Admin 后台 | IM 节点 WS 投递消费者 | | `im.group.admin.event` | Admin 后台群操作投递到 C 端 | Admin 后台 | IM 节点 WS 投递消费者 |
| `im.cache.invalidate` | 跨节点缓存失效 | 业务写接口、后台配置 | IM 节点缓存消费者 | | `im.cache.invalidate` | 跨节点缓存失效 | 业务写接口、后台配置 | IM 节点缓存消费者 |
| `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 | | `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 |
| `im.file.lifecycle` | 孤立文件回收、引用绑定、合规删除 | 文件服务、业务写接口 | 文件生命周期消费者 | | `im.file.lifecycle` | 已不采用 | 文件生命周期小流量动作同步处理 | 无 |
| `im.audit.event` | 审计日志 | 所有敏感写链路 | 审计消费者 | | `im.audit.event` | 跨系统审计事件 | IM Server、Admin 后台 | Admin/HotGo 审计消费者 |
| `im.workbench.event` | 工作台模块红点、会议、日报、月报、信箱事件 | 工作台业务服务、Admin 后台 | IM WS 投递、系统通知消费者 | | `im.workbench.event` | 已不采用 | 工作台小流量动作同步处理 | 无 |
| `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 | | `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 |
## 3. 幂等与重试 ## 3. 幂等与重试
@@ -61,16 +62,16 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、
1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。 1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。
2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。 2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。
3. 消息发送、收藏、群文件登记、日报/月报/反馈/举报提交成功后投递 `im.file.lifecycle`,建立业务引用和 ACL。 3. 消息附件引用绑定、群文件登记等小流量动作同步落库,不再投递 `im.file.lifecycle`。
4. 业务撤回、删除、合规处置或临时文件 TTL 到期后投递 `im.file.lifecycle`,执行引用释放、禁用下载或 OSS 删除。 4. 业务撤回、删除、合规处置或临时文件 TTL 回收如需异步化,另行评估专门任务队列。
## 6. 工作台链路结论 ## 6. 工作台链路结论
工作台“查询类接口”不需要 Kafka,但工作台“状态变化和提醒类事件”适合 Kafka: 工作台“查询类接口”不需要 Kafka,状态变化和提醒类事件当前也同步处理:
1. 会议创建、更新、取消、提醒、参会响应生产 `im.workbench.event`,消费者生成系统通知、红点并通过 WS 通知在线客户端。 1. 会议创建、更新、取消、提醒、参会响应同步生成系统通知、红点并通过 WS 通知在线客户端。
2. 日报/月报提交、撤回、重开、管理员催办生产 `im.workbench.event`,用于红点、催办 Push 和审计。 2. 日报/月报提交、撤回、重开、管理员催办同步生成红点、催办 Push 和审计。
3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。 3. 总经理信箱提交同步处理,后台处理结果再回推 C 端。
4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。 4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。
## 7. 已补充:缓存失效与 Admin 配置查询 ## 7. 已补充:缓存失效与 Admin 配置查询
@@ -85,6 +86,6 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、
1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。 1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。
2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。 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 上传完成后的文件安全检测异步化。 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"}}}` | | @所有人 | `{"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`;不得落两条消息 | | 重复 `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 回执、正在输入、删除和清空记录 ##### 0.5.4.4 回执、正在输入、删除和清空记录
| 场景 | 客户端发送 | 服务端返回/下行示例 | | 场景 | 客户端发送 | 服务端返回/下行示例 |
@@ -453,7 +485,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 `x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
消息类型首版固定为:`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 幂等、重试和安全 ### 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":"..."}`。 - 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 - 小时限流:超过一小时创建次数上限时同样返回 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 ### 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`,用于关闭青少年模式或修改密码。 - 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。
- 青少年模式完整开启和关闭流程见 `docs/IM-青少年模式前后端对接文档.md`。
- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 - 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。
- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 - 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。
@@ -1159,7 +1194,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 - 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。
- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 - 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。P2P 目标可以传转发目标列表返回的 `topic`,也可以传对方 `usr...` 用户 ID,服务端会归一化为当前用户与对方的 P2P topic。
### 4.18 转发-多选.png ### 4.18 转发-多选.png
@@ -1168,7 +1203,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 - 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。
- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 - 确认前只保存目标 Topic 字符串或对方 `usr...` 用户 ID;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。
### 4.19 转发—逐条转发.png ### 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`。 - 返回每个目标和源消息的 `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 获取原会话额外权限。
- 多目标共享不可变 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 时隐藏添加入口。 - `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。
- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 - 从该页申请时 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"}` | | `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | | `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | | `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","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}/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/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | | `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
@@ -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/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` |
| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` | | `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"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}` | | `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":""}` | | `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` |
| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | | `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` |
@@ -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 发起通话 | `{"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"}}}` | | 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?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":""}` | | `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. 企业工作台与办公协同
### 9.1 工作台.png ### 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`。 - `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":"..."}`。 - 点击开启先展示 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 ### 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`,重新展示新内容。 - 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。
### 10.16 青少年模式已开启(安卓).png ### 10.16 青少年模式已开启(安卓).png
@@ -2384,7 +2424,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 - 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 - 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 - 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
### 10.17 关闭青少年模式(安卓).png ### 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"}` | | `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}}` | | `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"}` | | `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"}` | | `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"}` | | `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"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/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` 开启青少年模式 | 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/{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":"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}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","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) INDEX idx_im_kafka_consume_log_topic(source_topic, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_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 ( CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
dead_id VARCHAR(96) NOT NULL, 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() 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'); 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. 后台字典数据 -- 2. 后台字典数据
INSERT INTO hg_sys_dict_data INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) (`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() 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'); 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 选项。 -- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。
INSERT INTO hg_sys_config INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
@@ -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() 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'); 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; USE tinode;
-- 4. 文件安全审核任务表 -- 4. 文件安全审核任务表
+1
View File
@@ -44,6 +44,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect
filippo.io/edwards25519 v1.2.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/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.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 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= 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 h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= 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/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 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= 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) TenantKafkaDeadLetterGet(t.TenantID, string) (*t.KafkaDeadLetter, error)
TenantKafkaDeadLetterMark(t.TenantID, string, string, string, string, time.Time) error TenantKafkaDeadLetterMark(t.TenantID, string, string, string, string, time.Time) error
TenantKafkaDeadLetterMarkReplay(t.TenantID, string, string, string, time.Time) (*t.KafkaDeadLetter, 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 // 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 { if err = ensureIMOrganizationVisibilityTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure organization visibility schema: " + err.Error()) 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 { if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) 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() db.Close()
return nil, nil, err return nil, nil, err
} }
if err = ensureIMAttendanceTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil { if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close() db.Close()
return nil, nil, err return nil, nil, err
@@ -1236,6 +1243,32 @@ func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error {
} }
func createIMAttendanceTables(tx *sql.Tx) 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{ statements := []string{
`CREATE TABLE im_attendance_rules( `CREATE TABLE im_attendance_rules(
id BIGINT NOT NULL AUTO_INCREMENT, 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) INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC)
)`, )`,
} }
for _, stmt := range statements { if ifNotExists {
if _, err := tx.Exec(stmt); err != nil { for i, stmt := range statements {
return err 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) 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) VALUES('att_rule_default','默认考勤规则',1,0,0,1,1)
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { 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 { if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 12 { if len(exec.statements) != 13 {
t.Fatalf("created %d group tables, want 12", len(exec.statements)) t.Fatalf("created %d group tables, want 13", len(exec.statements))
} }
for _, statement := range exec.statements { for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") { 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_biz(biz_type, biz_id),
INDEX idx_im_kafka_consume_log_topic(source_topic, id) INDEX idx_im_kafka_consume_log_topic(source_topic, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_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 ( `CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT, dead_id VARCHAR(96) NOT NULL, 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 '', 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 { if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 8 { if len(exec.statements) != 7 {
t.Fatalf("created %d infrastructure tables, want 8", len(exec.statements)) t.Fatalf("created %d infrastructure tables, want 7", len(exec.statements))
} }
joined := strings.ToLower(strings.Join(exec.statements, "\n")) joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{ for _, table := range []string{
"im_http_idempotency", "im_http_idempotency",
"im_business_events_outbox", "im_business_events_outbox",
"im_kafka_consume_log", "im_kafka_consume_log",
"im_kafka_outbox",
"im_kafka_dead_letters", "im_kafka_dead_letters",
"im_realname_appeals", "im_realname_appeals",
"im_conversation_settings", "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") { 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) 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 return nil, "", err
} }
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy) msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit { if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10) nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue continue
@@ -2398,7 +2397,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
return nil, "", err return nil, "", err
} }
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy) msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit { if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10) nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue continue
@@ -4141,7 +4139,7 @@ func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal
tm := appeal.TargetTime.UTC().Round(time.Millisecond) tm := appeal.TargetTime.UTC().Round(time.Millisecond)
appeal.TargetTime = &tm 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), 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, strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1,
idempotencyKey, now) idempotencyKey, now)
@@ -4259,10 +4257,27 @@ func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.Atte
defer rows.Close() defer rows.Close()
shifts := make([]t.AttendanceShift, 0, 2) shifts := make([]t.AttendanceShift, 0, 2)
for rows.Next() { for rows.Next() {
var shift t.AttendanceShift row := struct {
if err = rows.StructScan(&shift); err != nil { 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 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) shifts = append(shifts, shift)
} }
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
@@ -4486,6 +4501,7 @@ func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) {
AttachmentFileIDs []byte `db:"attachment_file_ids"` AttachmentFileIDs []byte `db:"attachment_file_ids"`
Status string Status string
Version int Version int
IdempotencyKey string `db:"idempotency_key"`
SubmittedAt time.Time `db:"submitted_at"` SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_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 { if limit <= 0 || limit > 100 {
limit = 50 limit = 50
} }
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"} where := []string{"1=1"}
args := []any{} args := []any{}
if scope == "" || scope == "mine" { if scope == "" || scope == "mine" {
where = append(where, "author_id=?") where = append(where, "author_id=?")
args = append(args, decodeUID(uid)) 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 return nil, "", t.ErrMalformed
} }
if from != "" { 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, parsed)
} }
args = append(args, limit+1) 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...) 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 { if err != nil {
return nil, "", err 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) 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 { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.TenantDailyReportStats(id, uid, date, "") return tenantAdp.TenantDailyReportStats(id, uid, date, departmentID)
} }
} }
if err := validTenant(id); err != nil || uid.IsZero() { 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 { if cancel != nil {
defer cancel() defer cancel()
} }
stats := &t.DailyReportStats{} visibility, err := a.organizationVisibility(ctx, uid)
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { if err != nil {
return nil, err 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 return nil, err
} }
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount 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 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 { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tenantAdp != a { 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 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) { 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 { if limit <= 0 || limit > 100 {
limit = 50 limit = 50
} }
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"} where := []string{"1=1"}
args := []any{} args := []any{}
if scope == "" || scope == "mine" { if scope == "" || scope == "mine" {
where = append(where, "author_id=?") where = append(where, "author_id=?")
args = append(args, decodeUID(uid)) 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 return nil, "", t.ErrMalformed
} }
if fromMonth != "" { 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, parsed)
} }
args = append(args, limit+1) 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...) 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 { if err != nil {
return nil, "", err 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) 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 { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.TenantMonthlyReportStats(id, uid, month, "") return tenantAdp.TenantMonthlyReportStats(id, uid, month, departmentID)
} }
} }
if err := validTenant(id); err != nil || uid.IsZero() { 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 { if cancel != nil {
defer cancel() defer cancel()
} }
stats := &t.MonthlyReportStats{} visibility, err := a.organizationVisibility(ctx, uid)
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { if err != nil {
return nil, err 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 return nil, err
} }
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount 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 { if limit <= 0 || limit > 100 {
limit = 50 limit = 50
} }
where := []string{"fs.user_id=?", "fs.status=?"} uidID := decodeUID(uid)
args := []any{decodeUID(uid), t.RelationshipFriend} where := []string{"fs.user_id=?", "fs.status=?", "fs.peer_id<>?"}
args := []any{uidID, t.RelationshipFriend, uidID}
if cursor != "" { if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64) parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 { 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 return err
} }
defer tx.Rollback() defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), now, topic, decodeUID(target)) modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target))
if err != nil { if err != nil {
return err return err
} }
@@ -1194,6 +1194,10 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return tx.Commit() 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, func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid,
version int, now time.Time) error { version int, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil { 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 uid t.Uid
mode t.AccessMode mode t.AccessMode
role string 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 { for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), now, topic, decodeUID(update.uid)) update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid))
if execErr != nil { if execErr != nil {
return execErr return execErr
} }
-134
View File
@@ -512,140 +512,6 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
return a.TenantKafkaDeadLetterGet(id, deadID) 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) { func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
var row struct { var row struct {
ID int64 `db:"id"` 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 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 { if err = conn.SelectContext(ctx, &rows, query); err != nil {
return err 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)
}
+522 -45
View File
@@ -62,6 +62,7 @@ const (
imVerificationResendAfterSeconds = 60 imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5 imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour imVerificationCreateRateWindow = time.Hour
imSensitiveChallengeRateLimitEnabled = false
imActionTokenExpiresInSeconds = 300 imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5 imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300 imIndependentPasswordLockSeconds = 300
@@ -425,12 +426,36 @@ type imMessagesForwardRequest struct {
Mode string `json:"mode"` Mode string `json:"mode"`
Sources []struct { Sources []struct {
Topic string `json:"topic"` Topic string `json:"topic"`
Seq int64 `json:"seq,string"` Seq imForwardSeq `json:"seq"`
} `json:"sources"` } `json:"sources"`
TargetTopics []string `json:"target_topics"` TargetTopics []string `json:"target_topics"`
Comment string `json:"comment,omitempty"` 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 { type imFriendRequestCreateRequest struct {
TargetUserID string `json:"target_user_id"` TargetUserID string `json:"target_user_id"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
@@ -551,6 +576,18 @@ type imGroupMuteBatchRequest struct {
Reason string `json:"reason,omitempty"` 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) { func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) 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) config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date)
if err != nil { if err != nil {
logs.Warn.Println("im attendance config failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
@@ -1911,6 +1949,7 @@ func serveIMAttendanceRecords(wrt http.ResponseWriter, req *http.Request) {
Limit: limit, Limit: limit,
}) })
if err != nil { if err != nil {
logs.Warn.Println("im attendance records failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
@@ -1971,6 +2010,7 @@ func serveIMAttendanceCheckins(wrt http.ResponseWriter, req *http.Request) {
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
}) })
if err != nil { if err != nil {
logs.Warn.Println("im attendance checkin failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
@@ -1997,6 +2037,7 @@ func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) {
Limit: limit, Limit: limit,
}) })
if err != nil { if err != nil {
logs.Warn.Println("im attendance appeals failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
@@ -2039,6 +2080,7 @@ func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) {
AttachmentFileIDs: body.AttachmentFileIDs, AttachmentFileIDs: body.AttachmentFileIDs,
}, idempotencyKey) }, idempotencyKey)
if err != nil { if err != nil {
logs.Warn.Println("im attendance appeal create failed:", principal.TenantID, principal.Uid.UserId(), err)
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
@@ -2364,8 +2406,8 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
"permissions": map[string]any{ "permissions": map[string]any{
"can_create": true, "can_create": true,
"makeup_allowed_days": 7, "makeup_allowed_days": 7,
"can_view_managed": true, "can_view_managed": false,
"can_remind_unsubmitted": true, "can_remind_unsubmitted": false,
}, },
}) })
case http.MethodPost: 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) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return 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) writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return return
} }
@@ -3511,8 +3553,8 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
if mode == "" { if mode == "" {
mode = "separate" mode = "separate"
} }
if mode != "separate" { if mode != "separate" && mode != "bundle" {
writeIMError(wrt, req, http.StatusNotImplemented, 30004, "NOT_IMPLEMENTED", "forward bundle is not implemented", map[string]any{"field": "mode"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "mode"})
return return
} }
if len(body.Sources) == 0 || len(body.Sources) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 { 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)) items := make([]map[string]any, 0, len(body.TargetTopics)*len(body.Sources))
failed := make([]map[string]any, 0) failed := make([]map[string]any, 0)
for _, rawTarget := range body.TargetTopics { for _, rawTarget := range body.TargetTopics {
target := strings.TrimSpace(rawTarget) target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
if target == "" { if target == "" {
for _, source := range body.Sources { 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 continue
} }
@@ -3538,14 +3586,50 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
} }
if targetErr != nil { if targetErr != nil {
for _, source := range body.Sources { 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 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 { for _, source := range body.Sources {
sourceTopic := strings.TrimSpace(source.Topic) sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
if sourceTopic == "" || source.Seq <= 0 { if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, imReasonMalformedRequest)) failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
continue continue
} }
readErr, ok := sourceReadable[sourceTopic] readErr, ok := sourceReadable[sourceTopic]
@@ -3554,20 +3638,20 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
sourceReadable[sourceTopic] = readErr sourceReadable[sourceTopic] = readErr
} }
if readErr != nil { 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 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 { 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 continue
} }
if msg == nil { 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 continue
} }
if messageAvailability(*msg) != "available" { 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 continue
} }
clientMid := "fwd_" + store.Store.GetUidString() clientMid := "fwd_" + store.Store.GetUidString()
@@ -3578,13 +3662,19 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
Head: forwardedMessageHead(msg, clientMid), Head: forwardedMessageHead(msg, clientMid),
Content: forwardedMessageContent(msg, body.Comment), Content: forwardedMessageContent(msg, body.Comment),
} }
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true) if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
if err != nil { failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
failed = append(failed, forwardFailurePayload(sourceTopic, source.Seq, target, relationErrorReason(err)))
continue 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{ 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, "target_topic": target,
"client_mid": clientMid, "client_mid": clientMid,
"seq": saved.SeqId, "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) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return return
} }
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRecallRequest var body imMessageRecallRequest
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 { if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
if 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) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return return
} }
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRevisionRequest var body imMessageRevisionRequest
if !decodeIMJSONBody(wrt, req, &body) { if !decodeIMJSONBody(wrt, req, &body) {
return return
@@ -3996,7 +4088,11 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return 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() { if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return return
@@ -4164,6 +4260,9 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
} }
payload := make([]map[string]any, 0, len(items)) payload := make([]map[string]any, 0, len(items))
for _, item := range items { for _, item := range items {
if item.Friendship.PeerID == principal.Uid {
continue
}
payload = append(payload, friendListItemPayload(item)) payload = append(payload, friendListItemPayload(item))
} }
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
@@ -4581,13 +4680,19 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return return
} }
patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version} 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 _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil {
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 { 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) logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
} }
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
}
dispatchIMGroupEvents(principal.TenantID) dispatchIMGroupEvents(principal.TenantID)
updated, err := store.Topics.Get(principal.TenantID, topic) updated, err := store.Topics.Get(principal.TenantID, topic)
if err != nil { if err != nil {
@@ -4595,9 +4700,11 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return return
} }
payload := groupDetailPayload(updated, selfSub) payload := groupDetailPayload(updated, selfSub)
if settingsUpdated {
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings) payload = mergeGroupSettingsPayload(payload, settings)
} }
}
writeIMSuccess(wrt, req, payload) writeIMSuccess(wrt, req, payload)
default: default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -4665,7 +4772,7 @@ func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topi
} }
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) { 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) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return return
} }
@@ -4673,6 +4780,19 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
if !ok { if !ok {
return 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 var body imGroupMemberProfileUpdateRequest
if !decodeIMJSONBody(wrt, req, &body) { if !decodeIMJSONBody(wrt, req, &body) {
return 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"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
return return
} }
private := groupPrivateMap(selfSub.Private)
currentVersion := intFromAny(private["profile_version"])
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion { if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return return
@@ -4700,6 +4815,12 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
return return
} }
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil { 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 { 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) 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) writeIMRelationError(wrt, req, err)
return 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) dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupInvitationPayload(inv)) writeIMSuccess(wrt, req, groupInvitationPayload(inv))
} }
@@ -5043,6 +5174,16 @@ func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, t
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return 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) dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated)) writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
} }
@@ -5171,6 +5312,21 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return 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) dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub)) writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
default: default:
@@ -5239,7 +5395,7 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail
} }
item["user_id"] = uid.UserId() item["user_id"] = uid.UserId()
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid, 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["ok"] = false
item["reason"] = relationErrorReason(err) item["reason"] = relationErrorReason(err)
} else { } else {
@@ -5383,8 +5539,16 @@ func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic
if !ok { if !ok {
return return
} }
if types.GetTopicCat(topic) != types.TopicCatGrp || memberID.IsZero() { if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) 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 return
} }
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) 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) writeIMRelationError(wrt, req, err)
return 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) dispatchIMBusinessEvents(principal.TenantID)
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status 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) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return 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) { func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
@@ -6180,6 +6364,19 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone") phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
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 { 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) logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed { } else if !allowed {
@@ -6192,17 +6389,6 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
}) })
return 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) challengeID, err := randomIMToken(18)
if err != nil { if err != nil {
@@ -6270,7 +6456,7 @@ func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target s
func defaultIMActionTarget(action, fallback string) string { func defaultIMActionTarget(action, fallback string) string {
switch action { 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" return "youth_mode"
default: default:
return fallback return fallback
@@ -8089,6 +8275,7 @@ func youthModePayload(settings *types.YouthModeSettings) map[string]any {
"enabled": settings.Enabled, "enabled": settings.Enabled,
"policy_version": settings.PolicyVersion, "policy_version": settings.PolicyVersion,
"restrictions": settings.Restrictions, "restrictions": settings.Restrictions,
"restriction_items": youthModeRestrictionItems(settings.Restrictions),
"password_set": settings.PasswordSet, "password_set": settings.PasswordSet,
"can_enable": settings.CanEnable, "can_enable": settings.CanEnable,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
@@ -8099,6 +8286,47 @@ func youthModePayload(settings *types.YouthModeSettings) map[string]any {
return data 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 { func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
return map[string]any{ return map[string]any{
"device_id": token.DeviceID, "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 { func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
head := types.KVMap{} head := types.KVMap{}
if msg != nil { if msg != nil {
@@ -8675,6 +9049,15 @@ func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
return head 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 { func forwardedMessageContent(msg *types.Message, comment string) any {
if strings.TrimSpace(comment) == "" { if strings.TrimSpace(comment) == "" {
if msg == nil { if msg == nil {
@@ -9108,7 +9491,8 @@ func cleanStringList(values []string, limit int) []string {
func isAllowedPublicUploadPurpose(purpose string) bool { func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) { switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback: case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeGroupAvatar:
return true return true
default: default:
return false return false
@@ -9127,6 +9511,10 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool {
return strings.HasPrefix(mimeType, "image/jpeg") || return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") 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: default:
return false return false
} }
@@ -9243,6 +9631,64 @@ func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string
return nil 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 { func systemNotificationIDFromPath(path string) string {
const marker = "/v1/im/system-notifications/" const marker = "/v1/im/system-notifications/"
idx := strings.Index(path, marker) idx := strings.Index(path, marker)
@@ -9567,6 +10013,37 @@ func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.U
return topic, peer 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 { func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() { if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized return normalized
+982 -26
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{ var internalOrgResourceSpecs = map[string]*internalQuerySpec{
"departments": { "departments": {
Select: "SELECT id,department_id,parent_id,name,sort_order,state,created_at,updated_at", 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", DefaultSort: "ORDER BY sort_order ASC,id ASC", From: "FROM im_org_departments d", DefaultSort: "ORDER BY d.sort_order ASC,d.id ASC",
Filters: []internalQueryFilter{{"parent_id", "parent_id", "=", "string"}, {"state", "state", "=", "string"}}, Filters: []internalQueryFilter{{"department_id", "d.department_id", "=", "string"}, {"parent_id", "d.parent_id", "=", "string"}, {"name", "d.name", "LIKE", "string"}, {"state", "d.state", "=", "string"}},
}, },
"members": { "members": {
Select: "SELECT id,department_id,user_id,display_name,title,mobile,email,employment_state,sort_order,created_at,updated_at", 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", DefaultSort: "ORDER BY sort_order ASC,id ASC", 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", "department_id", "=", "string"}, {"user_id", "user_id", "=", "uid"}, {"employment_state", "employment_state", "=", "string"}}, 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" adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
) )
var publishKafkaDeadLetter = publishKafkaEnvelope
type adminCommandPayload struct { type adminCommandPayload struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"` ActorUserID string `json:"actor_user_id"`
@@ -482,6 +484,13 @@ func kafkaTenantID(tenantCode string) (types.TenantID, error) {
return tenant.ID, nil 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 { func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope kafkaEnvelope, consume *types.KafkaConsumeRecord, reason, message string) error {
encoded, err := json.Marshal(envelope) encoded, err := json.Marshal(envelope)
if err != nil { if err != nil {
@@ -512,26 +521,15 @@ func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope
Status: types.KafkaDeadStatusDead, Status: types.KafkaDeadStatusDead,
} }
now := time.Now().UTC().Round(time.Millisecond) 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) deadPayload, err := json.Marshal(dead)
if err != nil { if err != nil {
return err return err
} }
return publishKafkaEnvelope(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{ return publishKafkaDeadLetter(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
EventID: deadID, EventID: deadID,
TraceID: envelope.TraceID, TraceID: envelope.TraceID,
TenantCode: envelope.TenantCode, TenantCode: envelope.TenantCode,
TenantID: tenantID,
SourceService: "im-server", SourceService: "im-server",
TargetService: "hotgo-admin", TargetService: "hotgo-admin",
EventType: "kafka.dead_letter.created", EventType: "kafka.dead_letter.created",
+47
View File
@@ -6,10 +6,57 @@ import (
"strings" "strings"
"time" "time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "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) { 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) now := time.Now().UTC().Round(time.Millisecond)
dead, err := store.HTTPInfrastructure.MarkKafkaDeadLetterReplay(tenantID, deadID, actor.UserId(), note, now) dead, err := store.HTTPInfrastructure.MarkKafkaDeadLetterReplay(tenantID, deadID, actor.UserId(), note, now)
+5 -77
View File
@@ -2,14 +2,11 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
) )
@@ -39,12 +36,8 @@ type kafkaFileLifecyclePayload struct {
OccurredAt time.Time `json:"occurred_at"` OccurredAt time.Time `json:"occurred_at"`
} }
func startKafkaFileLifecycleConsumer() {
startKafkaConsumer("file_lifecycle", handleKafkaFileLifecycle)
}
func enqueueKafkaFileUploaded(ctx context.Context, tenantID types.TenantID, fd *types.FileDef) error { 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 return nil
} }
payload := kafkaFileLifecyclePayload{ 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 { 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 return nil
} }
payload := kafkaFileLifecyclePayload{ 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 { 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 return nil
} }
occurredAt := file.UpdatedAt 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) return enqueueKafkaFileLifecycle(ctx, tenantID, eventType, firstNonEmpty(file.GroupFileID, file.FileID), payload)
} }
func enqueueKafkaFileLifecycle(_ context.Context, tenantID types.TenantID, eventType, bizID string, payload kafkaFileLifecyclePayload) error { func enqueueKafkaFileLifecycle(ctx 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)
}
}
if payload.OccurredAt.IsZero() { if payload.OccurredAt.IsZero() {
payload.OccurredAt = time.Now().UTC().Round(time.Millisecond) payload.OccurredAt = time.Now().UTC().Round(time.Millisecond)
} }
encoded, err := json.Marshal(payload) return executeKafkaFileLifecycle(tenantID, 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))
} }
func executeKafkaFileLifecycle(tenantID types.TenantID, payload kafkaFileLifecyclePayload) error { func executeKafkaFileLifecycle(tenantID types.TenantID, payload kafkaFileLifecyclePayload) error {
+72 -21
View File
@@ -20,36 +20,68 @@ type kafkaMessagePersistPayload struct {
MessageKind string `json:"message_kind"` MessageKind string `json:"message_kind"`
} }
var publishKafkaMessagePersist = publishKafkaEnvelope
func startKafkaMessagePersistConsumer() { func startKafkaMessagePersistConsumer() {
startKafkaConsumer("message_persist", handleKafkaMessagePersist) 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) { func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
if !kafkaEnabled() { if !kafkaEnabled() {
return store.Messages.Save(tenantID, msg, attachmentURLs, readBySender) return store.Messages.Save(tenantID, msg, attachmentURLs, readBySender)
} }
saveResult, err := store.PrepareMessagePersist(tenantID, msg, attachmentURLs, readBySender) if msg == nil || msg.TenantID != tenantID || msg.Topic == "" || msg.SeqId <= 0 {
if err != nil || saveResult == nil || saveResult.Duplicate { return nil, types.ErrMalformed
return saveResult, err
} }
if readBySender && !types.ParseUid(msg.From).IsZero() { clientMsgID, err := store.NormalizeClientMsgID(msg.Head)
saveResult.MarkedReadBySender = true if err != nil {
}
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
return nil, err return nil, err
} }
return saveResult, nil 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(_ context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error { func enqueueKafkaMessagePersist(ctx 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)
}
}
kind := "p2p" kind := "p2p"
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp { if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
kind = "group" 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) eventID := fmt.Sprintf("msg.persist.%d.%s.%d", tenantID, msg.Topic, msg.SeqId)
envelope := kafkaEnvelope{ envelope := kafkaEnvelope{
EventID: eventID, EventID: eventID,
TenantCode: tenantCode, TenantID: tenantID,
SourceService: "im-server", SourceService: "im-server",
TargetService: "im-server", TargetService: "im-server",
EventType: "message.persist." + kind, 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()}, Actor: &kafkaActor{Type: "user", ID: types.ParseUid(msg.From).UserId()},
Payload: payload, 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 { func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode) 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 { if err != nil {
return err return err
} }
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist") }
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, sourceTopicName)
if err != nil || !claimed { if err != nil || !claimed {
return err 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)) 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 { func validateKafkaMessageKind(payload kafkaMessagePersistPayload) error {
if payload.Message.Topic == "" || payload.Message.SeqId <= 0 { if payload.Message.Topic == "" || payload.Message.SeqId <= 0 {
return types.ErrMalformed 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
}
+6 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/segmentio/kafka-go/sasl" "github.com/segmentio/kafka-go/sasl"
"github.com/segmentio/kafka-go/sasl/scram" "github.com/segmentio/kafka-go/sasl/scram"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store/types"
) )
const ( const (
@@ -21,10 +22,9 @@ const (
kafkaTopicConfigEvent = "im.config.event" kafkaTopicConfigEvent = "im.config.event"
kafkaTopicBusinessEvent = "im.business.event" kafkaTopicBusinessEvent = "im.business.event"
kafkaTopicMessagePersist = "im.message.persist" kafkaTopicMessagePersist = "im.message.persist"
kafkaTopicGroupMessagePersist = "im.group.message.persist"
kafkaTopicMessagePush = "im.message.push" kafkaTopicMessagePush = "im.message.push"
kafkaTopicFileSecurity = "im.file.security" kafkaTopicFileSecurity = "im.file.security"
kafkaTopicFileLifecycle = "im.file.lifecycle"
kafkaTopicWorkbenchEvent = "im.workbench.event"
kafkaTopicCacheInvalidate = "im.cache.invalidate" kafkaTopicCacheInvalidate = "im.cache.invalidate"
kafkaTopicAuditEvent = "im.audit.event" kafkaTopicAuditEvent = "im.audit.event"
kafkaTopicDeadLetter = "im.dead.letter" kafkaTopicDeadLetter = "im.dead.letter"
@@ -55,6 +55,7 @@ type kafkaEnvelope struct {
EventID string `json:"event_id"` EventID string `json:"event_id"`
TraceID string `json:"trace_id,omitempty"` TraceID string `json:"trace_id,omitempty"`
TenantCode string `json:"tenant_code,omitempty"` TenantCode string `json:"tenant_code,omitempty"`
TenantID types.TenantID `json:"tenant_id,omitempty"`
SourceService string `json:"source_service,omitempty"` SourceService string `json:"source_service,omitempty"`
TargetService string `json:"target_service,omitempty"` TargetService string `json:"target_service,omitempty"`
EventType string `json:"event_type"` EventType string `json:"event_type"`
@@ -132,6 +133,7 @@ func initKafkaRuntime(raw json.RawMessage) error {
AllowAutoTopicCreation: cfg.AllowAutoTopic, AllowAutoTopicCreation: cfg.AllowAutoTopic,
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond, WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond, ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
MaxAttempts: cfg.MaxRetryCount,
} }
if err := rt.ping(context.Background()); err != nil { if err := rt.ping(context.Background()); err != nil {
@@ -164,7 +166,7 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
cfg.RequiredAcks = int(kafka.RequireOne) cfg.RequiredAcks = int(kafka.RequireOne)
} }
if cfg.MaxRetryCount <= 0 { if cfg.MaxRetryCount <= 0 {
cfg.MaxRetryCount = 5 cfg.MaxRetryCount = 3
} }
if len(cfg.RetryBackoffMS) == 0 { if len(cfg.RetryBackoffMS) == 0 {
cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000} cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000}
@@ -201,10 +203,9 @@ func kafkaTopics(overrides map[string]string) map[string]string {
"config_event": kafkaTopicConfigEvent, "config_event": kafkaTopicConfigEvent,
"business_event": kafkaTopicBusinessEvent, "business_event": kafkaTopicBusinessEvent,
"message_persist": kafkaTopicMessagePersist, "message_persist": kafkaTopicMessagePersist,
"group_message_persist": kafkaTopicGroupMessagePersist,
"message_push": kafkaTopicMessagePush, "message_push": kafkaTopicMessagePush,
"file_security": kafkaTopicFileSecurity, "file_security": kafkaTopicFileSecurity,
"file_lifecycle": kafkaTopicFileLifecycle,
"workbench_event": kafkaTopicWorkbenchEvent,
"cache_invalidate": kafkaTopicCacheInvalidate, "cache_invalidate": kafkaTopicCacheInvalidate,
"audit_event": kafkaTopicAuditEvent, "audit_event": kafkaTopicAuditEvent,
"dead_letter": kafkaTopicDeadLetter, "dead_letter": kafkaTopicDeadLetter,
+18 -60
View File
@@ -5,9 +5,6 @@ import (
"encoding/json" "encoding/json"
"time" "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" "github.com/tinode/chat/server/store/types"
) )
@@ -35,15 +32,10 @@ type kafkaAuditPayload struct {
OccurredAt time.Time `json:"occurred_at"` OccurredAt time.Time `json:"occurred_at"`
} }
func startKafkaWorkbenchEventConsumer() { var publishKafkaAuditEventEnvelope = publishKafkaEnvelope
startKafkaConsumer("workbench_event", handleKafkaWorkbenchEvent)
}
func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, 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 { targets []types.Uid, title, summary string, data map[string]any) error {
if !kafkaEnabled() {
return nil
}
targetUserIDs := make([]string, 0, len(targets)) targetUserIDs := make([]string, 0, len(targets))
for _, target := range targets { for _, target := range targets {
if !target.IsZero() { if !target.IsZero() {
@@ -61,7 +53,14 @@ func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, ac
Data: data, Data: data,
OccurredAt: time.Now().UTC().Round(time.Millisecond), 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, 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, Data: data,
OccurredAt: time.Now().UTC().Round(time.Millisecond), 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) encoded, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return err
} }
eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded)
envelope := kafkaEnvelope{ envelope := kafkaEnvelope{
EventID: eventID, EventID: stableIMEventID("audit_event", tenantID, "audit."+action, resource+":"+resourceID, encoded),
TenantCode: tenantCode, TenantID: tenantID,
SourceService: "im-server", SourceService: "im-server",
TargetService: "im-server", TargetService: "hotgo-admin",
EventType: eventType, EventType: "audit." + action,
BizType: bizType, BizType: "audit",
BizID: bizID, BizID: resource + ":" + resourceID,
OccurredAt: time.Now().UTC(), OccurredAt: payload.OccurredAt,
Payload: encoded, Payload: encoded,
} }
if !actor.IsZero() { if !actor.IsZero() {
envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()} envelope.Actor = &kafkaActor{Type: actorType, ID: actorID}
} }
return enqueueKafkaOutbox(tenantID, topicName, bizID, envelope) return publishKafkaAuditEventEnvelope(ctx, "audit_event", envelope.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))
} }
func executeKafkaWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaWorkbenchPayload) error { 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() startKafkaConfigEventConsumer()
startKafkaCacheInvalidateConsumer() startKafkaCacheInvalidateConsumer()
startKafkaMessagePersistConsumer() startKafkaMessagePersistConsumer()
startKafkaFileLifecycleConsumer() startKafkaDeadLetterConsumer()
startKafkaWorkbenchEventConsumer()
startKafkaOutboxPoller()
stopExternalBusinessEvents := startIMExternalBusinessEventPoller() stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents() defer stopExternalBusinessEvents()
@@ -862,6 +860,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites) mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource) 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/calls", serveIMCalls)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
+3 -4
View File
@@ -95,7 +95,7 @@
"dial_timeout_ms": 2000, "dial_timeout_ms": 2000,
"read_timeout_ms": 2000, "read_timeout_ms": 2000,
"write_timeout_ms": 2000, "write_timeout_ms": 2000,
"pending_ttl_seconds": 120, "pending_ttl_seconds": 300,
"done_ttl_seconds": 86400, "done_ttl_seconds": 86400,
"failed_ttl_seconds": 600, "failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400, "hot_message_ttl_seconds": 86400,
@@ -121,7 +121,7 @@
"sasl_mechanism": "SCRAM-SHA-512", "sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external", "sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2", "sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 5, "max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000], "retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"dead_letter_retention_seconds": 7776000, "dead_letter_retention_seconds": 7776000,
"topics": { "topics": {
@@ -130,10 +130,9 @@
"config_event": "im.config.event", "config_event": "im.config.event",
"business_event": "im.business.event", "business_event": "im.business.event",
"message_persist": "im.message.persist", "message_persist": "im.message.persist",
"group_message_persist": "im.group.message.persist",
"message_push": "im.message.push", "message_push": "im.message.push",
"file_security": "im.file.security", "file_security": "im.file.security",
"file_lifecycle": "im.file.lifecycle",
"workbench_event": "im.workbench.event",
"cache_invalidate": "im.cache.invalidate", "cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event", "audit_event": "im.audit.event",
"dead_letter": "im.dead.letter" "dead_letter": "im.dead.letter"
+72 -2
View File
@@ -39,6 +39,11 @@ type groupMessageUniqueRecord struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type MessageUniqueClaim struct {
Status string
Seq int64
}
type groupFanoutEnvelope struct { type groupFanoutEnvelope struct {
OriginNodeID string `json:"origin_node_id"` OriginNodeID string `json:"origin_node_id"`
Message json.RawMessage `json:"message"` 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 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 MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
CacheHotMessage(tenantID types.TenantID, msg *types.Message) 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) 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 InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
@@ -75,6 +81,39 @@ type groupMessageRuntime interface {
var groupMsgRuntime groupMessageRuntime 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 { type UserPresence struct {
Online bool `json:"online"` Online bool `json:"online"`
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
@@ -187,6 +226,27 @@ func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id) 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 { func InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" { if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil return nil
@@ -239,7 +299,7 @@ func initGroupMessageRuntime(raw json.RawMessage) error {
cfg.KeyPrefix = "im" cfg.KeyPrefix = "im"
} }
if cfg.PendingTTL <= 0 { if cfg.PendingTTL <= 0 {
cfg.PendingTTL = 120 cfg.PendingTTL = 300
} }
if cfg.DoneTTL <= 0 { if cfg.DoneTTL <= 0 {
cfg.DoneTTL = 86400 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 { 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 { if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return types.ErrMalformed return types.ErrMalformed
} }
if ttl <= 0 {
return types.ErrMalformed
}
payload, err := json.Marshal(msg) payload, err := json.Marshal(msg)
if err != nil { if err != nil {
return err return err
} }
ctx := context.Background() ctx := context.Background()
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
seq := strconv.FormatInt(msg.SeqId, 10) seq := strconv.FormatInt(msg.SeqId, 10)
pipe := r.client.Pipeline() pipe := r.client.Pipeline()
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl) 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) GetKafkaDeadLetter(types.TenantID, string) (*types.KafkaDeadLetter, error)
MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error
MarkKafkaDeadLetterReplay(types.TenantID, string, string, string, time.Time) (*types.KafkaDeadLetter, 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{} type httpInfrastructureMapper struct{}
@@ -155,35 +151,3 @@ func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantI
} }
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now) 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) 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 { 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}) latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
if err != nil { if err != nil {
@@ -3570,7 +3574,7 @@ func validIMMessageHeaderToken(value string, max int) bool {
func validIMMessageType(value string) bool { func validIMMessageType(value string) bool {
switch strings.TrimSpace(value) { switch strings.TrimSpace(value) {
case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card", 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 return true
default: default:
return false 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": json.Number("1"), "x-im-type": "voice"},
{"x-im-schema": "1", "x-im-type": "contact_card"}, {"x-im-schema": "1", "x-im-type": "contact_card"},
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"}, {"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"}, {"type": "system"},
} }
for _, head := range valid { for _, head := range valid {
-24
View File
@@ -23,10 +23,6 @@ const (
KafkaDeadStatusReplayed = "replayed" KafkaDeadStatusReplayed = "replayed"
KafkaDeadStatusIgnored = "ignored" KafkaDeadStatusIgnored = "ignored"
KafkaDeadStatusResolved = "resolved" KafkaDeadStatusResolved = "resolved"
KafkaOutboxPending = "pending"
KafkaOutboxSent = "sent"
KafkaOutboxFailed = "failed"
) )
// HTTPIdempotencyRecord stores the first response for one authenticated write. // HTTPIdempotencyRecord stores the first response for one authenticated write.
@@ -115,26 +111,6 @@ type KafkaDeadLetterPage struct {
NextCursor string 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. // StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string { func StableEventID(parts ...any) string {
hash := sha256.New() hash := sha256.New()
+1
View File
@@ -5,6 +5,7 @@ import "time"
const ( const (
PublicUploadPurposeEnterpriseLicense = "enterprise_license" PublicUploadPurposeEnterpriseLicense = "enterprise_license"
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback" PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
PublicUploadPurposeGroupAvatar = "group_avatar"
TenantApplicationStatusReceived = "received" TenantApplicationStatusReceived = "received"
TenantEntryFeedbackReceived = "received" TenantEntryFeedbackReceived = "received"
+3 -4
View File
@@ -95,7 +95,7 @@
"dial_timeout_ms": 2000, "dial_timeout_ms": 2000,
"read_timeout_ms": 2000, "read_timeout_ms": 2000,
"write_timeout_ms": 2000, "write_timeout_ms": 2000,
"pending_ttl_seconds": 120, "pending_ttl_seconds": 300,
"done_ttl_seconds": 86400, "done_ttl_seconds": 86400,
"failed_ttl_seconds": 600, "failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400, "hot_message_ttl_seconds": 86400,
@@ -121,7 +121,7 @@
"sasl_mechanism": "SCRAM-SHA-512", "sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external", "sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2", "sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 5, "max_retry_count": 3,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000], "retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"dead_letter_retention_seconds": 7776000, "dead_letter_retention_seconds": 7776000,
"topics": { "topics": {
@@ -130,10 +130,9 @@
"config_event": "im.config.event", "config_event": "im.config.event",
"business_event": "im.business.event", "business_event": "im.business.event",
"message_persist": "im.message.persist", "message_persist": "im.message.persist",
"group_message_persist": "im.group.message.persist",
"message_push": "im.message.push", "message_push": "im.message.push",
"file_security": "im.file.security", "file_security": "im.file.security",
"file_lifecycle": "im.file.lifecycle",
"workbench_event": "im.workbench.event",
"cache_invalidate": "im.cache.invalidate", "cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event", "audit_event": "im.audit.event",
"dead_letter": "im.dead.letter" "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 { if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
legacyType, _ := head["type"].(string) legacyType, _ := head["type"].(string)
productType, _ := head["x-im-type"].(string) productType, _ := head["x-im-type"].(string)
if legacyType == "system" || legacyType == "system_event" || if isSystemGeneratedMessageType(legacyType) || isSystemGeneratedMessageType(productType) {
productType == "system" || productType == "system_event" {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied return types.ErrPermissionDenied
} }
@@ -1190,6 +1189,15 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
return nil 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. // handlePubBroadcast fans out {pub} -> {data} messages to recipients in a master topic.
// This is a NON-proxy broadcast. // This is a NON-proxy broadcast.
func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { 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 count := 0
if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() { if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() {
// Read messages from DB // 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 { if err != nil {
sess.queueOut(ErrUnknownReply(msg, now)) sess.queueOut(ErrUnknownReply(msg, now))
return err 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}. // Push the list of messages to the client as {data}.
if messages != nil { if messages != nil {
@@ -3024,6 +3041,56 @@ func (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *M
return nil 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. // replyGetTags returns topics' tags - tokens used for discovery.
func (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error { func (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error {
now := types.TimeNow() now := types.TimeNow()
+198 -1
View File
@@ -1,6 +1,8 @@
package main package main
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -9,6 +11,7 @@ import (
"time" "time"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
@@ -789,6 +792,8 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
} }
func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) { func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
for _, msgType := range []string{"system_event", "system_notice", "system_action"} {
t.Run(msgType, func(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
@@ -800,7 +805,7 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
AsUser: from, AsUser: from,
Pub: &MsgClientPub{ Pub: &MsgClientPub{
Topic: "p2p", Topic: "p2p",
Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"}, Head: map[string]any{"x-im-schema": float64(1), "x-im-type": msgType},
Content: "test", Content: "test",
}, },
sess: helper.sessions[0], sess: helper.sessions[0],
@@ -828,6 +833,8 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
if len(helper.hubMessages) != 0 { if len(helper.hubMessages) != 0 {
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
} }
})
}
} }
func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) { func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
@@ -897,6 +904,196 @@ func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, 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) { func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}
+4
View File
@@ -223,6 +223,8 @@ type testSecurityStore struct {
realName *types.RealNameVerification realName *types.RealNameVerification
realNameAppeal *types.RealNameAppeal realNameAppeal *types.RealNameAppeal
actionToken *types.ActionToken actionToken *types.ActionToken
consumedAction string
consumedTarget string
independentPassword *types.IndependentPassword independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings youthMode *types.YouthModeSettings
attempts int 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) { 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 { if s.err != nil {
return nil, s.err return nil, s.err
} }