Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7193ba2316 | ||
|
|
db00e994d5 | ||
|
|
a0bd0b0649 | ||
|
|
256f768d15 | ||
|
|
871e0ba735 | ||
|
|
2e4e1f0df3 | ||
|
|
54e58763ff | ||
|
|
1eee0179f1 | ||
|
|
8193491686 | ||
|
|
a94f60c254 | ||
|
|
79947a2c6f | ||
|
|
7f6995d8fe | ||
|
|
ac45223e39 | ||
|
|
15a4174ed1 | ||
|
|
dc5e09f2ae | ||
|
|
318ce04f7d | ||
|
|
f8a286cec2 | ||
|
|
929f847003 | ||
|
|
18237cf3ec | ||
|
|
edced2675b | ||
|
|
39e7e019c7 | ||
|
|
6c6aa9f4c1 | ||
|
|
44ad626f63 | ||
|
|
61ab313f35 | ||
|
|
79630c26f5 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 '{}'
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
@@ -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. 文件安全审核任务表
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
rtctokenbuilder "github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src/rtctokenbuilder2"
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
imReasonAgoraDisabled = "AGORA_DISABLED"
|
||||||
|
imReasonAgoraConfigMissing = "AGORA_CONFIG_MISSING"
|
||||||
|
imReasonCallNotFound = "CALL_NOT_FOUND"
|
||||||
|
imReasonCallNotAllowed = "CALL_NOT_ALLOWED"
|
||||||
|
imReasonCallStateInvalid = "CALL_STATE_INVALID"
|
||||||
|
)
|
||||||
|
|
||||||
|
type imCallRTCJoinRequest struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
CallType string `json:"call_type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type imAgoraConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
AuthMode string
|
||||||
|
AppID string
|
||||||
|
AppCertificate string
|
||||||
|
TokenTTLSeconds int
|
||||||
|
ChannelPrefix string
|
||||||
|
UIDMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveIMCallRTCJoin(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body imCallRTCJoinRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(wrt, req.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Topic = strings.TrimSpace(body.Topic)
|
||||||
|
body.CallID = strings.TrimSpace(body.CallID)
|
||||||
|
body.CallType = strings.ToLower(strings.TrimSpace(body.CallType))
|
||||||
|
body.Role = strings.ToLower(strings.TrimSpace(body.Role))
|
||||||
|
if body.Topic == "" || body.CallID == "" || body.Seq <= 0 ||
|
||||||
|
(body.CallType != constCallTypeAudio && body.CallType != constCallTypeVideo) ||
|
||||||
|
(body.Role != "caller" && body.Role != "callee") {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalizedTopic, _ := normalizeP2PConversationTopic(body.Topic, principal.Uid)
|
||||||
|
if normalizedTopic == "" {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Topic = normalizedTopic
|
||||||
|
config, err := loadIMAgoraConfig()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, types.ErrNotFound) || errors.Is(err, types.ErrMalformed) {
|
||||||
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !config.Enabled {
|
||||||
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30061, imReasonAgoraDisabled, "agora disabled", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, err := store.Subs.Get(principal.TenantID, body.Topic, principal.Uid, false)
|
||||||
|
if err != nil {
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
|
||||||
|
writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, err := findCallRecordForRTCJoin(principal.TenantID, principal.Uid, body)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, types.ErrNotFound):
|
||||||
|
writeIMError(wrt, req, http.StatusNotFound, 30063, imReasonCallNotFound, "call not found", nil)
|
||||||
|
case errors.Is(err, types.ErrPermissionDenied):
|
||||||
|
writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil)
|
||||||
|
default:
|
||||||
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !callStateAllowsRTCJoin(record.Status) {
|
||||||
|
writeIMError(wrt, req, http.StatusConflict, 30064, imReasonCallStateInvalid, "call state invalid", map[string]any{"status": record.Status})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channelName := config.ChannelPrefix + record.CallID
|
||||||
|
uid, err := agoraUIDForUser(config, principal.Uid)
|
||||||
|
if err != nil {
|
||||||
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := ""
|
||||||
|
var expiresAt any
|
||||||
|
if config.AuthMode == "token" {
|
||||||
|
token, expiresAt, err = buildAgoraRTCToken(config, channelName, principal.Uid)
|
||||||
|
if err != nil {
|
||||||
|
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora rtc token generation failed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeIMSuccess(wrt, req, map[string]any{
|
||||||
|
"provider": "agora",
|
||||||
|
"auth_mode": config.AuthMode,
|
||||||
|
"app_id": config.AppID,
|
||||||
|
"channel_name": channelName,
|
||||||
|
"uid": uid,
|
||||||
|
"token": token,
|
||||||
|
"token_expires_at": expiresAt,
|
||||||
|
"call_id": record.CallID,
|
||||||
|
"topic": record.Topic,
|
||||||
|
"seq": record.SeqID,
|
||||||
|
"call_type": record.CallType,
|
||||||
|
"role": body.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadIMAgoraConfig() (imAgoraConfig, error) {
|
||||||
|
values, err := store.ThirdPartyConfig.Get("im_shengwang",
|
||||||
|
"agora_enabled",
|
||||||
|
"agora_auth_mode",
|
||||||
|
"agora_app_id",
|
||||||
|
"agora_app_certificate",
|
||||||
|
"agora_token_ttl_seconds",
|
||||||
|
"agora_channel_prefix",
|
||||||
|
"agora_uid_mode",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return imAgoraConfig{}, err
|
||||||
|
}
|
||||||
|
config := imAgoraConfig{
|
||||||
|
Enabled: imTruthy(values["agora_enabled"]),
|
||||||
|
AuthMode: strings.ToLower(strings.TrimSpace(values["agora_auth_mode"])),
|
||||||
|
AppID: strings.TrimSpace(values["agora_app_id"]),
|
||||||
|
AppCertificate: strings.TrimSpace(values["agora_app_certificate"]),
|
||||||
|
TokenTTLSeconds: 3600,
|
||||||
|
ChannelPrefix: strings.TrimSpace(values["agora_channel_prefix"]),
|
||||||
|
UIDMode: strings.ToLower(strings.TrimSpace(values["agora_uid_mode"])),
|
||||||
|
}
|
||||||
|
if config.AuthMode == "" {
|
||||||
|
config.AuthMode = "app_id"
|
||||||
|
}
|
||||||
|
if config.ChannelPrefix == "" {
|
||||||
|
config.ChannelPrefix = "call_"
|
||||||
|
}
|
||||||
|
if config.UIDMode == "" {
|
||||||
|
config.UIDMode = "string"
|
||||||
|
}
|
||||||
|
if ttl := strings.TrimSpace(values["agora_token_ttl_seconds"]); ttl != "" {
|
||||||
|
parsed, err := strconv.Atoi(ttl)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return imAgoraConfig{}, types.ErrMalformed
|
||||||
|
}
|
||||||
|
config.TokenTTLSeconds = parsed
|
||||||
|
}
|
||||||
|
if !config.Enabled {
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
if config.AppID == "" || (config.AuthMode != "app_id" && config.AuthMode != "token") {
|
||||||
|
return imAgoraConfig{}, types.ErrMalformed
|
||||||
|
}
|
||||||
|
if config.AuthMode == "token" && config.AppCertificate == "" {
|
||||||
|
return imAgoraConfig{}, types.ErrMalformed
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) {
|
||||||
|
if store.Calls == nil {
|
||||||
|
return nil, types.ErrUnsupported
|
||||||
|
}
|
||||||
|
records, _, err := store.Calls.List(tenantID, uid, body.Topic, "", 100)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range records {
|
||||||
|
record := &records[i]
|
||||||
|
if record.CallID != body.CallID || record.Topic != body.Topic || record.SeqID != body.Seq || record.CallType != body.CallType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.CallerID != uid && record.CalleeID != uid {
|
||||||
|
return nil, types.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if body.Role == "caller" && record.CallerID != uid {
|
||||||
|
return nil, types.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if body.Role == "callee" && record.CalleeID != uid {
|
||||||
|
return nil, types.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
return nil, types.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func callStateAllowsRTCJoin(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case types.CallStatusStarted, types.CallStatusAccepted:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agoraUIDForUser(config imAgoraConfig, uid types.Uid) (any, error) {
|
||||||
|
switch config.UIDMode {
|
||||||
|
case "int":
|
||||||
|
decoded := store.DecodeUid(uid)
|
||||||
|
if decoded <= 0 || decoded > math.MaxUint32 {
|
||||||
|
return nil, types.ErrMalformed
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
case "string":
|
||||||
|
return uid.UserId(), nil
|
||||||
|
default:
|
||||||
|
return nil, types.ErrMalformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgoraRTCToken(config imAgoraConfig, channelName string, uid types.Uid) (string, string, error) {
|
||||||
|
ttl := uint32(config.TokenTTLSeconds)
|
||||||
|
if config.UIDMode == "int" {
|
||||||
|
decoded := store.DecodeUid(uid)
|
||||||
|
if decoded <= 0 || decoded > math.MaxUint32 {
|
||||||
|
return "", "", types.ErrMalformed
|
||||||
|
}
|
||||||
|
token, err := rtctokenbuilder.BuildTokenWithUid(config.AppID, config.AppCertificate, channelName, uint32(decoded), rtctokenbuilder.RolePublisher, ttl, ttl)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil
|
||||||
|
}
|
||||||
|
account := uid.UserId()
|
||||||
|
if account == "" {
|
||||||
|
return "", "", types.ErrMalformed
|
||||||
|
}
|
||||||
|
token, err := rtctokenbuilder.BuildTokenWithUserAccount(config.AppID, config.AppCertificate, channelName, account, rtctokenbuilder.RolePublisher, ttl, ttl)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func imTruthy(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agoraTokenExpiresAt(ttlSeconds int) string {
|
||||||
|
return time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
+559
-82
@@ -57,19 +57,20 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
imVerificationCodeLength = 6
|
imVerificationCodeLength = 6
|
||||||
imVerificationExpiresInSeconds = 300
|
imVerificationExpiresInSeconds = 300
|
||||||
imVerificationResendAfterSeconds = 60
|
imVerificationResendAfterSeconds = 60
|
||||||
imVerificationMaxAttempts = 5
|
imVerificationMaxAttempts = 5
|
||||||
imVerificationCreateRateWindow = time.Hour
|
imVerificationCreateRateWindow = time.Hour
|
||||||
imActionTokenExpiresInSeconds = 300
|
imSensitiveChallengeRateLimitEnabled = false
|
||||||
imIndependentPasswordMaxAttempts = 5
|
imActionTokenExpiresInSeconds = 300
|
||||||
imIndependentPasswordLockSeconds = 300
|
imIndependentPasswordMaxAttempts = 5
|
||||||
imQRCodeDefaultExpiresInSeconds = 86400
|
imIndependentPasswordLockSeconds = 300
|
||||||
imQRCodeMaxExpiresInSeconds = 7 * 86400
|
imQRCodeDefaultExpiresInSeconds = 86400
|
||||||
imPublicUploadMaxBytes = 10 << 20
|
imQRCodeMaxExpiresInSeconds = 7 * 86400
|
||||||
groupMaxAdminCount = 10
|
imPublicUploadMaxBytes = 10 << 20
|
||||||
reportPreviewMaxRunes = 120
|
groupMaxAdminCount = 10
|
||||||
|
reportPreviewMaxRunes = 120
|
||||||
)
|
)
|
||||||
|
|
||||||
type imHTTPResponse struct {
|
type imHTTPResponse struct {
|
||||||
@@ -424,13 +425,37 @@ type imFavoritesForwardRequest struct {
|
|||||||
type imMessagesForwardRequest struct {
|
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,12 +4680,18 @@ 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 rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
|
if errors.Is(err, types.ErrNotFound) {
|
||||||
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
|
settingsUpdated = false
|
||||||
|
logs.Warn.Printf("group[%s]: business settings missing while patching group profile; kept topic public update", topic)
|
||||||
|
} else {
|
||||||
|
if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
|
||||||
|
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
|
||||||
|
}
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeIMRelationError(wrt, req, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
dispatchIMGroupEvents(principal.TenantID)
|
dispatchIMGroupEvents(principal.TenantID)
|
||||||
updated, err := store.Topics.Get(principal.TenantID, topic)
|
updated, err := store.Topics.Get(principal.TenantID, topic)
|
||||||
@@ -4595,8 +4700,10 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload := groupDetailPayload(updated, selfSub)
|
payload := groupDetailPayload(updated, selfSub)
|
||||||
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
if settingsUpdated {
|
||||||
payload = mergeGroupSettingsPayload(payload, settings)
|
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
|
||||||
|
payload = mergeGroupSettingsPayload(payload, settings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeIMSuccess(wrt, req, payload)
|
writeIMSuccess(wrt, req, payload)
|
||||||
default:
|
default:
|
||||||
@@ -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,29 +6364,31 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
|
|||||||
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
|
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 allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
if imSensitiveChallengeRateLimitEnabled {
|
||||||
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
|
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
|
||||||
} else if !allowed {
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||||
retryAfter := int(ttl.Seconds())
|
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||||
if retryAfter < 1 {
|
return
|
||||||
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
}
|
||||||
|
if active != nil && now.Before(active.ResendAfter) {
|
||||||
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
||||||
|
"challenge_id": active.ChallengeID,
|
||||||
|
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
|
||||||
|
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
|
||||||
|
} else if !allowed {
|
||||||
|
retryAfter := int(ttl.Seconds())
|
||||||
|
if retryAfter < 1 {
|
||||||
|
retryAfter = int(imVerificationCreateRateWindow.Seconds())
|
||||||
|
}
|
||||||
|
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
||||||
|
"retry_after_seconds": retryAfter,
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
||||||
"retry_after_seconds": retryAfter,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
|
|
||||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
|
||||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if active != nil && now.Before(active.ResendAfter) {
|
|
||||||
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
|
|
||||||
"challenge_id": active.ChallengeID,
|
|
||||||
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
challengeID, err := randomIMToken(18)
|
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
|
||||||
@@ -8086,12 +8272,13 @@ func appReleasePayload(release *types.AppRelease) map[string]any {
|
|||||||
|
|
||||||
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
|
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"enabled": settings.Enabled,
|
"enabled": settings.Enabled,
|
||||||
"policy_version": settings.PolicyVersion,
|
"policy_version": settings.PolicyVersion,
|
||||||
"restrictions": settings.Restrictions,
|
"restrictions": settings.Restrictions,
|
||||||
"password_set": settings.PasswordSet,
|
"restriction_items": youthModeRestrictionItems(settings.Restrictions),
|
||||||
"can_enable": settings.CanEnable,
|
"password_set": settings.PasswordSet,
|
||||||
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
"can_enable": settings.CanEnable,
|
||||||
|
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
}
|
}
|
||||||
if settings.EnabledAt != nil {
|
if settings.EnabledAt != nil {
|
||||||
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
|
data["enabled_at"] = settings.EnabledAt.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
|
||||||
|
|||||||
+993
-37
File diff suppressed because it is too large
Load Diff
@@ -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"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
|
claim, claimed, runtimeReady, claimErr := store.ClaimMessageUnique(tenantID, msg.Topic, fromUid, clientMsgID)
|
||||||
tenantCode := ""
|
if claimErr != nil {
|
||||||
if store.Tenants != nil {
|
return nil, claimErr
|
||||||
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
|
}
|
||||||
tenantCode = tenant.Code
|
if runtimeReady && !claimed {
|
||||||
} else {
|
if claim != nil && claim.Seq > 0 {
|
||||||
logs.Warn.Printf("kafka message persist: failed to resolve tenant code for tenant %d: %v", tenantID, err)
|
msg.SeqId = claim.Seq
|
||||||
|
return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: true}, nil
|
||||||
|
}
|
||||||
|
return nil, types.ErrDuplicate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if seq, runtimeReady, seqErr := store.ReserveMessageSeq(tenantID, msg.Topic, msg.SeqId-1); seqErr != nil {
|
||||||
|
if clientMsgID != "" {
|
||||||
|
_ = store.MarkMessageUniqueFailed(tenantID, msg.Topic, fromUid, clientMsgID)
|
||||||
|
}
|
||||||
|
return nil, seqErr
|
||||||
|
} else if runtimeReady {
|
||||||
|
msg.SeqId = seq
|
||||||
|
}
|
||||||
|
msg.InitTimes()
|
||||||
|
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
|
||||||
|
if clientMsgID != "" {
|
||||||
|
_ = store.MarkMessageUniqueFailed(tenantID, msg.Topic, fromUid, clientMsgID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if clientMsgID != "" {
|
||||||
|
if err = store.MarkMessageUniqueDone(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId, ""); err != nil {
|
||||||
|
logs.Warn.Printf("kafka message persist: failed to mark message unique key done topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
|
||||||
|
if cacheErr := store.CachePendingHotMessage(tenantID, msg); cacheErr != nil {
|
||||||
|
logs.Warn.Printf("kafka message persist: failed to cache pending hot message after publish topic=%s seq=%d: %v", msg.Topic, msg.SeqId, cacheErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: readBySender && !fromUid.IsZero()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
|
||||||
kind := "p2p"
|
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")
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
|
func handleKafkaGroupMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
||||||
|
return handleKafkaMessagePersistFromTopic(ctx, envelope, "group_message_persist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleKafkaMessagePersistFromTopic(ctx context.Context, envelope kafkaEnvelope, sourceTopicName string) error {
|
||||||
|
tenantID := envelope.TenantID
|
||||||
|
var err error
|
||||||
|
if tenantID.IsZero() {
|
||||||
|
tenantID, err = kafkaTenantID(envelope.TenantCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist")
|
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, sourceTopicName)
|
||||||
if err != nil || !claimed {
|
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
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/segmentio/kafka-go"
|
|
||||||
"github.com/tinode/chat/server/logs"
|
|
||||||
"github.com/tinode/chat/server/store"
|
|
||||||
"github.com/tinode/chat/server/store/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
kafkaOutboxPollInterval = 2 * time.Second
|
|
||||||
kafkaOutboxTenantLimit = 1000
|
|
||||||
kafkaOutboxBatchLimit = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
func startKafkaOutboxPoller() {
|
|
||||||
if !kafkaEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
kafkaBus.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer kafkaBus.wg.Done()
|
|
||||||
ticker := time.NewTicker(kafkaOutboxPollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-kafkaBus.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
drainKafkaOutbox(kafkaBus.ctx)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-kafkaBus.ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueueKafkaOutbox(tenantID types.TenantID, topicName, key string, envelope kafkaEnvelope) error {
|
|
||||||
if envelope.OccurredAt.IsZero() {
|
|
||||||
envelope.OccurredAt = time.Now().UTC()
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(envelope)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return store.HTTPInfrastructure.CreateKafkaOutbox(tenantID, &types.KafkaOutboxRecord{
|
|
||||||
EventID: envelope.EventID,
|
|
||||||
Topic: topicName,
|
|
||||||
EventType: envelope.EventType,
|
|
||||||
BizType: envelope.BizType,
|
|
||||||
BizID: envelope.BizID,
|
|
||||||
Key: key,
|
|
||||||
Envelope: raw,
|
|
||||||
Status: types.KafkaOutboxPending,
|
|
||||||
NextRetryAt: nil,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func drainKafkaOutbox(ctx context.Context) {
|
|
||||||
if store.Tenants == nil || store.HTTPInfrastructure == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tenants, err := store.Tenants.ListActive(kafkaOutboxTenantLimit)
|
|
||||||
if err != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: list active tenants: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
|
||||||
for _, tenant := range tenants {
|
|
||||||
if tenant.ID.IsZero() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
records, err := store.HTTPInfrastructure.ListPendingKafkaOutbox(tenant.ID, kafkaOutboxBatchLimit, now)
|
|
||||||
if err != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: list tenant=%d: %v", tenant.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, record := range records {
|
|
||||||
if err = publishKafkaOutboxRecord(ctx, record); err != nil {
|
|
||||||
nextRetryAt := now.Add(kafkaOutboxRetryDelay(record.Attempts + 1))
|
|
||||||
if markErr := store.HTTPInfrastructure.MarkKafkaOutboxFailed(tenant.ID, record.EventID,
|
|
||||||
"KAFKA_PUBLISH_FAILED", err.Error(), nextRetryAt, now); markErr != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: mark failed event=%s tenant=%d: %v", record.EventID, tenant.ID, markErr)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err = store.HTTPInfrastructure.MarkKafkaOutboxSent(tenant.ID, record.EventID, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: mark sent event=%s tenant=%d: %v", record.EventID, tenant.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishKafkaOutboxRecord(ctx context.Context, record types.KafkaOutboxRecord) error {
|
|
||||||
topic := kafkaTopic(record.Topic)
|
|
||||||
if topic == "" {
|
|
||||||
topic = record.Topic
|
|
||||||
}
|
|
||||||
return kafkaBus.writer.WriteMessages(ctx, kafka.Message{
|
|
||||||
Topic: topic,
|
|
||||||
Key: []byte(record.Key),
|
|
||||||
Value: record.Envelope,
|
|
||||||
Time: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func kafkaOutboxRetryDelay(attempt int) time.Duration {
|
|
||||||
if attempt <= 0 {
|
|
||||||
attempt = 1
|
|
||||||
}
|
|
||||||
if kafkaBus == nil || len(kafkaBus.config.RetryBackoffMS) == 0 {
|
|
||||||
return time.Duration(attempt) * 10 * time.Second
|
|
||||||
}
|
|
||||||
index := attempt - 1
|
|
||||||
if index >= len(kafkaBus.config.RetryBackoffMS) {
|
|
||||||
index = len(kafkaBus.config.RetryBackoffMS) - 1
|
|
||||||
}
|
|
||||||
return time.Duration(kafkaBus.config.RetryBackoffMS[index]) * time.Millisecond
|
|
||||||
}
|
|
||||||
+26
-25
@@ -13,21 +13,21 @@ 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 (
|
||||||
kafkaTopicAdminCommand = "im.admin.command"
|
kafkaTopicAdminCommand = "im.admin.command"
|
||||||
kafkaTopicAdminCommandResult = "im.admin.command.result"
|
kafkaTopicAdminCommandResult = "im.admin.command.result"
|
||||||
kafkaTopicConfigEvent = "im.config.event"
|
kafkaTopicConfigEvent = "im.config.event"
|
||||||
kafkaTopicBusinessEvent = "im.business.event"
|
kafkaTopicBusinessEvent = "im.business.event"
|
||||||
kafkaTopicMessagePersist = "im.message.persist"
|
kafkaTopicMessagePersist = "im.message.persist"
|
||||||
kafkaTopicMessagePush = "im.message.push"
|
kafkaTopicGroupMessagePersist = "im.group.message.persist"
|
||||||
kafkaTopicFileSecurity = "im.file.security"
|
kafkaTopicMessagePush = "im.message.push"
|
||||||
kafkaTopicFileLifecycle = "im.file.lifecycle"
|
kafkaTopicFileSecurity = "im.file.security"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type kafkaConfig struct {
|
type kafkaConfig struct {
|
||||||
@@ -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}
|
||||||
@@ -196,18 +198,17 @@ func kafkaSASLMechanism(cfg kafkaConfig) (sasl.Mechanism, error) {
|
|||||||
|
|
||||||
func kafkaTopics(overrides map[string]string) map[string]string {
|
func kafkaTopics(overrides map[string]string) map[string]string {
|
||||||
topics := map[string]string{
|
topics := map[string]string{
|
||||||
"admin_command": kafkaTopicAdminCommand,
|
"admin_command": kafkaTopicAdminCommand,
|
||||||
"admin_command_result": kafkaTopicAdminCommandResult,
|
"admin_command_result": kafkaTopicAdminCommandResult,
|
||||||
"config_event": kafkaTopicConfigEvent,
|
"config_event": kafkaTopicConfigEvent,
|
||||||
"business_event": kafkaTopicBusinessEvent,
|
"business_event": kafkaTopicBusinessEvent,
|
||||||
"message_persist": kafkaTopicMessagePersist,
|
"message_persist": kafkaTopicMessagePersist,
|
||||||
"message_push": kafkaTopicMessagePush,
|
"group_message_persist": kafkaTopicGroupMessagePersist,
|
||||||
"file_security": kafkaTopicFileSecurity,
|
"message_push": kafkaTopicMessagePush,
|
||||||
"file_lifecycle": kafkaTopicFileLifecycle,
|
"file_security": kafkaTopicFileSecurity,
|
||||||
"workbench_event": kafkaTopicWorkbenchEvent,
|
"cache_invalidate": kafkaTopicCacheInvalidate,
|
||||||
"cache_invalidate": kafkaTopicCacheInvalidate,
|
"audit_event": kafkaTopicAuditEvent,
|
||||||
"audit_event": kafkaTopicAuditEvent,
|
"dead_letter": kafkaTopicDeadLetter,
|
||||||
"dead_letter": kafkaTopicDeadLetter,
|
|
||||||
}
|
}
|
||||||
for key, value := range overrides {
|
for key, value := range overrides {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
+232
-35
@@ -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,44 +792,48 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
|
func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
|
||||||
numUsers := 2
|
for _, msgType := range []string{"system_event", "system_notice", "system_action"} {
|
||||||
helper := TopicTestHelper{}
|
t.Run(msgType, func(t *testing.T) {
|
||||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
|
numUsers := 2
|
||||||
defer helper.tearDown()
|
helper := TopicTestHelper{}
|
||||||
|
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
|
||||||
|
defer helper.tearDown()
|
||||||
|
|
||||||
from := helper.uids[0].UserId()
|
from := helper.uids[0].UserId()
|
||||||
msg := &ClientComMessage{
|
msg := &ClientComMessage{
|
||||||
Id: "sys-1",
|
Id: "sys-1",
|
||||||
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],
|
||||||
}
|
}
|
||||||
|
|
||||||
helper.topic.handleClientMsg(msg)
|
helper.topic.handleClientMsg(msg)
|
||||||
helper.finish()
|
helper.finish()
|
||||||
|
|
||||||
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
|
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
|
||||||
t.Fatal(errorMsgs[0].Ctrl.Text)
|
t.Fatal(errorMsgs[0].Ctrl.Text)
|
||||||
}
|
}
|
||||||
if helper.topic.lastID != 0 {
|
if helper.topic.lastID != 0 {
|
||||||
t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID)
|
t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID)
|
||||||
}
|
}
|
||||||
if len(helper.results[0].messages) != 1 {
|
if len(helper.results[0].messages) != 1 {
|
||||||
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
|
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
|
||||||
}
|
}
|
||||||
resp := helper.results[0].messages[0].(*ServerComMessage)
|
resp := helper.results[0].messages[0].(*ServerComMessage)
|
||||||
if resp.Ctrl == nil || resp.Ctrl.Code != 403 {
|
if resp.Ctrl == nil || resp.Ctrl.Code != 403 {
|
||||||
t.Fatalf("User 1 expected 403 ctrl, received %#v", resp)
|
t.Fatalf("User 1 expected 403 ctrl, received %#v", resp)
|
||||||
}
|
}
|
||||||
if len(helper.results[1].messages) != 0 {
|
if len(helper.results[1].messages) != 0 {
|
||||||
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
|
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
|
||||||
}
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user