feat: enforce youth mode policy and improve search

This commit is contained in:
richjohnrambo
2026-09-18 14:51:40 +08:00
parent 9482bb19f6
commit 8b20118570
13 changed files with 567 additions and 70 deletions
@@ -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
View File
@@ -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&current_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&current_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&current_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。 - `GET /v1/im/app/releases/latest?platform=ios|android|web&current_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}` |
+227
View File
@@ -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&current_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&current_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&current_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&current_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&current_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 - `GET /v1/im/app/releases/latest?platform=ios|android|web&current_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}` |
+22 -25
View File
@@ -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 {
+125 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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&current_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)
+91 -2
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+1
View File
@@ -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 {
+10
View File
@@ -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