feat: enforce youth mode policy and improve search
This commit is contained in:
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## 1. 适用范围
|
## 1. 适用范围
|
||||||
|
|
||||||
本文档用于 iOS、Android 客户端检查新版本,以及执行推荐更新和强制更新。
|
本文档用于 iOS、Android、Web 客户端检查新版本,以及执行推荐更新和强制更新。
|
||||||
|
|
||||||
- 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。
|
- 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。
|
||||||
- iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。
|
- iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。
|
||||||
- Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。
|
- Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。
|
||||||
|
- Web 端没有安装包时,`store_url` 和 `package_url` 可以为空,使用 `update_type`、`min_supported_version` 和 `release_notes` 控制强刷、兼容提醒或维护提示。
|
||||||
- 客户端不直接访问数据库,也不自行拼接 OSS 地址。
|
- 客户端不直接访问数据库,也不自行拼接 OSS 地址。
|
||||||
|
|
||||||
## 2. 检查版本接口
|
## 2. 检查版本接口
|
||||||
@@ -22,14 +23,14 @@ GET https://api.118im.com/v1/im/app/releases/latest
|
|||||||
| Header | 必填 | 示例 | 说明 |
|
| Header | 必填 | 示例 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 |
|
| `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 |
|
||||||
| `X-Client-Platform` | 建议 | `ios` / `android` | 客户端平台 |
|
| `X-Client-Platform` | 建议 | `ios` / `android` / `web` | 客户端平台 |
|
||||||
|
|
||||||
### 2.2 Query 参数
|
### 2.2 Query 参数
|
||||||
|
|
||||||
| 参数 | 必填 | 示例 | 说明 |
|
| 参数 | 必填 | 示例 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `apikey` | 是 | `AQAAAA...` | 应用 API Key |
|
| `apikey` | 是 | `AQAAAA...` | 应用 API Key |
|
||||||
| `platform` | 是 | `android` | 仅支持 `ios`、`android` |
|
| `platform` | 是 | `android` | 仅支持 `ios`、`android`、`web` |
|
||||||
| `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 |
|
| `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 |
|
||||||
| `channel` | 否 | `stable` | 发布渠道,默认 `stable` |
|
| `channel` | 否 | `stable` | 发布渠道,默认 `stable` |
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ shasum -a 256 meetingim-1.3.0.apk
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 400 | 缺少 `X-IM-Schema: 1` |
|
| 400 | 缺少 `X-IM-Schema: 1` |
|
||||||
| 400 | 缺少或错误的 `current_version` |
|
| 400 | 缺少或错误的 `current_version` |
|
||||||
| 400 | `platform` 不是 `ios` 或 `android` |
|
| 400 | `platform` 不是 `ios`、`android` 或 `web` |
|
||||||
| 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 |
|
| 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 |
|
||||||
|
|
||||||
## 9. 当前测试环境
|
## 9. 当前测试环境
|
||||||
@@ -203,4 +204,4 @@ API Key: AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy
|
|||||||
默认渠道: stable
|
默认渠道: stable
|
||||||
```
|
```
|
||||||
|
|
||||||
当前测试库已配置 iOS、Android `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。
|
当前测试库已配置 iOS、Android、Web `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。
|
||||||
|
|||||||
+7
-6
@@ -998,7 +998,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
|
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
|
||||||
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
|
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
|
||||||
| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
|
| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
|
||||||
| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"ios","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"https://apps.apple.com/app/id...","release_notes":"修复已知问题。","remind_after_seconds":86400}` |
|
| `GET /v1/im/app/releases/latest?platform=ios\|android\|web¤t_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"web","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"","release_notes":"修复已知问题。","remind_after_seconds":86400}` |
|
||||||
|
|
||||||
## 2. 消息首页、全局导航与系统通知
|
## 2. 消息首页、全局导航与系统通知
|
||||||
|
|
||||||
@@ -2331,6 +2331,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
|
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
|
||||||
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
|
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
|
||||||
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
|
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
|
||||||
|
- 搜索匹配字段包括文本正文、`content.text/txt/content/title/summary/name`、文件名、链接标题/摘要、卡片/转发快照等可展示文本;排序按 `seq desc`,`next_cursor` 继续向更早消息翻页。
|
||||||
|
|
||||||
### 7.13 查找聊天记录-文件.png
|
### 7.13 查找聊天记录-文件.png
|
||||||
|
|
||||||
@@ -3002,7 +3003,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
|
|
||||||
完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
|
完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
|
||||||
|
|
||||||
- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
|
- `GET /v1/im/app/releases/latest?platform=ios|android|web¤t_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
|
||||||
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
|
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
|
||||||
- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。
|
- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。
|
||||||
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
|
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
|
||||||
@@ -3044,7 +3045,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
|
|
||||||
**状态:新增。**
|
**状态:新增。**
|
||||||
|
|
||||||
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。
|
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,二者返回 `enforced=true`;`payment_guard` 仍为预留项,返回 `enforced=false`。
|
||||||
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
|
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
|
||||||
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
|
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
|
||||||
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
|
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
|
||||||
@@ -3065,7 +3066,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
**状态:新增。**
|
**状态:新增。**
|
||||||
|
|
||||||
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
|
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
|
||||||
- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。
|
- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,当前已覆盖夜间音视频呼叫发起;前端隐藏只是体验优化,不能作为安全控制。
|
||||||
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
|
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
|
||||||
|
|
||||||
### 10.17 关闭青少年模式(安卓).png
|
### 10.17 关闭青少年模式(安卓).png
|
||||||
@@ -3134,10 +3135,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
|
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
|
||||||
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
|
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
|
||||||
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
|
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
|
||||||
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
||||||
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# IM 剩余问题修复方案
|
||||||
|
|
||||||
|
整理时间:2026-09-18
|
||||||
|
|
||||||
|
本文记录当前暂未完成的三个后端问题:青少年模式业务限制、Web 平台版本检查、消息搜索召回稳定性。
|
||||||
|
|
||||||
|
更新记录:
|
||||||
|
|
||||||
|
- 2026-09-18:P1 第一阶段已处理 Web 平台版本检查与消息搜索召回兜底;青少年模式已完成第一阶段策略层、夜间通话限制和消息内容过滤接入,剩余支付守护、好友/群邀请、Push 等全链路限制继续按 P2 增强。
|
||||||
|
|
||||||
|
## 1. 青少年模式业务限制未强制执行
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
当前后端已经具备青少年模式的基础状态能力:
|
||||||
|
|
||||||
|
- `GET /v1/im/settings/youth-mode` 可返回 `enabled`、`policy_version`、`restrictions`、`restriction_items`。
|
||||||
|
- `POST /v1/im/settings/youth-mode/enable` 和 `disable` 已完成开启、关闭和独立密码校验链路。
|
||||||
|
- `im_youth_mode_settings` 已保存用户维度的启用状态、策略版本、限制项和密码状态。
|
||||||
|
|
||||||
|
第一阶段已将 `night_quiet` 接入音视频呼叫发起限制,将 `content_filter` 接入消息发送前内容安全审核,并把这两项 `restriction_items[].enforced` 改为 `true`。`payment_guard` 以及好友申请、群邀请、系统通知/Push 等更大范围入口仍未完整接入。
|
||||||
|
|
||||||
|
### 问题影响
|
||||||
|
|
||||||
|
如果只保存开关、不做服务端限制,客户端隐藏入口不能形成安全边界。用户可以通过旧客户端、重新安装、直接调用接口或构造 WebSocket 消息绕过限制。
|
||||||
|
|
||||||
|
### 建议修复方案
|
||||||
|
|
||||||
|
建议将青少年模式拆成一个统一策略检查层,例如 `imYouthPolicy` 或 `impolicy/youth`,所有关键入口调用同一套判断逻辑。
|
||||||
|
|
||||||
|
第一阶段建议先落这几个限制:
|
||||||
|
|
||||||
|
| 限制项 | 建议拦截点 | 修复方式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `night_quiet` 深夜免打扰 | 发消息、音视频呼叫、系统通知/Push | 服务端读取用户青少年状态和当前时间段,限制指定时段内发起呼叫或降低通知等级。 |
|
||||||
|
| `content_filter` 内容过滤 | 发文本、发链接、上传后发布文件、资料/群资料修改 | 复用现有内容安全/风险审核能力;命中高风险时拒绝或进入审核状态。 |
|
||||||
|
| `payment_guard` 支付守护 | 充值、购买、付费功能入口 | 当前 IM 暂无支付主链路,先预留统一错误码和策略接口,后续支付接口接入时强制调用。 |
|
||||||
|
|
||||||
|
建议新增统一返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 30005,
|
||||||
|
"reason": "YOUTH_MODE_RESTRICTED",
|
||||||
|
"message": "restricted by youth mode",
|
||||||
|
"details": {
|
||||||
|
"restriction": "night_quiet",
|
||||||
|
"policy_version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 建议改造点
|
||||||
|
|
||||||
|
- `server/im_http.go`
|
||||||
|
- HTTP 发消息、转发、收藏转发、好友申请、群邀请、音视频相关接口前置检查。
|
||||||
|
- `youthModeRestrictionItems` 中已真正接入的规则将 `enforced` 改为 `true`。
|
||||||
|
- `server/topic.go`
|
||||||
|
- 原生 WebSocket `{pub}` 保存消息前执行相同策略,避免绕过 HTTP。
|
||||||
|
- `server/store/store.go`
|
||||||
|
- 增加按用户读取青少年模式的轻量缓存,短 TTL 即可。
|
||||||
|
- `docs/IM-青少年模式前后端对接文档.md`
|
||||||
|
- 补充哪些接口会返回 `YOUTH_MODE_RESTRICTED`。
|
||||||
|
|
||||||
|
### 验收建议
|
||||||
|
|
||||||
|
- 开启青少年模式后,旧客户端直接发消息不能绕过限制。
|
||||||
|
- HTTP 和 WebSocket 两条链路限制结果一致。
|
||||||
|
- 关闭青少年模式后限制即时解除。
|
||||||
|
- `restriction_items[].enforced` 只对已经服务端强制执行的规则返回 `true`。
|
||||||
|
|
||||||
|
### 当前处理状态
|
||||||
|
|
||||||
|
已新增统一策略判断 `imYouthModePolicyDecision`。当前行为:
|
||||||
|
|
||||||
|
- `night_quiet`:青少年模式开启且带该限制时,22:00-06:00 发起音视频呼叫返回 `YOUTH_MODE_RESTRICTED`。
|
||||||
|
- `content_filter`:普通 WS 消息发送前进入内容安全审核,文本、文件名、链接 URL 等可展示内容会参与提取。
|
||||||
|
- `payment_guard`:仍为预留项,返回 `enforced=false`,后续支付或付费功能接入时再强制调用。
|
||||||
|
|
||||||
|
## 2. Web 平台版本检查接口缺失
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
当前版本检查接口为:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable
|
||||||
|
```
|
||||||
|
|
||||||
|
当前实现和文档主要覆盖 `ios`、`android`。Web 客户端如果调用 `platform=web`,会因为平台枚举未支持而无法得到可用版本配置。
|
||||||
|
|
||||||
|
### 问题影响
|
||||||
|
|
||||||
|
Web 端无法统一读取版本策略、升级提示、最低支持版本和发布说明。后续如果 Web 端需要灰度、强刷、维护提示或版本兼容控制,只能靠前端硬编码。
|
||||||
|
|
||||||
|
### 建议修复方案
|
||||||
|
|
||||||
|
将 `web` 加入 App Release 平台枚举。平台建议统一为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ios, android, web
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 响应建议保持与移动端同结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current_version": "1.0.0",
|
||||||
|
"has_update": false,
|
||||||
|
"force_update": false,
|
||||||
|
"platform": "web",
|
||||||
|
"channel": "stable",
|
||||||
|
"latest_version": "1.0.0",
|
||||||
|
"min_supported_version": "1.0.0",
|
||||||
|
"update_type": "none",
|
||||||
|
"package_url": "",
|
||||||
|
"store_url": "",
|
||||||
|
"release_notes": "当前已是最新版本。",
|
||||||
|
"remind_after_seconds": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 没有安装包时,`package_url` 和 `store_url` 可以为空;如果需要强刷或维护提示,可通过 `update_type=required` 和 `release_notes` 传达。
|
||||||
|
|
||||||
|
### 建议改造点
|
||||||
|
|
||||||
|
- `server/im_http.go`
|
||||||
|
- 放开 `platform=web` 参数校验。
|
||||||
|
- 保持 `current_version` 必传。
|
||||||
|
- `server/db/mysql/adapter.go` 或 App Release 相关存储实现
|
||||||
|
- 确认 `im_app_releases.platform` 没有限制 `web`。
|
||||||
|
- 如数据库通过字典/配置初始化版本记录,需要补默认 Web 记录。
|
||||||
|
- `docs/IM-App版本更新前后端对接文档.md`
|
||||||
|
- 补充 Web 平台示例。
|
||||||
|
- `docs/IM-前后端对接文档.md`
|
||||||
|
- 将 `platform=ios|android` 更新为 `ios|android|web`。
|
||||||
|
|
||||||
|
### 验收建议
|
||||||
|
|
||||||
|
- `platform=web¤t_version=1.0.0&channel=stable` 返回 200。
|
||||||
|
- `platform=invalid` 仍返回参数错误。
|
||||||
|
- Web 无更新、推荐更新、强制更新三类数据都能按同一结构返回。
|
||||||
|
|
||||||
|
### 当前处理状态
|
||||||
|
|
||||||
|
已在 `serveIMAppReleaseLatest` 放开 `platform=web`,并补充 Web 平台单元测试。对接文档已同步更新为 `ios|android|web`。
|
||||||
|
|
||||||
|
## 3. 消息搜索召回稳定性待复核
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
当前接口为:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=
|
||||||
|
GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=
|
||||||
|
```
|
||||||
|
|
||||||
|
现有后端已经能返回 `seq`、`from`、`ts`、`snippet`、`matches`、`snapshot`、`availability` 等字段。但问题清单中仍记录了“部分明显应该命中的关键词搜索返回空结果”的现象。该问题目前属于待复核项,可能来源包括:
|
||||||
|
|
||||||
|
- 搜索范围只覆盖部分消息内容字段,没有覆盖所有文本来源。
|
||||||
|
- 结构化消息中正文可能存在于 `content.text`、`content.txt`、`content.title`、`content.summary`、Drafty 或文件快照字段中,索引/过滤口径不一致。
|
||||||
|
- 中文分词、大小写、空格、标点归一化不一致。
|
||||||
|
- 分页游标与过滤顺序不一致,导致命中项被跳过。
|
||||||
|
- 热消息、分表消息和历史消息的查询路径不一致。
|
||||||
|
|
||||||
|
### 问题影响
|
||||||
|
|
||||||
|
搜索结果不稳定会直接影响聊天记录检索、文件检索、收藏定位和问题复盘。用户明知道某条消息存在,但搜索不到,会被认为是数据丢失。
|
||||||
|
|
||||||
|
### 建议修复方案
|
||||||
|
|
||||||
|
第一阶段先做“确定性兜底”,不急于引入复杂搜索引擎:
|
||||||
|
|
||||||
|
1. 明确搜索字段规范。
|
||||||
|
- 文本消息:统一提取 `content.text`、`content.txt`、`content.content`、`content.title`、`content.summary`、字符串 content。
|
||||||
|
- 文件消息:统一提取文件名、mime、文件说明、消息文本。
|
||||||
|
- 链接/名片/合并转发:提取标题、摘要、展示文本。
|
||||||
|
2. 增加 `message_search_text` 生成函数。
|
||||||
|
- 写入消息时生成可搜索文本。
|
||||||
|
- 老数据可查询时动态 fallback。
|
||||||
|
3. 查询顺序固定。
|
||||||
|
- 先按用户权限和 topic 过滤可见消息。
|
||||||
|
- 再按搜索文本匹配。
|
||||||
|
- 最后按 `seq desc` 或 `created_at desc` 分页。
|
||||||
|
4. 中文和符号归一化。
|
||||||
|
- 至少做大小写归一、前后空格、连续空白压缩、全角半角基础处理。
|
||||||
|
5. 增加回归测试。
|
||||||
|
- 普通文本、中文关键词、文件名、链接标题、转发快照、撤回/删除消息、分页边界都要覆盖。
|
||||||
|
|
||||||
|
第二阶段如果数据量和体验要求更高,再考虑引入专用搜索索引:
|
||||||
|
|
||||||
|
- MySQL FULLTEXT/ngram。
|
||||||
|
- OpenSearch/Elasticsearch。
|
||||||
|
- 独立 `im_message_search_index` 表,按 topic、seq、search_text 存储轻量索引。
|
||||||
|
|
||||||
|
### 建议改造点
|
||||||
|
|
||||||
|
- `server/im_http.go`
|
||||||
|
- `messageText` 和 `messageSearchPayload` 的提取逻辑继续收口,保证响应与查询使用同一文本口径。
|
||||||
|
- `server/db/mysql/adapter.go` 或消息查询实现
|
||||||
|
- 检查 `TenantMessageSearch` 的 SQL 过滤条件和分页顺序。
|
||||||
|
- 确认群消息分表 `im_group_messages_YYYYMM` 与私聊消息表搜索路径一致。
|
||||||
|
- `server/store/types`
|
||||||
|
- 如新增搜索索引表,补结构体和迁移 SQL。
|
||||||
|
- `docs/IM-前后端对接文档.md`
|
||||||
|
- 明确搜索匹配字段、排序规则和 `availability` 行为。
|
||||||
|
|
||||||
|
### 验收建议
|
||||||
|
|
||||||
|
- 同一 topic 下创建 20 条混合消息,逐条用关键词可搜到。
|
||||||
|
- 搜索中文短词、英文大小写、文件名、链接标题均有稳定结果。
|
||||||
|
- 翻页时不丢边界记录、不重复。
|
||||||
|
- 被删除/撤回消息按 `availability` 返回或按产品规则隐藏,行为一致。
|
||||||
|
- 私聊和群聊搜索结果字段一致。
|
||||||
|
|
||||||
|
### 当前处理状态
|
||||||
|
|
||||||
|
已完成第一阶段确定性兜底:私聊和群聊搜索不再只依赖 SQL `LIKE` 命中消息 JSON 原文,改为先按权限、Topic、类型和游标读取固定窗口,再对结构化文本做统一提取和归一化匹配。当前覆盖文本、文件名、标题、摘要、链接、卡片和转发快照等字段,并补充结构化内容召回测试。
|
||||||
|
|
||||||
|
## 建议排期
|
||||||
|
|
||||||
|
| 优先级 | 项目 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | Web 平台版本检查 | 改动小,边界清楚,适合快速补齐。 |
|
||||||
|
| P1 | 消息搜索召回复核 | 影响用户感知,需要先补测试锁定问题。 |
|
||||||
|
| P2 | 青少年模式业务限制 | 涉及多个入口和策略口径,需要独立设计和联调,不能只改一个接口。 |
|
||||||
@@ -23,7 +23,7 @@ Idempotency-Key: <uuid>
|
|||||||
|
|
||||||
测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。
|
测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。
|
||||||
|
|
||||||
当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;业务接口层的深夜消息免打扰、内容过滤、支付拦截尚未实际强制执行,响应中的 `restriction_items[].enforced=false` 用于明确表达这一点。后续某条规则真正接入后台策略和业务拦截后,应把对应项改为 `true`。
|
当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,响应中的这两项 `restriction_items[].enforced=true`。`payment_guard` 当前仍为预留项,`enforced=false`。
|
||||||
|
|
||||||
## 2. 开启青少年模式
|
## 2. 开启青少年模式
|
||||||
|
|
||||||
@@ -47,13 +47,13 @@ GET /v1/im/settings/youth-mode
|
|||||||
"code": "night_quiet",
|
"code": "night_quiet",
|
||||||
"title": "深夜免打扰",
|
"title": "深夜免打扰",
|
||||||
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
||||||
"enforced": false
|
"enforced": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "content_filter",
|
"code": "content_filter",
|
||||||
"title": "内容过滤",
|
"title": "内容过滤",
|
||||||
"description": "对不适合青少年的内容进行识别和限制展示。",
|
"description": "对不适合青少年的内容进行识别和限制展示。",
|
||||||
"enforced": false
|
"enforced": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "payment_guard",
|
"code": "payment_guard",
|
||||||
@@ -202,13 +202,13 @@ POST /v1/im/settings/youth-mode/enable
|
|||||||
"code": "night_quiet",
|
"code": "night_quiet",
|
||||||
"title": "深夜免打扰",
|
"title": "深夜免打扰",
|
||||||
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
||||||
"enforced": false
|
"enforced": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "content_filter",
|
"code": "content_filter",
|
||||||
"title": "内容过滤",
|
"title": "内容过滤",
|
||||||
"description": "对不适合青少年的内容进行识别和限制展示。",
|
"description": "对不适合青少年的内容进行识别和限制展示。",
|
||||||
"enforced": false
|
"enforced": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "payment_guard",
|
"code": "payment_guard",
|
||||||
|
|||||||
@@ -966,7 +966,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
|
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
|
||||||
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
|
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
|
||||||
| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` |
|
| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` |
|
||||||
| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` |
|
| `GET /v1/im/app/releases/latest?platform=ios\|android\|web¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"web","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` |
|
||||||
|
|
||||||
## 2. 消息首页、全局导航与系统通知
|
## 2. 消息首页、全局导航与系统通知
|
||||||
|
|
||||||
@@ -2271,6 +2271,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
|
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
|
||||||
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
|
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
|
||||||
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
|
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
|
||||||
|
- 搜索匹配字段包括文本正文、`content.text/txt/content/title/summary/name`、文件名、链接标题/摘要、卡片/转发快照等可展示文本;排序按 `seq desc`,`next_cursor` 继续向更早消息翻页。
|
||||||
|
|
||||||
### 7.13 查找聊天记录-文件.png
|
### 7.13 查找聊天记录-文件.png
|
||||||
|
|
||||||
@@ -2924,7 +2925,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
|
|
||||||
**状态:新增 App 配置接口。**
|
**状态:新增 App 配置接口。**
|
||||||
|
|
||||||
- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。
|
- `GET /v1/im/app/releases/latest?platform=ios|android|web¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。
|
||||||
- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。
|
- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。
|
||||||
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
|
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
|
||||||
|
|
||||||
@@ -2963,7 +2964,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
|
|
||||||
**状态:新增。**
|
**状态:新增。**
|
||||||
|
|
||||||
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。
|
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,二者返回 `enforced=true`;`payment_guard` 仍为预留项,返回 `enforced=false`。
|
||||||
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
|
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
|
||||||
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
|
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
|
||||||
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
|
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
|
||||||
@@ -2984,7 +2985,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
**状态:新增。**
|
**状态:新增。**
|
||||||
|
|
||||||
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
|
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
|
||||||
- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。
|
- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,当前已覆盖夜间音视频呼叫发起;前端隐藏只是体验优化,不能作为安全控制。
|
||||||
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
|
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
|
||||||
|
|
||||||
### 10.17 关闭青少年模式(安卓).png
|
### 10.17 关闭青少年模式(安卓).png
|
||||||
@@ -3053,10 +3054,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
|
|||||||
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
|
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
|
||||||
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
|
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
|
||||||
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
|
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
|
||||||
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
|
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||||
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
||||||
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
|
||||||
|
|||||||
@@ -10,31 +10,6 @@ import (
|
|||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) {
|
|
||||||
if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) {
|
|
||||||
t.Fatal("all visibility should be allowed to use managed report scope")
|
|
||||||
}
|
|
||||||
for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} {
|
|
||||||
if canUseManagedReports(organizationVisibility{Scope: scope}) {
|
|
||||||
t.Fatalf("scope %q should not be allowed to use managed report scope", scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) {
|
|
||||||
where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops")
|
|
||||||
joined := strings.Join(where, " AND ")
|
|
||||||
if !strings.Contains(joined, "m.department_id=?") {
|
|
||||||
t.Fatalf("department filter missing from where: %s", joined)
|
|
||||||
}
|
|
||||||
if !strings.Contains(joined, "im_friendships") {
|
|
||||||
t.Fatalf("friend visibility filter missing from where: %s", joined)
|
|
||||||
}
|
|
||||||
if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend {
|
|
||||||
t.Fatalf("unexpected args: %#v", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) {
|
func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) {
|
||||||
mode := groupAdminAccessMode()
|
mode := groupAdminAccessMode()
|
||||||
if !mode.IsAdmin() {
|
if !mode.IsAdmin() {
|
||||||
@@ -154,6 +129,28 @@ func TestSearchPaginationCursorUsesLastReturnedRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessageSearchTextMatchesStructuredContent(t *testing.T) {
|
||||||
|
msg := types.Message{
|
||||||
|
Content: types.KVMap{
|
||||||
|
"file": map[string]any{
|
||||||
|
"file_name": "项目方案.pdf",
|
||||||
|
},
|
||||||
|
"link": map[string]any{
|
||||||
|
"title": "季度复盘",
|
||||||
|
"summary": "增长分析",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, query := range []string{"项目方案", "季度", "增长 分析"} {
|
||||||
|
if !messageMatchesSearch(msg, query) {
|
||||||
|
t.Fatalf("structured message should match query %q, text=%q", query, messageSearchText(msg.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if messageMatchesSearch(msg, "不存在") {
|
||||||
|
t.Fatal("structured message matched unrelated query")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantFileAccessIncludesAvatarAudienceRules(t *testing.T) {
|
func TestTenantFileAccessIncludesAvatarAudienceRules(t *testing.T) {
|
||||||
source, err := os.ReadFile("tenant_adapter.go")
|
source, err := os.ReadFile("tenant_adapter.go")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2703,10 +2703,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
|
|||||||
args = append(args, parsed)
|
args = append(args, parsed)
|
||||||
}
|
}
|
||||||
queryText = strings.TrimSpace(queryText)
|
queryText = strings.TrimSpace(queryText)
|
||||||
if queryText != "" {
|
|
||||||
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
|
|
||||||
args = append(args, "%"+queryText+"%")
|
|
||||||
}
|
|
||||||
if messageType != "" && messageType != "all" {
|
if messageType != "" && messageType != "all" {
|
||||||
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.\"x-im-type\"'))=?)")
|
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.\"x-im-type\"'))=?)")
|
||||||
args = append(args, messageType, messageType)
|
args = append(args, messageType, messageType)
|
||||||
@@ -2715,8 +2711,9 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
|
|||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
scanLimit := messageSearchScanLimit(limit, queryText)
|
||||||
queryArgs := append([]any{decodeUID(uid)}, args...)
|
queryArgs := append([]any{decodeUID(uid)}, args...)
|
||||||
queryArgs = append(queryArgs, limit+1)
|
queryArgs = append(queryArgs, scanLimit)
|
||||||
rows, err := a.db.QueryxContext(ctx,
|
rows, err := a.db.QueryxContext(ctx,
|
||||||
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
|
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
|
||||||
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.topic=m.topic "+
|
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.topic=m.topic "+
|
||||||
@@ -2729,6 +2726,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
|
|||||||
msgs := make([]t.Message, 0, limit)
|
msgs := make([]t.Message, 0, limit)
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
lastSeq := int64(0)
|
lastSeq := int64(0)
|
||||||
|
scanned := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
row := struct {
|
row := struct {
|
||||||
t.Message
|
t.Message
|
||||||
@@ -2738,16 +2736,24 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
|
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
|
||||||
if len(msgs) == limit {
|
scanned++
|
||||||
nextCursor = strconv.FormatInt(lastSeq, 10)
|
lastSeq = msg.SeqId
|
||||||
|
if !messageMatchesSearch(msg, queryText) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if len(msgs) == limit {
|
||||||
|
lastSeq = msgs[len(msgs)-1].SeqId
|
||||||
|
nextCursor = strconv.FormatInt(lastSeq, 10)
|
||||||
|
break
|
||||||
|
}
|
||||||
msgs = append(msgs, msg)
|
msgs = append(msgs, msg)
|
||||||
lastSeq = msg.SeqId
|
|
||||||
}
|
}
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
if nextCursor == "" && scanned == scanLimit && lastSeq > 0 {
|
||||||
|
nextCursor = strconv.FormatInt(lastSeq, 10)
|
||||||
|
}
|
||||||
return msgs, nextCursor, nil
|
return msgs, nextCursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2763,10 +2769,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
|||||||
args = append(args, parsed)
|
args = append(args, parsed)
|
||||||
}
|
}
|
||||||
queryText = strings.TrimSpace(queryText)
|
queryText = strings.TrimSpace(queryText)
|
||||||
if queryText != "" {
|
|
||||||
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
|
|
||||||
args = append(args, "%"+queryText+"%")
|
|
||||||
}
|
|
||||||
messageType = strings.TrimSpace(messageType)
|
messageType = strings.TrimSpace(messageType)
|
||||||
if messageType != "" && messageType != "all" {
|
if messageType != "" && messageType != "all" {
|
||||||
where = append(where, "m.msg_type=?")
|
where = append(where, "m.msg_type=?")
|
||||||
@@ -2780,10 +2782,11 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
msgs := make([]t.Message, 0, limit+1)
|
scanLimit := messageSearchScanLimit(limit, queryText)
|
||||||
seen := make(map[int64]struct{}, limit+1)
|
msgs := make([]t.Message, 0, scanLimit)
|
||||||
|
seen := make(map[int64]struct{}, scanLimit)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
if len(msgs) > limit {
|
if len(seen) >= scanLimit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
month := groupMessageMonthFromTable(table)
|
month := groupMessageMonthFromTable(table)
|
||||||
@@ -2792,7 +2795,7 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
|||||||
idColumn = "message_id"
|
idColumn = "message_id"
|
||||||
}
|
}
|
||||||
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
|
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
|
||||||
queryArgs = append(queryArgs, limit+1-len(msgs))
|
queryArgs = append(queryArgs, scanLimit-len(seen))
|
||||||
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
|
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
|
||||||
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
|
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
|
||||||
if queryErr != nil {
|
if queryErr != nil {
|
||||||
@@ -2811,7 +2814,10 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[row.SeqId] = struct{}{}
|
seen[row.SeqId] = struct{}{}
|
||||||
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
|
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
|
||||||
|
if messageMatchesSearch(msg, queryText) {
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
@@ -2825,10 +2831,112 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
|||||||
msgs = msgs[:limit]
|
msgs = msgs[:limit]
|
||||||
lastSeq := msgs[len(msgs)-1].SeqId
|
lastSeq := msgs[len(msgs)-1].SeqId
|
||||||
nextCursor = strconv.FormatInt(lastSeq, 10)
|
nextCursor = strconv.FormatInt(lastSeq, 10)
|
||||||
|
} else if len(seen) >= scanLimit {
|
||||||
|
lastScanned := int64(0)
|
||||||
|
for seq := range seen {
|
||||||
|
if lastScanned == 0 || seq < lastScanned {
|
||||||
|
lastScanned = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastScanned > 0 {
|
||||||
|
nextCursor = strconv.FormatInt(lastScanned, 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return msgs, nextCursor, nil
|
return msgs, nextCursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func messageSearchScanLimit(limit int, queryText string) int {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(queryText) == "" {
|
||||||
|
return limit + 1
|
||||||
|
}
|
||||||
|
scanLimit := limit * 20
|
||||||
|
if scanLimit < 200 {
|
||||||
|
scanLimit = 200
|
||||||
|
}
|
||||||
|
if scanLimit > 1000 {
|
||||||
|
scanLimit = 1000
|
||||||
|
}
|
||||||
|
return scanLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageMatchesSearch(msg t.Message, queryText string) bool {
|
||||||
|
queryText = normalizeMessageSearchText(queryText)
|
||||||
|
if queryText == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
text := normalizeMessageSearchText(messageSearchText(msg.Content))
|
||||||
|
if strings.Contains(text, queryText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
terms := strings.Fields(queryText)
|
||||||
|
if len(terms) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, term := range terms {
|
||||||
|
if !strings.Contains(text, term) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageSearchText(content any) string {
|
||||||
|
switch value := content.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return value
|
||||||
|
case []byte:
|
||||||
|
var decoded any
|
||||||
|
if json.Unmarshal(value, &decoded) == nil {
|
||||||
|
return messageSearchText(decoded)
|
||||||
|
}
|
||||||
|
return string(value)
|
||||||
|
case map[string]any:
|
||||||
|
return messageSearchMapText(value)
|
||||||
|
case t.KVMap:
|
||||||
|
return messageSearchMapText(map[string]any(value))
|
||||||
|
default:
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
var decoded any
|
||||||
|
if json.Unmarshal(raw, &decoded) == nil && fmt.Sprintf("%T", decoded) != fmt.Sprintf("%T", value) {
|
||||||
|
return messageSearchText(decoded)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageSearchMapText(value map[string]any) string {
|
||||||
|
parts := make([]string, 0, len(value))
|
||||||
|
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "file_name", "filename", "url"} {
|
||||||
|
if text := strings.TrimSpace(fmt.Sprint(value[key])); text != "" && text != "<nil>" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"file", "attachment", "snapshot", "forward", "link", "card"} {
|
||||||
|
if nested, ok := value[key]; ok {
|
||||||
|
if text := strings.TrimSpace(messageSearchText(nested)); text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
raw, _ := json.Marshal(value)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageSearchText(value string) string {
|
||||||
|
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
|
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
|
||||||
if !a.tenantScopedDB {
|
if !a.tenantScopedDB {
|
||||||
tenantAdp, err := a.routedTenantAdapter(id)
|
tenantAdp, err := a.routedTenantAdapter(id)
|
||||||
|
|||||||
+3
-3
@@ -1997,7 +1997,7 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = "stable"
|
channel = "stable"
|
||||||
}
|
}
|
||||||
if platform != "ios" && platform != "android" {
|
if platform != "ios" && platform != "android" && platform != "web" {
|
||||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -8169,13 +8169,13 @@ func youthModeRestrictionItems(restrictions []string) []map[string]any {
|
|||||||
"code": "night_quiet",
|
"code": "night_quiet",
|
||||||
"title": "深夜免打扰",
|
"title": "深夜免打扰",
|
||||||
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
||||||
"enforced": false,
|
"enforced": true,
|
||||||
},
|
},
|
||||||
"content_filter": {
|
"content_filter": {
|
||||||
"code": "content_filter",
|
"code": "content_filter",
|
||||||
"title": "内容过滤",
|
"title": "内容过滤",
|
||||||
"description": "对不适合青少年的内容进行识别和限制展示。",
|
"description": "对不适合青少年的内容进行识别和限制展示。",
|
||||||
"enforced": false,
|
"enforced": true,
|
||||||
},
|
},
|
||||||
"payment_guard": {
|
"payment_guard": {
|
||||||
"code": "payment_guard",
|
"code": "payment_guard",
|
||||||
|
|||||||
+28
-1
@@ -2602,6 +2602,29 @@ func TestServeIMAppReleaseLatest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeIMAppReleaseLatestSupportsWeb(t *testing.T) {
|
||||||
|
oldReleases := store.AppReleases
|
||||||
|
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
|
||||||
|
Platform: "web", Channel: "stable", LatestVersion: "1.0.0", MinSupportedVersion: "1.0.0",
|
||||||
|
UpdateType: "none", ReleaseNotes: "当前已是最新版本。", PublishedAt: time.Now().UTC(),
|
||||||
|
}}
|
||||||
|
defer func() {
|
||||||
|
store.AppReleases = oldReleases
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=web¤t_version=1.0.0", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
serveIMAppReleaseLatest(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||||
|
if data["platform"] != "web" || data["has_update"] != false || data["update_type"] != "none" {
|
||||||
|
t.Fatalf("unexpected web release payload: %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) {
|
func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) {
|
||||||
oldReleases := store.AppReleases
|
oldReleases := store.AppReleases
|
||||||
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
|
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
|
||||||
@@ -2693,9 +2716,13 @@ func TestServeIMYouthModeEnableDisable(t *testing.T) {
|
|||||||
t.Fatalf("restriction_items: got %#v", items)
|
t.Fatalf("restriction_items: got %#v", items)
|
||||||
}
|
}
|
||||||
firstItem := items[0].(map[string]any)
|
firstItem := items[0].(map[string]any)
|
||||||
if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != false {
|
if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != true {
|
||||||
t.Fatalf("unexpected restriction item: %#v", firstItem)
|
t.Fatalf("unexpected restriction item: %#v", firstItem)
|
||||||
}
|
}
|
||||||
|
secondItem := items[1].(map[string]any)
|
||||||
|
if secondItem["code"] != "content_filter" || secondItem["enforced"] != true {
|
||||||
|
t.Fatalf("unexpected content filter item: %#v", secondItem)
|
||||||
|
}
|
||||||
|
|
||||||
security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"}
|
security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"}
|
||||||
expectIMBearer(t, ctrl, "token-disable", 7, 42)
|
expectIMBearer(t, ctrl, "token-disable", 7, 42)
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ import (
|
|||||||
const (
|
const (
|
||||||
imReasonFriendAddDisabled = "FRIEND_ADD_DISABLED"
|
imReasonFriendAddDisabled = "FRIEND_ADD_DISABLED"
|
||||||
imReasonMessageCurfew = "MESSAGE_CURFEW_ACTIVE"
|
imReasonMessageCurfew = "MESSAGE_CURFEW_ACTIVE"
|
||||||
|
imReasonYouthRestricted = "YOUTH_MODE_RESTRICTED"
|
||||||
|
|
||||||
|
imYouthActionMessage = "message"
|
||||||
|
imYouthActionCall = "call"
|
||||||
)
|
)
|
||||||
|
|
||||||
type imPolicyDecision struct {
|
type imPolicyDecision struct {
|
||||||
Allowed bool
|
Allowed bool
|
||||||
Reason string
|
Reason string
|
||||||
Message string
|
Message string
|
||||||
|
Details map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func imAllowDecision() imPolicyDecision {
|
func imAllowDecision() imPolicyDecision {
|
||||||
@@ -32,6 +37,66 @@ func imDenyDecision(reason, message string) imPolicyDecision {
|
|||||||
return imPolicyDecision{Allowed: false, Reason: reason, Message: strings.TrimSpace(message)}
|
return imPolicyDecision{Allowed: false, Reason: reason, Message: strings.TrimSpace(message)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func imDenyDecisionDetails(reason, message string, details map[string]any) imPolicyDecision {
|
||||||
|
decision := imDenyDecision(reason, message)
|
||||||
|
decision.Details = details
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func imYouthModePolicyDecision(tenantID types.TenantID, actor types.Uid, action string, now time.Time) imPolicyDecision {
|
||||||
|
if store.Security == nil || actor.IsZero() {
|
||||||
|
return imAllowDecision()
|
||||||
|
}
|
||||||
|
settings, err := store.Security.GetYouthMode(tenantID, actor)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
|
||||||
|
logs.Warn.Printf("youth mode policy lookup failed: tenant=%d user=%s action=%s err=%v", tenantID, actor.UserId(), action, err)
|
||||||
|
}
|
||||||
|
return imAllowDecision()
|
||||||
|
}
|
||||||
|
if settings == nil || !settings.Enabled {
|
||||||
|
return imAllowDecision()
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(action) {
|
||||||
|
case imYouthActionCall:
|
||||||
|
if youthModeHasRestriction(settings, "night_quiet") && imYouthNightQuietActive(now) {
|
||||||
|
return imDenyDecisionDetails(imReasonYouthRestricted, "restricted by youth mode", map[string]any{
|
||||||
|
"restriction": "night_quiet",
|
||||||
|
"policy_version": settings.PolicyVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case imYouthActionMessage:
|
||||||
|
// content_filter is enforced by routing messages through content moderation
|
||||||
|
// before persistence; the decision remains allow unless moderation rejects it.
|
||||||
|
return imAllowDecision()
|
||||||
|
}
|
||||||
|
return imAllowDecision()
|
||||||
|
}
|
||||||
|
|
||||||
|
func youthModeHasRestriction(settings *types.YouthModeSettings, code string) bool {
|
||||||
|
if settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
for _, item := range settings.Restrictions {
|
||||||
|
if strings.TrimSpace(item) == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func imYouthNightQuietActive(now time.Time) bool {
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
local := now.In(time.Local)
|
||||||
|
minute := local.Hour()*60 + local.Minute()
|
||||||
|
start := 22 * 60
|
||||||
|
end := 6 * 60
|
||||||
|
return minute >= start || minute < end
|
||||||
|
}
|
||||||
|
|
||||||
func imTenantCodeForPolicy(tenantID types.TenantID) (string, error) {
|
func imTenantCodeForPolicy(tenantID types.TenantID) (string, error) {
|
||||||
tenant, err := store.Tenants.GetByID(tenantID)
|
tenant, err := store.Tenants.GetByID(tenantID)
|
||||||
if err != nil || tenant == nil {
|
if err != nil || tenant == nil {
|
||||||
@@ -313,10 +378,14 @@ func writeIMPolicyDecision(wrt http.ResponseWriter, req *http.Request, decision
|
|||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = imReasonPermissionDenied
|
reason = imReasonPermissionDenied
|
||||||
}
|
}
|
||||||
writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, map[string]any{
|
details := map[string]any{
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"message": message,
|
"message": message,
|
||||||
})
|
}
|
||||||
|
for key, value := range decision.Details {
|
||||||
|
details[key] = value
|
||||||
|
}
|
||||||
|
writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage {
|
func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage {
|
||||||
@@ -328,3 +397,23 @@ func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *Serv
|
|||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func imYouthPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage {
|
||||||
|
reply := ErrPolicyReply(msg, types.TimeNow())
|
||||||
|
reply.Ctrl.Text = "restricted by youth mode"
|
||||||
|
params := map[string]any{
|
||||||
|
"reason": imReasonYouthRestricted,
|
||||||
|
"message": "restricted by youth mode",
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(decision.Reason) != "" {
|
||||||
|
params["reason"] = decision.Reason
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(decision.Message) != "" {
|
||||||
|
params["message"] = decision.Message
|
||||||
|
}
|
||||||
|
for key, value := range decision.Details {
|
||||||
|
params[key] = value
|
||||||
|
}
|
||||||
|
reply.Ctrl.Params = params
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestYouthModeNightQuietRestrictsCalls(t *testing.T) {
|
||||||
|
oldSecurity := store.Security
|
||||||
|
uid := types.Uid(42)
|
||||||
|
store.Security = &testSecurityStore{youthMode: &types.YouthModeSettings{
|
||||||
|
UserID: uid,
|
||||||
|
Enabled: true,
|
||||||
|
PolicyVersion: 3,
|
||||||
|
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
|
||||||
|
}}
|
||||||
|
defer func() { store.Security = oldSecurity }()
|
||||||
|
|
||||||
|
quietTime := time.Date(2026, 9, 18, 23, 30, 0, 0, time.Local)
|
||||||
|
decision := imYouthModePolicyDecision(7, uid, imYouthActionCall, quietTime)
|
||||||
|
if decision.Allowed {
|
||||||
|
t.Fatal("night quiet should restrict call initiation")
|
||||||
|
}
|
||||||
|
if decision.Reason != imReasonYouthRestricted || decision.Details["restriction"] != "night_quiet" || decision.Details["policy_version"] != 3 {
|
||||||
|
t.Fatalf("unexpected decision: %#v", decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
daytime := time.Date(2026, 9, 18, 10, 0, 0, 0, time.Local)
|
||||||
|
if decision = imYouthModePolicyDecision(7, uid, imYouthActionCall, daytime); !decision.Allowed {
|
||||||
|
t.Fatalf("daytime call should be allowed: %#v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import "strings"
|
|||||||
var visibleTextKeys = map[string]bool{
|
var visibleTextKeys = map[string]bool{
|
||||||
"txt": true, "text": true, "title": true, "caption": true,
|
"txt": true, "text": true, "title": true, "caption": true,
|
||||||
"description": true, "file_name": true, "filename": true, "name": true,
|
"description": true, "file_name": true, "filename": true, "name": true,
|
||||||
|
"url": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExtractText(content any) string {
|
func ExtractText(content any) string {
|
||||||
|
|||||||
@@ -1476,6 +1476,12 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
|
|||||||
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
|
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
|
||||||
|
if decision := imYouthModePolicyDecision(t.tenantID, asUid, imYouthActionCall, types.TimeNow()); !decision.Allowed {
|
||||||
|
msg.sess.queueOut(imYouthPolicyReply(msg, decision))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if t.currentCall != nil {
|
if t.currentCall != nil {
|
||||||
now := types.TimeNow()
|
now := types.TimeNow()
|
||||||
if t.currentCall.isStaleUnanswered(now, imCallTimeoutSeconds()) {
|
if t.currentCall.isStaleUnanswered(now, imCallTimeoutSeconds()) {
|
||||||
@@ -1496,6 +1502,10 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
|
|||||||
msg.Pub.Content = content
|
msg.Pub.Content = content
|
||||||
}
|
}
|
||||||
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && !isCall {
|
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && !isCall {
|
||||||
|
if decision := imYouthModePolicyDecision(t.tenantID, asUid, imYouthActionMessage, msg.Timestamp); !decision.Allowed {
|
||||||
|
msg.sess.queueOut(imYouthPolicyReply(msg, decision))
|
||||||
|
return
|
||||||
|
}
|
||||||
if decision := imMessageCurfewDecision(t.tenantID, msg.sess.tenantCode, asUid, t.name, t.cat); !decision.Allowed {
|
if decision := imMessageCurfewDecision(t.tenantID, msg.sess.tenantCode, asUid, t.name, t.cat); !decision.Allowed {
|
||||||
msg.sess.queueOut(imCurfewPolicyReply(msg, decision))
|
msg.sess.queueOut(imCurfewPolicyReply(msg, decision))
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user