feat: refine IM admin management workflows
This commit is contained in:
+1
-1
@@ -60,7 +60,7 @@ RUN mkdir -p storage/logs storage/data storage/cache storage/diskqueue \
|
||||
&& chmod +x entrypoint.sh hotgo
|
||||
|
||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \
|
||||
CMD nc -z localhost 8000 || exit 1
|
||||
CMD wget -q -O /dev/null http://localhost:8000/healthz || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+215
-59
@@ -29,7 +29,7 @@ API Key 是应用级接入标识,由部署人员在发布客户端前生成并
|
||||
|
||||
1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。
|
||||
2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。
|
||||
3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
|
||||
3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
|
||||
4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。
|
||||
|
||||
生成示意:
|
||||
@@ -44,7 +44,7 @@ API Key、企业码和用户 Token 是三个不同概念:
|
||||
|
||||
| 值 | 取得时间 | 作用 | 是否代表用户身份 |
|
||||
| --- | --- | --- | --- |
|
||||
| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 |
|
||||
| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 |
|
||||
| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 |
|
||||
| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 |
|
||||
|
||||
@@ -57,11 +57,11 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
|
||||
| 通道 | 地址 | 认证 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 |
|
||||
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 |
|
||||
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 |
|
||||
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
|
||||
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
|
||||
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
|
||||
|
||||
HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
|
||||
HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
|
||||
|
||||
WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
|
||||
|
||||
@@ -221,7 +221,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
|
||||
```http
|
||||
POST /v0/channels/lp HTTP/1.1
|
||||
Host: im.example.com
|
||||
X-Tinode-APIKey: <预置的普通 API Key>
|
||||
X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
Content-Length: 0
|
||||
```
|
||||
|
||||
@@ -235,7 +235,7 @@ Content-Length: 0
|
||||
|
||||
```http
|
||||
POST /v0/channels/lp?sid=session-id HTTP/1.1
|
||||
X-Tinode-APIKey: <预置的普通 API Key>
|
||||
X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
Content-Type: application/json
|
||||
|
||||
{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}}
|
||||
@@ -245,7 +245,7 @@ POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的
|
||||
|
||||
```http
|
||||
GET /v0/channels/lp?sid=session-id HTTP/1.1
|
||||
X-Tinode-APIKey: <预置的普通 API Key>
|
||||
X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
```
|
||||
|
||||
收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。
|
||||
@@ -270,7 +270,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
本节用于 WebSocket 联调。除特别说明外:
|
||||
|
||||
- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-Tinode-APIKey`。
|
||||
- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。
|
||||
- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。
|
||||
- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。
|
||||
- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。
|
||||
@@ -334,16 +334,60 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` |
|
||||
| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` |
|
||||
|
||||
##### 0.5.4.6 系统事件、好友和群业务下行
|
||||
##### 0.5.4.6 系统通知、好友和群业务下行
|
||||
|
||||
| 事件 | 服务端下行示例 | 客户端动作 |
|
||||
| --- | --- | --- |
|
||||
| 好友申请创建 | `{"data":{"topic":"sys","seq":101,"from":"usr_system","head":{"mime":"application/json","x-im-type":"system_event"},"content":{"event_type":"friend.request.created","request_id":"frq_1","applicant":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","action":{"type":"friend_request","request_id":"frq_1"}}}}` | 系统消息入库/展示,刷新 `friend-requests` 和 `bootstrap.entry_badges` |
|
||||
| 好友关系变化 | `{"data":{"topic":"sys","seq":102,"head":{"x-im-type":"system_event"},"content":{"event_type":"friend_relationship_changed","peer_user_id":"usr_a","relationship":"friend","topic":"p2p..."}}}` | 刷新会话 context,解锁或锁定输入区 |
|
||||
| 群邀请 | `{"data":{"topic":"sys","seq":103,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_invitation","invitation_id":"ginv_1","group":{"topic":"grp...","name":"项目群"},"inviter":{"user_id":"usr_a","display_name":"张三"},"status":"pending","expires_at":"2026-08-27T09:00:00Z"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 |
|
||||
| 入群申请 | `{"data":{"topic":"grp...","seq":20,"head":{"x-im-type":"system_event"},"content":{"event_type":"group.join_request.created","request_id":"gjr_1","applicant":{"user_id":"usr_a","display_name":"张三"},"source":"qr"}}}` | 管理员刷新 `join-requests` 和 `summary.pending_join_request_count` |
|
||||
| 群成员变化 | `{"data":{"topic":"grp...","seq":21,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_membership_changed","operator":"usr_owner","target_users":["usr_a"],"action":"join","result":"joined"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 |
|
||||
| 工作台未读变化 | `{"data":{"topic":"sys","seq":104,"head":{"x-im-type":"system_event"},"content":{"event_type":"workbench.badge.updated","modules":[{"code":"meetings","unread_count":2}],"workbench_total_unread":2}}}` | 更新工作台 badge;必要时重新拉 `GET /v1/im/workbench` |
|
||||
服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。
|
||||
|
||||
用户维度业务事件统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"topic": "usr_target",
|
||||
"from": "usr_actor",
|
||||
"what": "im",
|
||||
"event": "friend.request.accepted",
|
||||
"payload": {
|
||||
"user_id": "usr_actor"
|
||||
},
|
||||
"ts": "2026-08-25T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
群维度业务事件统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"topic": "grp_xxx",
|
||||
"from": "usr_actor",
|
||||
"what": "group",
|
||||
"event": "group.member.joined",
|
||||
"payload": {
|
||||
"topic": "grp_xxx",
|
||||
"user_id": "usr_xxx"
|
||||
},
|
||||
"ts": "2026-08-25T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 事件 | 接收方 | 服务端下行示例 | 客户端动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 好友申请创建 | 被申请人 | `{"info":{"topic":"usr_target","from":"usr_applicant","what":"im","event":"friend.request.created","payload":{"user_id":"usr_applicant"}}}` | 刷新 `GET /v1/im/friend-requests` 和 `GET /v1/im/bootstrap` 的新朋友未处理数;系统通知列表也会新增记录 |
|
||||
| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 |
|
||||
| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 |
|
||||
| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 |
|
||||
| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
|
||||
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
|
||||
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
|
||||
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
|
||||
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 |
|
||||
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
|
||||
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
|
||||
|
||||
业务事件写入 outbox 后会立即尝试投递;Hub 不可用或队列满时保持 pending,后台 poller 按在线租户重试。`registration.invite_used` 和 `security.password_reset` 是服务端内部消费事件,不作为普通业务事件下发给 App。
|
||||
|
||||
##### 0.5.4.7 WebRTC 通话信令
|
||||
|
||||
@@ -381,8 +425,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
|
||||
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
|
||||
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
|
||||
| `POST /v0/file/u/` | Multipart 文件;Header `X-Tinode-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
|
||||
| `GET /v0/file/s/{file_id}` | Header `X-Tinode-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
|
||||
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
|
||||
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
|
||||
|
||||
### 0.6 Topic 与产品消息
|
||||
|
||||
@@ -437,7 +481,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**企业解析接口:**
|
||||
|
||||
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
|
||||
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
|
||||
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
|
||||
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
|
||||
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
|
||||
@@ -463,6 +507,109 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
}
|
||||
```
|
||||
|
||||
### 1.1.1 企业注册-登录入口.png
|
||||
|
||||
<img src="images/企业注册-登录入口.png" alt="企业注册-登录入口" width="360">
|
||||
|
||||
**状态:已实现。**
|
||||
|
||||
- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。
|
||||
- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。
|
||||
- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。
|
||||
- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。
|
||||
- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。
|
||||
|
||||
### 1.1.2 企业注册-填写注册信息.png
|
||||
|
||||
<img src="images/企业注册-填写注册信息.png" alt="企业注册-填写注册信息" width="360">
|
||||
|
||||
**状态:已实现。**
|
||||
|
||||
- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。
|
||||
- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。
|
||||
- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。
|
||||
- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。
|
||||
- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。
|
||||
- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
|
||||
- 请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"credit_code": "91310000MA1K000000",
|
||||
"enterprise_email": "admin@example.com",
|
||||
"license_file_id": "file_01K...",
|
||||
"tenant_code_hint": "acme",
|
||||
"client_context": {
|
||||
"platform": "android",
|
||||
"app_version": "1.0.0",
|
||||
"device_model": "Pixel 8"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。
|
||||
|
||||
```json
|
||||
{
|
||||
"application_id": "entapp_01K...",
|
||||
"status": "received",
|
||||
"estimated_review_hours": 24,
|
||||
"submitted_at": "2026-08-24T08:30:15.123Z"
|
||||
}
|
||||
```
|
||||
|
||||
- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。
|
||||
|
||||
### 1.1.3 企业注册-提交成功提示.png
|
||||
|
||||
<img src="images/企业注册-提交成功提示.png" alt="企业注册-提交成功提示" width="360">
|
||||
|
||||
**状态:已实现。**
|
||||
|
||||
- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。
|
||||
- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。
|
||||
- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。
|
||||
- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。
|
||||
- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。
|
||||
|
||||
### 1.1.4 企业注册-问题反馈.png
|
||||
|
||||
<img src="images/企业注册-问题反馈.png" alt="企业注册-问题反馈" width="360">
|
||||
|
||||
**状态:已实现。**
|
||||
|
||||
- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。
|
||||
- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。
|
||||
- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。
|
||||
- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
|
||||
- 请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "企业码 acme 提示不可用,无法进入企业。",
|
||||
"contact": "user@example.com",
|
||||
"tenant_code_hint": "acme",
|
||||
"screenshot_file_ids": ["file_01K..."],
|
||||
"client_context": {
|
||||
"platform": "ios",
|
||||
"app_version": "1.0.0",
|
||||
"network": "wifi"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback_id": "entryfb_01K...",
|
||||
"status": "received",
|
||||
"submitted_at": "2026-08-24T08:35:15.123Z"
|
||||
}
|
||||
```
|
||||
|
||||
- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。
|
||||
|
||||
### 1.2 登录页面.png
|
||||
|
||||
<img src="images/登录页面.png" alt="登录页面" width="360">
|
||||
@@ -472,8 +619,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
|
||||
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
|
||||
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
|
||||
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
|
||||
- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
|
||||
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
|
||||
- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
|
||||
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
|
||||
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
|
||||
|
||||
@@ -593,7 +740,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。
|
||||
- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。
|
||||
- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。
|
||||
- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。
|
||||
- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。
|
||||
|
||||
### 1.9 实名认证-已实名.png
|
||||
@@ -631,7 +778,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -642,6 +789,9 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
|
||||
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
|
||||
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
|
||||
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
|
||||
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
||||
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
|
||||
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
||||
@@ -692,7 +842,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:基础消息操作部分已实现;产品权限矩阵需改造。**
|
||||
|
||||
- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。
|
||||
- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。
|
||||
- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。
|
||||
- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。
|
||||
- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。
|
||||
|
||||
@@ -702,7 +852,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:需改造。**
|
||||
|
||||
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。
|
||||
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
|
||||
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
|
||||
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
|
||||
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
|
||||
@@ -711,7 +861,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -721,7 +871,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
|
||||
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
|
||||
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
|
||||
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
|
||||
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
|
||||
@@ -746,7 +896,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。
|
||||
- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。
|
||||
- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。
|
||||
- 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。
|
||||
|
||||
### 3.3 个人对话框(我的状态).png
|
||||
|
||||
@@ -774,7 +924,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:新增好友审批与 Topic 联动。**
|
||||
|
||||
- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。
|
||||
- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。
|
||||
- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。
|
||||
- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。
|
||||
|
||||
@@ -1044,7 +1194,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -1068,6 +1218,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` |
|
||||
| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
|
||||
|
||||
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
|
||||
|
||||
## 5. 通讯录、好友关系与黑名单
|
||||
|
||||
### 5.1 通讯录.png
|
||||
@@ -1090,6 +1242,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
|
||||
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
|
||||
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
|
||||
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
|
||||
|
||||
### 5.3 添加好友.png
|
||||
|
||||
@@ -1208,7 +1361,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。**
|
||||
|
||||
- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
|
||||
- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
|
||||
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
|
||||
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
|
||||
|
||||
@@ -1216,7 +1369,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -1251,11 +1404,11 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
<img src="images/群聊.png" alt="群聊" width="360">
|
||||
|
||||
**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。**
|
||||
**状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。**
|
||||
|
||||
- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。
|
||||
- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。
|
||||
- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。
|
||||
- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。
|
||||
|
||||
### 6.2 空白页.png
|
||||
|
||||
@@ -1272,7 +1425,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:新增邀请卡片和加入接口。**
|
||||
|
||||
- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。
|
||||
- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。
|
||||
- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
|
||||
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
|
||||
|
||||
@@ -1283,8 +1436,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:新增群成员事件。**
|
||||
|
||||
- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。
|
||||
- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。
|
||||
- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。
|
||||
- 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。
|
||||
- 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。
|
||||
|
||||
### 6.5 邀请进群申请.png
|
||||
|
||||
@@ -1313,7 +1466,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。
|
||||
- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。
|
||||
- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
|
||||
- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
|
||||
|
||||
### 6.8 群公告状态/邀请进群申请同时存在.png
|
||||
|
||||
@@ -1497,7 +1650,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:新增。**
|
||||
|
||||
- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。
|
||||
- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。
|
||||
- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成 `group.owner.transferred` 群业务事件。
|
||||
- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。
|
||||
|
||||
### 6.27 群文件.png
|
||||
@@ -1524,7 +1677,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -1659,7 +1812,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:底层下载已实现租户校验;资源 ACL 需改造。**
|
||||
|
||||
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。
|
||||
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。
|
||||
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。
|
||||
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
|
||||
|
||||
@@ -1732,7 +1885,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -1782,6 +1935,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。**
|
||||
|
||||
- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。
|
||||
- 发起前查询 `capabilities.can_call`,再发布持久邀请消息:
|
||||
|
||||
```json
|
||||
@@ -1869,7 +2023,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -1895,7 +2049,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。
|
||||
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。
|
||||
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
|
||||
- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。
|
||||
- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
|
||||
|
||||
### 9.2 部门组织框架.png
|
||||
|
||||
@@ -1906,6 +2060,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。
|
||||
- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。
|
||||
- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。
|
||||
- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。
|
||||
|
||||
### 9.3 会议通知-发起人状态.png
|
||||
|
||||
@@ -1916,6 +2071,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。
|
||||
- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。
|
||||
- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。
|
||||
- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。
|
||||
|
||||
### 9.4 会议通知-接收人状态.png
|
||||
|
||||
@@ -1924,7 +2080,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
**状态:新增。**
|
||||
|
||||
- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。
|
||||
- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。
|
||||
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
|
||||
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
|
||||
|
||||
### 9.5 日报.png
|
||||
@@ -2019,7 +2175,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -2041,16 +2197,16 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
|
||||
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
|
||||
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
|
||||
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
|
||||
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
|
||||
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
|
||||
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
|
||||
| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
|
||||
| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` |
|
||||
| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` |
|
||||
| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` |
|
||||
| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
|
||||
| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
|
||||
| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
|
||||
| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
|
||||
| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` |
|
||||
| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` |
|
||||
| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
|
||||
@@ -2076,10 +2232,10 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
<img src="images/个人信息.png" alt="个人信息" width="360">
|
||||
|
||||
**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。**
|
||||
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
|
||||
|
||||
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
|
||||
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。
|
||||
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
|
||||
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
|
||||
|
||||
### 10.3 设置昵称.png
|
||||
@@ -2266,7 +2422,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -2304,7 +2460,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。
|
||||
- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。
|
||||
- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。
|
||||
- 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。
|
||||
|
||||
### 11.2 反诈弹窗(二次提示安卓).png
|
||||
|
||||
@@ -2351,7 +2507,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
|
||||
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
|
||||
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
|
||||
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
@@ -2373,7 +2529,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
|
||||
1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。
|
||||
2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。
|
||||
3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。
|
||||
3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。
|
||||
4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。
|
||||
5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。
|
||||
|
||||
@@ -2383,7 +2539,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
|
||||
| --- | --- | --- |
|
||||
| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 |
|
||||
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 |
|
||||
| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 |
|
||||
| P0 | 普通用户禁止发布系统 Topic;系统通知和业务事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 |
|
||||
| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 |
|
||||
| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 |
|
||||
| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 |
|
||||
|
||||
@@ -60,7 +60,9 @@ type GovernanceUpdateReq struct {
|
||||
sysin.ImGroupGovernanceUpdateInp
|
||||
}
|
||||
|
||||
type GovernanceUpdateRes struct{}
|
||||
type GovernanceUpdateRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// ProfileUpdateReq 修改IM群资料
|
||||
type ProfileUpdateReq struct {
|
||||
@@ -78,7 +80,9 @@ type DissolveReq struct {
|
||||
sysin.ImGroupDissolveInp
|
||||
}
|
||||
|
||||
type DissolveRes struct{}
|
||||
type DissolveRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// MuteReq 禁言IM群成员
|
||||
type MuteReq struct {
|
||||
@@ -86,7 +90,9 @@ type MuteReq struct {
|
||||
sysin.ImGroupMuteInp
|
||||
}
|
||||
|
||||
type MuteRes struct{}
|
||||
type MuteRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// MemberMuteReq Kafka禁言IM群成员
|
||||
type MemberMuteReq struct {
|
||||
@@ -104,7 +110,9 @@ type UnmuteReq struct {
|
||||
sysin.ImGroupUnmuteInp
|
||||
}
|
||||
|
||||
type UnmuteRes struct{}
|
||||
type UnmuteRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// MemberUnmuteReq Kafka解除IM群成员禁言
|
||||
type MemberUnmuteReq struct {
|
||||
@@ -148,7 +156,9 @@ type RoleUpdateReq struct {
|
||||
sysin.ImGroupRoleUpdateInp
|
||||
}
|
||||
|
||||
type RoleUpdateRes struct{}
|
||||
type RoleUpdateRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// AdminSetReq 设置IM群管理员
|
||||
type AdminSetReq struct {
|
||||
@@ -176,7 +186,9 @@ type OwnerTransferReq struct {
|
||||
sysin.ImGroupOwnerTransferInp
|
||||
}
|
||||
|
||||
type OwnerTransferRes struct{}
|
||||
type OwnerTransferRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// JoinRequestListReq 查询IM入群申请列表
|
||||
type JoinRequestListReq struct {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gcmd"
|
||||
"github.com/gogf/gf/v2/util/gmode"
|
||||
"hotgo/internal/controller/health"
|
||||
"hotgo/internal/library/addons"
|
||||
"hotgo/internal/library/casbin"
|
||||
"hotgo/internal/library/hggen"
|
||||
@@ -33,6 +34,7 @@ var (
|
||||
|
||||
// 请求响应结束后回调
|
||||
s.BindHookHandler("/*any", ghttp.HookAfterOutput, service.Hook().AfterOutput)
|
||||
s.BindHandler("/healthz", health.Serve)
|
||||
|
||||
// 注册全局中间件
|
||||
s.BindMiddleware("/*any", []ghttp.HandlerFunc{
|
||||
|
||||
@@ -38,6 +38,7 @@ const (
|
||||
ImAdminCommandGroupMemberUnmute = "group.member.unmute"
|
||||
ImAdminCommandGroupMemberRemove = "group.member.remove"
|
||||
ImAdminCommandGroupOwnerTransfer = "group.owner.transfer"
|
||||
ImAdminCommandGroupDissolve = "group.dissolve"
|
||||
ImAdminCommandUserBan = "user.ban"
|
||||
ImAdminCommandUserUnban = "user.unban"
|
||||
ImAdminCommandUserFreeze = "user.freeze"
|
||||
|
||||
@@ -77,7 +77,12 @@ func (c *cImGroup) Governance(ctx context.Context, req *imgroup.GovernanceReq) (
|
||||
|
||||
// GovernanceUpdate 更新IM群治理配置
|
||||
func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) {
|
||||
err = service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp)
|
||||
data, err := service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.GovernanceUpdateRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,13 +99,23 @@ func (c *cImGroup) ProfileUpdate(ctx context.Context, req *imgroup.ProfileUpdate
|
||||
|
||||
// Dissolve 解散IM群
|
||||
func (c *cImGroup) Dissolve(ctx context.Context, req *imgroup.DissolveReq) (res *imgroup.DissolveRes, err error) {
|
||||
err = service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp)
|
||||
data, err := service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.DissolveRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// Mute 禁言IM群成员
|
||||
func (c *cImGroup) Mute(ctx context.Context, req *imgroup.MuteReq) (res *imgroup.MuteRes, err error) {
|
||||
err = service.SysImGroup().Mute(ctx, &req.ImGroupMuteInp)
|
||||
data, err := service.SysImGroup().Mute(ctx, &req.ImGroupMuteInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.MuteRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +132,12 @@ func (c *cImGroup) MemberMute(ctx context.Context, req *imgroup.MemberMuteReq) (
|
||||
|
||||
// Unmute 解除IM群成员禁言
|
||||
func (c *cImGroup) Unmute(ctx context.Context, req *imgroup.UnmuteReq) (res *imgroup.UnmuteRes, err error) {
|
||||
err = service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp)
|
||||
data, err := service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.UnmuteRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,7 +177,12 @@ func (c *cImGroup) JoinRequestReject(ctx context.Context, req *imgroup.JoinReque
|
||||
|
||||
// RoleUpdate 更新IM群成员角色
|
||||
func (c *cImGroup) RoleUpdate(ctx context.Context, req *imgroup.RoleUpdateReq) (res *imgroup.RoleUpdateRes, err error) {
|
||||
err = service.SysImGroup().RoleUpdate(ctx, &req.ImGroupRoleUpdateInp)
|
||||
data, err := service.SysImGroup().RoleUpdate(ctx, &req.ImGroupRoleUpdateInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.RoleUpdateRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,7 +210,12 @@ func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq)
|
||||
|
||||
// OwnerTransfer 转让IM群主
|
||||
func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) {
|
||||
err = service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp)
|
||||
data, err := service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.OwnerTransferRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"hotgo/internal/library/queue"
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Checks map[string]string `json:"checks"`
|
||||
}
|
||||
|
||||
// Serve exposes an unauthenticated dependency health endpoint for container probes.
|
||||
func Serve(r *ghttp.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
r.Response.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
|
||||
r.Response.WriteStatusExit(http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
checks := map[string]string{}
|
||||
statusCode := http.StatusOK
|
||||
|
||||
if err := g.DB().Ctx(ctx).Raw("SELECT 1").Scan(new(int)); err != nil {
|
||||
checks["mysql"] = err.Error()
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["mysql"] = "ok"
|
||||
}
|
||||
|
||||
if _, err := g.Redis().Do(ctx, "PING"); err != nil {
|
||||
checks["redis"] = err.Error()
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["redis"] = "ok"
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(g.Cfg().MustGet(ctx, "queue.driver").String()), "kafka") {
|
||||
if err := queue.KafkaHealthCheck(ctx); err != nil {
|
||||
checks["kafka"] = err.Error()
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["kafka"] = "ok"
|
||||
}
|
||||
} else {
|
||||
checks["kafka"] = "disabled"
|
||||
}
|
||||
|
||||
body := response{Status: "ok", Checks: checks}
|
||||
if statusCode != http.StatusOK {
|
||||
body.Status = "error"
|
||||
}
|
||||
r.Response.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
r.Response.WriteHeader(statusCode)
|
||||
if r.Method != http.MethodHead {
|
||||
r.Response.WriteJsonExit(body)
|
||||
return
|
||||
}
|
||||
r.Exit()
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"hash"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -270,6 +271,46 @@ func configureKafkaSASL(conf *sarama.Config, connOpt KafkaConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// KafkaHealthCheck verifies broker connectivity by creating a short-lived Sarama client.
|
||||
func KafkaHealthCheck(ctx context.Context) error {
|
||||
if len(config.Kafka.Address) == 0 {
|
||||
return errors.New("queue kafka address is empty")
|
||||
}
|
||||
kfkVersion, err := parseKafkaVersion(config.Kafka.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !validateVersion(kfkVersion) {
|
||||
kfkVersion = sarama.V2_4_0_0
|
||||
}
|
||||
conf := sarama.NewConfig()
|
||||
conf.Version = kfkVersion
|
||||
conf.ClientID = "hotgo-healthz"
|
||||
conf.Net.DialTimeout = 2 * time.Second
|
||||
conf.Net.ReadTimeout = 2 * time.Second
|
||||
conf.Net.WriteTimeout = 2 * time.Second
|
||||
configureKafkaSASL(conf, KafkaConfig{
|
||||
UserName: config.Kafka.Username,
|
||||
Password: config.Kafka.Password,
|
||||
Mechanism: config.Kafka.Mechanism,
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
client, err := sarama.NewClient(config.Kafka.Address, conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
if len(client.Brokers()) == 0 {
|
||||
return errors.New("no kafka brokers available")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scramClientGenerator(hashFunc func() hash.Hash) func() sarama.SCRAMClient {
|
||||
return func() sarama.SCRAMClient {
|
||||
return &xdgSCRAMClient{HashGeneratorFcn: scram.HashGeneratorFcn(hashFunc)}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model {
|
||||
"t.trusted trusted_json",
|
||||
"t.tags tags_json",
|
||||
"t.aux aux_json",
|
||||
"COALESCE(gs.name, '') group_name",
|
||||
"COALESCE(NULLIF(gs.name, ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.fn')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.name')), ''), t.name) group_name",
|
||||
"COALESCE(gs.avatar_file_id, '') avatar_file_id",
|
||||
"COALESCE(gs.description, '') description",
|
||||
"COALESCE(gs.group_status, '') group_status",
|
||||
@@ -161,7 +161,7 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res *
|
||||
"t.aux aux_json",
|
||||
"t.access access_json",
|
||||
"t.usebt use_bt",
|
||||
"COALESCE(gs.name, '') group_name",
|
||||
"COALESCE(NULLIF(gs.name, ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.fn')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.name')), ''), t.name) group_name",
|
||||
"COALESCE(gs.avatar_file_id, '') avatar_file_id",
|
||||
"COALESCE(gs.description, '') description",
|
||||
"COALESCE(gs.group_status, '') group_status",
|
||||
@@ -349,50 +349,24 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp)
|
||||
}
|
||||
|
||||
// GovernanceUpdate 更新IM群治理配置
|
||||
func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error) {
|
||||
func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
payload := map[string]any{
|
||||
"topic": in.Topic,
|
||||
"actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
"governance": map[string]any{
|
||||
"join_approval": in.JoinApproval == 1,
|
||||
"mute_all": in.MuteAll == 1,
|
||||
"member_upload_files": in.MemberUploadFiles == 1,
|
||||
"member_private_chat": in.MemberPrivateChat == 1,
|
||||
"member_invite": in.MemberInvite == 1,
|
||||
"max_member_count": in.MaxMemberCount,
|
||||
"max_admin_count": in.MaxAdminCount,
|
||||
},
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
before, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil {
|
||||
return gerror.New("群治理配置不存在")
|
||||
}
|
||||
if before["group_status"].String() == consts.ImGroupStatusDissolved {
|
||||
return gerror.New("已解散群不能修改治理配置")
|
||||
}
|
||||
if _, txErr = tx.Exec(`UPDATE im_group_settings SET
|
||||
join_approval=?,mute_all=?,member_upload_files=?,member_private_chat=?,member_invite=?,
|
||||
max_member_count=?,max_admin_count=?,version=version+1,updated_at=?
|
||||
WHERE topic=?`,
|
||||
in.JoinApproval, in.MuteAll, in.MemberUploadFiles, in.MemberPrivateChat, in.MemberInvite,
|
||||
in.MaxMemberCount, in.MaxAdminCount, now, in.Topic); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
after, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, 0, "governance_update", "group_settings", in.Topic, before.Map(), after.Map(), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.governance_updated", g.Map{
|
||||
"topic": in.Topic, "operator_id": actor, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupGovernUpdate, payload, 0)
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
@@ -412,101 +386,21 @@ func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfil
|
||||
}
|
||||
|
||||
// Dissolve 解散IM群
|
||||
func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error) {
|
||||
func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
payload := map[string]any{
|
||||
"topic": in.Topic,
|
||||
"actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
"reason": in.Reason,
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
before, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil {
|
||||
return gerror.New("群治理配置不存在")
|
||||
}
|
||||
if before["group_status"].String() == consts.ImGroupStatusDissolved {
|
||||
return gerror.New("群已解散")
|
||||
}
|
||||
result, txErr := tx.Exec(`UPDATE im_group_settings
|
||||
SET group_status=?,dissolved_by=?,dissolved_at=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND group_status<>?`,
|
||||
consts.ImGroupStatusDissolved, actor, now, now, in.Topic, consts.ImGroupStatusDissolved)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return gerror.New("群已解散")
|
||||
}
|
||||
after, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, 0, "group_dissolve", "group", in.Topic, before.Map(), after.Map(), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.dissolved", g.Map{
|
||||
"topic": in.Topic, "operator_id": actor, "request_id": requestId, "reason": in.Reason,
|
||||
})
|
||||
})
|
||||
return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupDissolve, payload, 0)
|
||||
}
|
||||
|
||||
// Mute 禁言IM群成员
|
||||
func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if txErr := s.ensureActiveGroupMember(ctx, tx, in.Topic, in.UserId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
before, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
muteId, txErr := imtenant.NextSnowflakeID(ctx)
|
||||
if txErr != nil {
|
||||
return gerror.Wrap(txErr, "生成禁言ID失败")
|
||||
}
|
||||
if _, txErr = tx.Exec(`INSERT INTO im_group_mutes
|
||||
(id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
|
||||
VALUES(?,?,?,?,?,?,?, ?,1)
|
||||
ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),
|
||||
expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),
|
||||
revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`,
|
||||
muteId, in.Topic, in.UserId, actor, now, in.ExpiresAt, in.Reason, consts.ImGroupMuteStatusActive); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
after, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "member_mute", "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.mute.updated", g.Map{
|
||||
"topic": in.Topic, "user_id": in.UserId, "muted": true, "expires_at": in.ExpiresAt, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
return s.MemberMute(ctx, in)
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
@@ -526,49 +420,8 @@ func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp)
|
||||
}
|
||||
|
||||
// Unmute 解除IM群成员禁言
|
||||
func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
before, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil || before["status"].String() != consts.ImGroupMuteStatusActive {
|
||||
return gerror.New("成员未处于禁言状态")
|
||||
}
|
||||
result, txErr := tx.Exec(`UPDATE im_group_mutes
|
||||
SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND user_id=? AND status=?`,
|
||||
consts.ImGroupMuteStatusRevoked, actor, now, now, in.Topic, in.UserId, consts.ImGroupMuteStatusActive)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return gerror.New("成员未处于禁言状态")
|
||||
}
|
||||
after, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "member_unmute", "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.mute.updated", g.Map{
|
||||
"topic": in.Topic, "user_id": in.UserId, "muted": false, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
return s.MemberUnmute(ctx, in)
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
@@ -677,77 +530,18 @@ func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJ
|
||||
}
|
||||
|
||||
// RoleUpdate 更新IM群成员角色
|
||||
func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error) {
|
||||
func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
if in.Role != consts.ImGroupRoleAdmin && in.Role != consts.ImGroupRoleMember {
|
||||
return gerror.New("只能设置管理员或普通成员")
|
||||
switch in.Role {
|
||||
case consts.ImGroupRoleAdmin:
|
||||
return s.AdminSet(ctx, in)
|
||||
case consts.ImGroupRoleMember:
|
||||
return s.AdminRemove(ctx, in)
|
||||
default:
|
||||
return nil, gerror.New("只能设置管理员或普通成员")
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
modeGiven := imGroupModeMember
|
||||
action := "member_role_member"
|
||||
if in.Role == consts.ImGroupRoleAdmin {
|
||||
modeGiven = imGroupModeAdmin
|
||||
action = "member_role_admin"
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil {
|
||||
return err
|
||||
}
|
||||
before, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil || before["member_status"].String() != consts.ImGroupMemberStatusActive {
|
||||
return gerror.New("成员不在群内或不是正常状态")
|
||||
}
|
||||
if before["role"].String() == consts.ImGroupRoleOwner {
|
||||
return gerror.New("群主角色请通过群主转让处理")
|
||||
}
|
||||
if in.Role == consts.ImGroupRoleAdmin {
|
||||
if txErr = s.ensureAdminLimit(ctx, tx, in.Topic, in.UserId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
subResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=?
|
||||
WHERE topic=? AND userid=? AND deletedat IS NULL`, modeGiven, now, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := subResult.RowsAffected(); affected == 0 {
|
||||
return gerror.New("成员通信订阅不存在")
|
||||
}
|
||||
result, txErr := tx.Exec(`UPDATE im_group_members
|
||||
SET role=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND user_id=? AND member_status=?`,
|
||||
in.Role, now, in.Topic, in.UserId, consts.ImGroupMemberStatusActive)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return gerror.New("成员不在群内或不是正常状态")
|
||||
}
|
||||
after, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, action, "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.role.updated", g.Map{
|
||||
"topic": in.Topic, "user_id": in.UserId, "role": in.Role, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
@@ -775,105 +569,16 @@ func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpda
|
||||
}
|
||||
|
||||
// OwnerTransfer 转让IM群主
|
||||
func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) {
|
||||
func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
payload := map[string]any{
|
||||
"topic": in.Topic,
|
||||
"actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
"target_user_id": strconv.FormatInt(in.UserId, 10),
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
beforeGroup, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if beforeGroup == nil {
|
||||
return gerror.New("群治理配置不存在")
|
||||
}
|
||||
if beforeGroup["group_status"].String() == consts.ImGroupStatusDissolved {
|
||||
return gerror.New("已解散群不能转让群主")
|
||||
}
|
||||
previousOwner := beforeGroup["owner_user_id"].Int64()
|
||||
if previousOwner <= 0 {
|
||||
return gerror.New("原群主不存在")
|
||||
}
|
||||
if previousOwner == in.UserId {
|
||||
return gerror.New("新群主不能与原群主相同")
|
||||
}
|
||||
if txErr = s.ensureActiveGroupMember(ctx, tx, in.Topic, previousOwner); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.ensureActiveGroupMember(ctx, tx, in.Topic, in.UserId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
beforeOld, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, previousOwner)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
beforeNew, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
oldSubResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=?
|
||||
WHERE topic=? AND userid=? AND deletedat IS NULL`, imGroupModeAdmin, now, in.Topic, previousOwner)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := oldSubResult.RowsAffected(); affected == 0 {
|
||||
return gerror.New("原群主通信订阅不存在")
|
||||
}
|
||||
if _, txErr = tx.Exec(`UPDATE im_group_members SET role=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND user_id=? AND member_status=?`, consts.ImGroupRoleAdmin, now, in.Topic, previousOwner, consts.ImGroupMemberStatusActive); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
newSubResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=?
|
||||
WHERE topic=? AND userid=? AND deletedat IS NULL`, imGroupModeOwner, now, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := newSubResult.RowsAffected(); affected == 0 {
|
||||
return gerror.New("新群主通信订阅不存在")
|
||||
}
|
||||
if _, txErr = tx.Exec(`UPDATE im_group_members SET role=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND user_id=? AND member_status=?`, consts.ImGroupRoleOwner, now, in.Topic, in.UserId, consts.ImGroupMemberStatusActive); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if _, txErr = tx.Exec(`UPDATE im_topics SET owner=?,updatedat=? WHERE name=?`, in.UserId, now, in.Topic); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if _, txErr = tx.Exec(`UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?`, in.UserId, now, in.Topic); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
afterGroup, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
afterOld, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, previousOwner)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
afterNew, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "owner_transfer", "group", in.Topic, g.Map{
|
||||
"group": recordMap(beforeGroup), "previous_owner": recordMap(beforeOld), "new_owner": recordMap(beforeNew),
|
||||
}, g.Map{
|
||||
"group": recordMap(afterGroup), "previous_owner": recordMap(afterOld), "new_owner": recordMap(afterNew),
|
||||
}, requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.owner.transferred", g.Map{
|
||||
"topic": in.Topic, "previous_owner": previousOwner, "owner": in.UserId, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupOwnerTransfer, payload, 0)
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, topic string, eventType string, payload map[string]any, version int64) (*sysin.ImGroupCommandModel, error) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imadmin"
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/entity"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
@@ -483,21 +482,21 @@ func (s *sSysImUser) ResetPassword(ctx context.Context, in *sysin.ImUserPassword
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userId, err := sysin.ParseOptionalInt64(in.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command, err := s.publishPasswordResetCommand(ctx, tenant, userId, in.NewPassword)
|
||||
res, err = s.syncPasswordReset(ctx, in, userId)
|
||||
result := "success"
|
||||
if err != nil {
|
||||
result = "failed"
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenant.Id, "user_password_reset", "user", userId, result, nil)
|
||||
return command, err
|
||||
tenantId := in.TenantId
|
||||
if res != nil && res.TenantID > 0 {
|
||||
tenantId = res.TenantID
|
||||
}
|
||||
_ = writeImAdminAudit(ctx, tenantId, "user_password_reset", "user", userId, result, nil)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (s *sSysImUser) RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (err error) {
|
||||
@@ -686,59 +685,53 @@ func (s *sSysImUser) Devices(ctx context.Context, in *sysin.ImUserDeviceListInp)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
if err = in.FilterDevice(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
in.Hard = true
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete)
|
||||
return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) {
|
||||
func (s *sSysImUser) syncGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := contexts.GetUser(ctx)
|
||||
actorName := ""
|
||||
if user != nil {
|
||||
actorName = user.RealName
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": strconv.FormatInt(in.ActorUserId, 10),
|
||||
"user_id": strconv.FormatInt(in.UserId, 10),
|
||||
"target_user_id": strconv.FormatInt(in.UserId, 10),
|
||||
"reason": in.Reason,
|
||||
"event_type": eventType,
|
||||
"actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
"reason": in.Reason,
|
||||
}
|
||||
if in.DeviceId != "" {
|
||||
payload["device_id"] = in.DeviceId
|
||||
@@ -746,75 +739,51 @@ func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImU
|
||||
if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete {
|
||||
payload["hard"] = in.Hard
|
||||
}
|
||||
command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{
|
||||
TenantID: tenant.Id,
|
||||
TenantCode: tenant.Code,
|
||||
EventType: eventType,
|
||||
BizType: "user",
|
||||
BizID: strconv.FormatInt(in.UserId, 10),
|
||||
Actor: &imkafka.Actor{
|
||||
Type: "admin",
|
||||
ID: strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
DisplayName: actorName,
|
||||
},
|
||||
Payload: payload,
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
})
|
||||
res, err := s.syncUserAction(ctx, tenant, in.UserId, eventType, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sysin.ImUserCommandModel{
|
||||
CommandID: command.CommandID,
|
||||
EventID: command.EventID,
|
||||
Status: command.Status,
|
||||
TenantID: command.TenantID,
|
||||
TenantCode: command.TenantCode,
|
||||
EventType: command.EventType,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
}, nil
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *sSysImUser) publishPasswordResetCommand(ctx context.Context, tenant *entity.ImTenant, userId int64, newPassword string) (*sysin.ImUserCommandModel, error) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
func (s *sSysImUser) syncPasswordReset(ctx context.Context, in *sysin.ImUserPasswordResetInp, userId int64) (*sysin.ImUserCommandModel, error) {
|
||||
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "生成密码摘要失败")
|
||||
}
|
||||
user := contexts.GetUser(ctx)
|
||||
actorName := ""
|
||||
if user != nil {
|
||||
actorName = user.RealName
|
||||
}
|
||||
payload := map[string]any{
|
||||
"operator_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
"user_id": strconv.FormatInt(userId, 10),
|
||||
"target_user_id": strconv.FormatInt(userId, 10),
|
||||
"password_hash": base64.StdEncoding.EncodeToString(passwordHash),
|
||||
"event_type": consts.ImAdminCommandUserPasswordReset,
|
||||
"actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
"password_hash": base64.StdEncoding.EncodeToString(passwordHash),
|
||||
}
|
||||
command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{
|
||||
TenantID: tenant.Id,
|
||||
TenantCode: tenant.Code,
|
||||
EventType: consts.ImAdminCommandUserPasswordReset,
|
||||
BizType: "user",
|
||||
BizID: strconv.FormatInt(userId, 10),
|
||||
Actor: &imkafka.Actor{
|
||||
Type: "admin",
|
||||
ID: strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
DisplayName: actorName,
|
||||
},
|
||||
Payload: payload,
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
})
|
||||
return s.syncUserAction(ctx, tenant, userId, consts.ImAdminCommandUserPasswordReset, payload)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) syncUserAction(ctx context.Context, tenant *entity.ImTenant, userId int64, eventType string, payload map[string]any) (*sysin.ImUserCommandModel, error) {
|
||||
var out struct {
|
||||
UserId int64 `json:"user_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result"`
|
||||
}
|
||||
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users/" + strconv.FormatInt(userId, 10) + "/action"
|
||||
err := imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := out.Status
|
||||
if status == "" {
|
||||
status = consts.ImAdminCommandStatusSucceeded
|
||||
}
|
||||
return &sysin.ImUserCommandModel{
|
||||
CommandID: command.CommandID,
|
||||
EventID: command.EventID,
|
||||
Status: command.Status,
|
||||
TenantID: command.TenantID,
|
||||
TenantCode: command.TenantCode,
|
||||
EventType: command.EventType,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
Status: status,
|
||||
TenantID: tenant.Id,
|
||||
TenantCode: tenant.Code,
|
||||
EventType: eventType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ type ImGroupListModel struct {
|
||||
TenantName string `json:"tenantName" dc:"租户名称"`
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
State int `json:"state" dc:"状态"`
|
||||
OwnerId int64 `json:"ownerId" dc:"群主ID"`
|
||||
OwnerId int64 `json:"ownerId,string" dc:"群主ID"`
|
||||
OwnerNickname string `json:"ownerNickname" dc:"群主昵称"`
|
||||
OwnerUsername string `json:"ownerUsername" dc:"群主用户名"`
|
||||
SeqId int `json:"seqId" dc:"最新消息序号"`
|
||||
@@ -101,7 +101,7 @@ func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) {
|
||||
}
|
||||
|
||||
type ImGroupMemberListModel struct {
|
||||
UserId int64 `json:"userId" dc:"用户ID"`
|
||||
UserId int64 `json:"userId,string" dc:"用户ID"`
|
||||
Nickname string `json:"nickname" dc:"昵称"`
|
||||
State int `json:"state" dc:"用户状态"`
|
||||
ModeWant string `json:"modeWant" dc:"期望权限"`
|
||||
@@ -119,10 +119,10 @@ type ImGroupMemberListModel struct {
|
||||
Pinned int `json:"pinned" dc:"置顶"`
|
||||
MuteNotifications int `json:"muteNotifications" dc:"免打扰"`
|
||||
JoinSource string `json:"joinSource" dc:"加入来源"`
|
||||
JoinedBy int64 `json:"joinedBy" dc:"邀请/处理人"`
|
||||
JoinedBy int64 `json:"joinedBy,string" dc:"邀请/处理人"`
|
||||
JoinedAt *gtime.Time `json:"joinedAt" dc:"业务加入时间"`
|
||||
LeftAt *gtime.Time `json:"leftAt" dc:"离开时间"`
|
||||
RemovedBy int64 `json:"removedBy" dc:"移除人"`
|
||||
RemovedBy int64 `json:"removedBy,string" dc:"移除人"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ type ImGroupGovernanceModel struct {
|
||||
TenantId int64 `json:"tenantId" dc:"租户ID"`
|
||||
TenantName string `json:"tenantName" dc:"租户名称"`
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
OwnerUserId int64 `json:"ownerUserId" dc:"群主ID"`
|
||||
OwnerUserId int64 `json:"ownerUserId,string" dc:"群主ID"`
|
||||
Name string `json:"name" dc:"群名称"`
|
||||
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
|
||||
Description string `json:"description" dc:"简介"`
|
||||
@@ -157,8 +157,8 @@ type ImGroupGovernanceModel struct {
|
||||
MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"`
|
||||
MemberCount int `json:"memberCount" dc:"成员数"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
CreatedBy int64 `json:"createdBy" dc:"创建人"`
|
||||
DissolvedBy int64 `json:"dissolvedBy" dc:"解散人"`
|
||||
CreatedBy int64 `json:"createdBy,string" dc:"创建人"`
|
||||
DissolvedBy int64 `json:"dissolvedBy,string" dc:"解散人"`
|
||||
DissolvedAt *gtime.Time `json:"dissolvedAt" dc:"解散时间"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
|
||||
@@ -338,13 +338,13 @@ type ImGroupJoinRequestListModel struct {
|
||||
Id int64 `json:"id" dc:"ID"`
|
||||
RequestId string `json:"requestId" dc:"申请ID"`
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"`
|
||||
InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"`
|
||||
ApplicantUserId int64 `json:"applicantUserId,string" dc:"申请人ID"`
|
||||
InviterUserId int64 `json:"inviterUserId,string" dc:"邀请人ID"`
|
||||
Source string `json:"source" dc:"来源"`
|
||||
Message string `json:"message" dc:"申请消息"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
RejectReason string `json:"rejectReason" dc:"拒绝原因"`
|
||||
HandledBy int64 `json:"handledBy" dc:"处理人"`
|
||||
HandledBy int64 `json:"handledBy,string" dc:"处理人"`
|
||||
HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
@@ -368,13 +368,13 @@ type ImGroupFileListModel struct {
|
||||
FileId string `json:"fileId" dc:"文件ID"`
|
||||
SourceTopic string `json:"sourceTopic" dc:"来源Topic"`
|
||||
SourceSeq int `json:"sourceSeq" dc:"来源消息序号"`
|
||||
UploaderId int64 `json:"uploaderId" dc:"上传人ID"`
|
||||
UploaderId int64 `json:"uploaderId,string" dc:"上传人ID"`
|
||||
FileName string `json:"fileName" dc:"文件名"`
|
||||
MimeType string `json:"mimeType" dc:"MIME类型"`
|
||||
FileType string `json:"fileType" dc:"文件类型"`
|
||||
FileSize int64 `json:"fileSize" dc:"文件大小"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
DeletedBy int64 `json:"deletedBy" dc:"删除人"`
|
||||
DeletedBy int64 `json:"deletedBy,string" dc:"删除人"`
|
||||
DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
|
||||
@@ -407,8 +407,8 @@ type ImGroupInvitationListModel struct {
|
||||
Id int64 `json:"id" dc:"ID"`
|
||||
InvitationId string `json:"invitationId" dc:"邀请ID"`
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"`
|
||||
TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"`
|
||||
InviterUserId int64 `json:"inviterUserId,string" dc:"邀请人ID"`
|
||||
TargetUserId int64 `json:"targetUserId,string" dc:"目标用户ID"`
|
||||
Message string `json:"message" dc:"邀请消息"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
Result string `json:"result" dc:"结果"`
|
||||
@@ -429,12 +429,12 @@ type ImGroupAnnouncementListModel struct {
|
||||
Id int64 `json:"id" dc:"ID"`
|
||||
AnnouncementId string `json:"announcementId" dc:"公告ID"`
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
AuthorUserId int64 `json:"authorUserId" dc:"发布人ID"`
|
||||
AuthorUserId int64 `json:"authorUserId,string" dc:"发布人ID"`
|
||||
Content string `json:"content" dc:"内容"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
PublishedAt *gtime.Time `json:"publishedAt" dc:"发布时间"`
|
||||
DeletedBy int64 `json:"deletedBy" dc:"删除人"`
|
||||
DeletedBy int64 `json:"deletedBy,string" dc:"删除人"`
|
||||
DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
|
||||
@@ -449,13 +449,13 @@ type ImGroupMuteListInp struct {
|
||||
|
||||
type ImGroupMuteListModel struct {
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
UserId int64 `json:"userId" dc:"用户ID"`
|
||||
MutedBy int64 `json:"mutedBy" dc:"禁言人"`
|
||||
UserId int64 `json:"userId,string" dc:"用户ID"`
|
||||
MutedBy int64 `json:"mutedBy,string" dc:"禁言人"`
|
||||
MutedAt *gtime.Time `json:"mutedAt" dc:"禁言时间"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
|
||||
Reason string `json:"reason" dc:"原因"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
RevokedBy int64 `json:"revokedBy" dc:"解除人"`
|
||||
RevokedBy int64 `json:"revokedBy,string" dc:"解除人"`
|
||||
RevokedAt *gtime.Time `json:"revokedAt" dc:"解除时间"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
@@ -473,8 +473,8 @@ type ImGroupAdminLogListModel struct {
|
||||
Id int64 `json:"id" dc:"ID"`
|
||||
LogId string `json:"logId" dc:"日志ID"`
|
||||
Topic string `json:"topic" dc:"Topic"`
|
||||
OperatorId int64 `json:"operatorId" dc:"操作人"`
|
||||
TargetUserId int64 `json:"targetUserId" dc:"目标用户"`
|
||||
OperatorId int64 `json:"operatorId,string" dc:"操作人"`
|
||||
TargetUserId int64 `json:"targetUserId,string" dc:"目标用户"`
|
||||
Action string `json:"action" dc:"动作"`
|
||||
ObjectType string `json:"objectType" dc:"对象类型"`
|
||||
ObjectId string `json:"objectId" dc:"对象ID"`
|
||||
|
||||
@@ -264,17 +264,17 @@ type (
|
||||
// Governance 获取IM群治理配置
|
||||
Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error)
|
||||
// GovernanceUpdate 更新IM群治理配置
|
||||
GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error)
|
||||
GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// ProfileUpdate 修改IM群资料
|
||||
ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// Dissolve 解散IM群
|
||||
Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error)
|
||||
Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// Mute 禁言IM群成员
|
||||
Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error)
|
||||
Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// MemberMute Kafka禁言IM群成员
|
||||
MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// Unmute 解除IM群成员禁言
|
||||
Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error)
|
||||
Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// MemberUnmute Kafka解除IM群成员禁言
|
||||
MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// MemberRemove 移出IM群成员
|
||||
@@ -284,13 +284,13 @@ type (
|
||||
// JoinRequestReject 拒绝IM入群申请
|
||||
JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error)
|
||||
// RoleUpdate 更新IM群成员角色
|
||||
RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error)
|
||||
RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// AdminSet 设置IM群管理员
|
||||
AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// AdminRemove 删除IM群管理员
|
||||
AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// OwnerTransfer 转让IM群主
|
||||
OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error)
|
||||
OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// JoinRequestList 获取IM入群申请列表
|
||||
JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error)
|
||||
// FileList 获取IM群文件列表
|
||||
|
||||
@@ -132,8 +132,8 @@
|
||||
};
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiMap[actionForm.action](params);
|
||||
message.success(`命令已提交:${res?.commandId || '-'}`);
|
||||
await apiMap[actionForm.action](params);
|
||||
message.success('操作已完成');
|
||||
emit('reload');
|
||||
closeForm();
|
||||
} finally {
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
userId: Number(record.userId),
|
||||
reason: '后台强制下线',
|
||||
});
|
||||
message.success('强制下线命令已提交');
|
||||
message.success('强制下线已完成');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
@@ -470,7 +470,7 @@
|
||||
userId: Number(record.userId),
|
||||
reason: banning ? '后台封禁' : '后台解封',
|
||||
});
|
||||
message.success(`${banning ? '封禁' : '解封'}命令已提交`);
|
||||
message.success(`${banning ? '封禁' : '解封'}已完成`);
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
@@ -488,7 +488,7 @@
|
||||
userId: Number(record.userId),
|
||||
reason: '后台强制注销',
|
||||
});
|
||||
message.success('强制注销命令已提交');
|
||||
message.success('强制注销已完成');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user