From 8b201185705e82494ef7d68cb39e96c4dd9053c5 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 18 Sep 2026 14:51:40 +0800 Subject: [PATCH] feat: enforce youth mode policy and improve search --- docs/IM-App版本更新前后端对接文档.md | 11 +- docs/IM-前后端对接文档.md | 13 +- docs/IM-剩余问题修复方案.md | 227 ++++++++++++++++++ docs/IM-青少年模式前后端对接文档.md | 10 +- docs/对接文档最新/IM-前后端对接文档.md | 13 +- .../db/mysql/report_group_regression_test.go | 47 ++-- server/db/mysql/tenant_adapter.go | 142 +++++++++-- server/im_http.go | 6 +- server/im_http_test.go | 29 ++- server/im_security_policy_runtime.go | 93 ++++++- server/im_security_policy_runtime_test.go | 35 +++ server/moderation/extractor.go | 1 + server/topic.go | 10 + 13 files changed, 567 insertions(+), 70 deletions(-) create mode 100644 docs/IM-剩余问题修复方案.md create mode 100644 server/im_security_policy_runtime_test.go diff --git a/docs/IM-App版本更新前后端对接文档.md b/docs/IM-App版本更新前后端对接文档.md index bd5c6b8..493708d 100644 --- a/docs/IM-App版本更新前后端对接文档.md +++ b/docs/IM-App版本更新前后端对接文档.md @@ -2,11 +2,12 @@ ## 1. 适用范围 -本文档用于 iOS、Android 客户端检查新版本,以及执行推荐更新和强制更新。 +本文档用于 iOS、Android、Web 客户端检查新版本,以及执行推荐更新和强制更新。 - 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。 - iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。 - Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。 +- Web 端没有安装包时,`store_url` 和 `package_url` 可以为空,使用 `update_type`、`min_supported_version` 和 `release_notes` 控制强刷、兼容提醒或维护提示。 - 客户端不直接访问数据库,也不自行拼接 OSS 地址。 ## 2. 检查版本接口 @@ -22,14 +23,14 @@ GET https://api.118im.com/v1/im/app/releases/latest | Header | 必填 | 示例 | 说明 | | --- | --- | --- | --- | | `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 | -| `X-Client-Platform` | 建议 | `ios` / `android` | 客户端平台 | +| `X-Client-Platform` | 建议 | `ios` / `android` / `web` | 客户端平台 | ### 2.2 Query 参数 | 参数 | 必填 | 示例 | 说明 | | --- | --- | --- | --- | | `apikey` | 是 | `AQAAAA...` | 应用 API Key | -| `platform` | 是 | `android` | 仅支持 `ios`、`android` | +| `platform` | 是 | `android` | 仅支持 `ios`、`android`、`web` | | `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 | | `channel` | 否 | `stable` | 发布渠道,默认 `stable` | @@ -190,7 +191,7 @@ shasum -a 256 meetingim-1.3.0.apk | --- | --- | | 400 | 缺少 `X-IM-Schema: 1` | | 400 | 缺少或错误的 `current_version` | -| 400 | `platform` 不是 `ios` 或 `android` | +| 400 | `platform` 不是 `ios`、`android` 或 `web` | | 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 | ## 9. 当前测试环境 @@ -203,4 +204,4 @@ API Key: AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy 默认渠道: 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。 diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 4fd29eb..fecb6c1 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -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 /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"}` | -| `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. 消息首页、全局导航与系统通知 @@ -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/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 - 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 +- 搜索匹配字段包括文本正文、`content.text/txt/content/title/summary/name`、文件名、链接标题/摘要、卡片/转发快照等可展示文本;排序按 `seq desc`,`next_cursor` 继续向更早消息翻页。 ### 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)。 -- `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。 - Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。 - 版本策略由 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`。 - 开启链路中,短信校验返回的 `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` 同时返回受限模块和动作。 -- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。 +- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,当前已覆盖夜间音视频呼叫发起;前端隐藏只是体验优化,不能作为安全控制。 - 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 ### 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"}` | | `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"}` | -| `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/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/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}` | diff --git a/docs/IM-剩余问题修复方案.md b/docs/IM-剩余问题修复方案.md new file mode 100644 index 0000000..bf2ae92 --- /dev/null +++ b/docs/IM-剩余问题修复方案.md @@ -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 | 青少年模式业务限制 | 涉及多个入口和策略口径,需要独立设计和联调,不能只改一个接口。 | diff --git a/docs/IM-青少年模式前后端对接文档.md b/docs/IM-青少年模式前后端对接文档.md index b1bca93..078f8c6 100644 --- a/docs/IM-青少年模式前后端对接文档.md +++ b/docs/IM-青少年模式前后端对接文档.md @@ -23,7 +23,7 @@ Idempotency-Key: 测试环境验证码固定为 `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. 开启青少年模式 @@ -47,13 +47,13 @@ GET /v1/im/settings/youth-mode "code": "night_quiet", "title": "深夜免打扰", "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", - "enforced": false + "enforced": true }, { "code": "content_filter", "title": "内容过滤", "description": "对不适合青少年的内容进行识别和限制展示。", - "enforced": false + "enforced": true }, { "code": "payment_guard", @@ -202,13 +202,13 @@ POST /v1/im/settings/youth-mode/enable "code": "night_quiet", "title": "深夜免打扰", "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", - "enforced": false + "enforced": true }, { "code": "content_filter", "title": "内容过滤", "description": "对不适合青少年的内容进行识别和限制展示。", - "enforced": false + "enforced": true }, { "code": "payment_guard", diff --git a/docs/对接文档最新/IM-前后端对接文档.md b/docs/对接文档最新/IM-前后端对接文档.md index 1b0a148..e4be195 100644 --- a/docs/对接文档最新/IM-前后端对接文档.md +++ b/docs/对接文档最新/IM-前后端对接文档.md @@ -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 /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"}` | -| `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. 消息首页、全局导航与系统通知 @@ -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/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 - 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 +- 搜索匹配字段包括文本正文、`content.text/txt/content/title/summary/name`、文件名、链接标题/摘要、卡片/转发快照等可展示文本;排序按 `seq desc`,`next_cursor` 继续向更早消息翻页。 ### 7.13 查找聊天记录-文件.png @@ -2924,7 +2925,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增 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`。 - 客户端按平台只打开受信任商店/签名下载地址;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`。 - 开启链路中,短信校验返回的 `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` 同时返回受限模块和动作。 -- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。 +- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,当前已覆盖夜间音视频呼叫发起;前端隐藏只是体验优化,不能作为安全控制。 - 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 ### 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"}` | | `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"}` | -| `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/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/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}` | diff --git a/server/db/mysql/report_group_regression_test.go b/server/db/mysql/report_group_regression_test.go index 8fb9f3f..bc01ac9 100644 --- a/server/db/mysql/report_group_regression_test.go +++ b/server/db/mysql/report_group_regression_test.go @@ -10,31 +10,6 @@ import ( "github.com/tinode/chat/server/store/types" ) -func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) { - if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) { - t.Fatal("all visibility should be allowed to use managed report scope") - } - for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} { - if canUseManagedReports(organizationVisibility{Scope: scope}) { - t.Fatalf("scope %q should not be allowed to use managed report scope", scope) - } - } -} - -func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) { - where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops") - joined := strings.Join(where, " AND ") - if !strings.Contains(joined, "m.department_id=?") { - t.Fatalf("department filter missing from where: %s", joined) - } - if !strings.Contains(joined, "im_friendships") { - t.Fatalf("friend visibility filter missing from where: %s", joined) - } - if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend { - t.Fatalf("unexpected args: %#v", args) - } -} - func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) { mode := groupAdminAccessMode() if !mode.IsAdmin() { @@ -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) { source, err := os.ReadFile("tenant_adapter.go") if err != nil { diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 2e159a9..94f939b 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -2703,10 +2703,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu args = append(args, parsed) } queryText = strings.TrimSpace(queryText) - if queryText != "" { - where = append(where, "CAST(m.content AS CHAR) LIKE ?") - args = append(args, "%"+queryText+"%") - } if messageType != "" && messageType != "all" { 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) @@ -2715,8 +2711,9 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu if cancel != nil { defer cancel() } + scanLimit := messageSearchScanLimit(limit, queryText) queryArgs := append([]any{decodeUID(uid)}, args...) - queryArgs = append(queryArgs, limit+1) + queryArgs = append(queryArgs, scanLimit) 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 "+ "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) nextCursor := "" lastSeq := int64(0) + scanned := 0 for rows.Next() { row := struct { t.Message @@ -2738,16 +2736,24 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu return nil, "", err } msg := finalizeTenantMessage(id, row.Message, row.RecalledBy) - if len(msgs) == limit { - nextCursor = strconv.FormatInt(lastSeq, 10) + scanned++ + lastSeq = msg.SeqId + if !messageMatchesSearch(msg, queryText) { continue } + if len(msgs) == limit { + lastSeq = msgs[len(msgs)-1].SeqId + nextCursor = strconv.FormatInt(lastSeq, 10) + break + } msgs = append(msgs, msg) - lastSeq = msg.SeqId } if err = rows.Err(); err != nil { return nil, "", err } + if nextCursor == "" && scanned == scanLimit && lastSeq > 0 { + nextCursor = strconv.FormatInt(lastSeq, 10) + } return msgs, nextCursor, nil } @@ -2763,10 +2769,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui args = append(args, parsed) } queryText = strings.TrimSpace(queryText) - if queryText != "" { - where = append(where, "CAST(m.content AS CHAR) LIKE ?") - args = append(args, "%"+queryText+"%") - } messageType = strings.TrimSpace(messageType) if messageType != "" && messageType != "all" { 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 { return nil, "", err } - msgs := make([]t.Message, 0, limit+1) - seen := make(map[int64]struct{}, limit+1) + scanLimit := messageSearchScanLimit(limit, queryText) + msgs := make([]t.Message, 0, scanLimit) + seen := make(map[int64]struct{}, scanLimit) for _, table := range tables { - if len(msgs) > limit { + if len(seen) >= scanLimit { break } month := groupMessageMonthFromTable(table) @@ -2792,7 +2795,7 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui idColumn = "message_id" } 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 ")) rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...) if queryErr != nil { @@ -2811,7 +2814,10 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui continue } 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 { rows.Close() @@ -2825,10 +2831,112 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui msgs = msgs[:limit] lastSeq := msgs[len(msgs)-1].SeqId 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 } +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 != "" { + 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) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) diff --git a/server/im_http.go b/server/im_http.go index 05f51f8..9bf985e 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1997,7 +1997,7 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) { if channel == "" { 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"}) return } @@ -8169,13 +8169,13 @@ func youthModeRestrictionItems(restrictions []string) []map[string]any { "code": "night_quiet", "title": "深夜免打扰", "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", - "enforced": false, + "enforced": true, }, "content_filter": { "code": "content_filter", "title": "内容过滤", "description": "对不适合青少年的内容进行识别和限制展示。", - "enforced": false, + "enforced": true, }, "payment_guard": { "code": "payment_guard", diff --git a/server/im_http_test.go b/server/im_http_test.go index 7c97f2a..0283f66 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -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) { oldReleases := store.AppReleases store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{ @@ -2693,9 +2716,13 @@ func TestServeIMYouthModeEnableDisable(t *testing.T) { t.Fatalf("restriction_items: got %#v", items) } 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) } + 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"} expectIMBearer(t, ctrl, "token-disable", 7, 42) diff --git a/server/im_security_policy_runtime.go b/server/im_security_policy_runtime.go index 55b1098..abded7e 100644 --- a/server/im_security_policy_runtime.go +++ b/server/im_security_policy_runtime.go @@ -16,12 +16,17 @@ import ( const ( imReasonFriendAddDisabled = "FRIEND_ADD_DISABLED" imReasonMessageCurfew = "MESSAGE_CURFEW_ACTIVE" + imReasonYouthRestricted = "YOUTH_MODE_RESTRICTED" + + imYouthActionMessage = "message" + imYouthActionCall = "call" ) type imPolicyDecision struct { Allowed bool Reason string Message string + Details map[string]any } func imAllowDecision() imPolicyDecision { @@ -32,6 +37,66 @@ func imDenyDecision(reason, message string) imPolicyDecision { 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) { tenant, err := store.Tenants.GetByID(tenantID) if err != nil || tenant == nil { @@ -313,10 +378,14 @@ func writeIMPolicyDecision(wrt http.ResponseWriter, req *http.Request, decision if reason == "" { reason = imReasonPermissionDenied } - writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, map[string]any{ + details := map[string]any{ "reason": reason, "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 { @@ -328,3 +397,23 @@ func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *Serv } 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 +} diff --git a/server/im_security_policy_runtime_test.go b/server/im_security_policy_runtime_test.go new file mode 100644 index 0000000..673201b --- /dev/null +++ b/server/im_security_policy_runtime_test.go @@ -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) + } +} diff --git a/server/moderation/extractor.go b/server/moderation/extractor.go index d8d7b21..73698d4 100644 --- a/server/moderation/extractor.go +++ b/server/moderation/extractor.go @@ -5,6 +5,7 @@ import "strings" var visibleTextKeys = map[string]bool{ "txt": true, "text": true, "title": true, "caption": true, "description": true, "file_name": true, "filename": true, "name": true, + "url": true, } func ExtractText(content any) string { diff --git a/server/topic.go b/server/topic.go index 8c7c1f3..96b13fb 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1476,6 +1476,12 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow())) 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 { now := types.TimeNow() if t.currentCall.isStaleUnanswered(now, imCallTimeoutSeconds()) { @@ -1496,6 +1502,10 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { msg.Pub.Content = content } 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 { msg.sess.queueOut(imCurfewPolicyReply(msg, decision)) return