From a94f60c254cfb675eb31c91daadcb4f203effb8a Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 2 Sep 2026 13:47:12 +0800 Subject: [PATCH] Add youth mode restriction labels --- docs/IM-前后端对接文档.md | 10 +++--- docs/IM-青少年模式前后端对接文档.md | 44 ++++++++++++++++++++++- server/im_http.go | 54 +++++++++++++++++++++++++---- server/im_http_test.go | 8 +++++ 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 21efd9f..b0cf3e6 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2402,7 +2402,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。 +- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。 - 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。 - 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。 - 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 @@ -2413,7 +2413,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:弹窗纯客户端;文案由服务端配置。** -- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 +- 使用 10.14 返回的 `restriction_items` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。`restrictions` 仅作为兼容旧客户端的 code 数组。 - 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 ### 10.16 青少年模式已开启(安卓).png @@ -2423,7 +2423,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** - 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 -- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 +- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。 - 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 ### 10.17 关闭青少年模式(安卓).png @@ -2492,10 +2492,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` | | `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | -| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","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"],"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/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 index 943f00a..b1bca93 100644 --- a/docs/IM-青少年模式前后端对接文档.md +++ b/docs/IM-青少年模式前后端对接文档.md @@ -23,6 +23,8 @@ Idempotency-Key: 测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。 +当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;业务接口层的深夜消息免打扰、内容过滤、支付拦截尚未实际强制执行,响应中的 `restriction_items[].enforced=false` 用于明确表达这一点。后续某条规则真正接入后台策略和业务拦截后,应把对应项改为 `true`。 + ## 2. 开启青少年模式 ### 2.1 查询当前状态 @@ -40,6 +42,26 @@ GET /v1/im/settings/youth-mode "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" @@ -47,7 +69,7 @@ GET /v1/im/settings/youth-mode } ``` -前端使用 `policy_version` 和 `restrictions` 渲染开启确认页。最终开启时需要提交用户确认过的 `policy_version`。 +前端优先使用 `restriction_items` 渲染开启确认页,`restrictions` 保留为兼容旧客户端的 code 数组。最终开启时需要提交用户确认过的 `policy_version`。 ### 2.2 发起短信身份验证 @@ -175,6 +197,26 @@ POST /v1/im/settings/youth-mode/enable "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" } } diff --git a/server/im_http.go b/server/im_http.go index 8443ea9..28a74ce 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -8166,12 +8166,13 @@ func appReleasePayload(release *types.AppRelease) map[string]any { func youthModePayload(settings *types.YouthModeSettings) map[string]any { data := map[string]any{ - "enabled": settings.Enabled, - "policy_version": settings.PolicyVersion, - "restrictions": settings.Restrictions, - "password_set": settings.PasswordSet, - "can_enable": settings.CanEnable, - "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), + "enabled": settings.Enabled, + "policy_version": settings.PolicyVersion, + "restrictions": settings.Restrictions, + "restriction_items": youthModeRestrictionItems(settings.Restrictions), + "password_set": settings.PasswordSet, + "can_enable": settings.CanEnable, + "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), } if settings.EnabledAt != nil { data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano) @@ -8179,6 +8180,47 @@ func youthModePayload(settings *types.YouthModeSettings) map[string]any { return data } +func youthModeRestrictionItems(restrictions []string) []map[string]any { + catalog := map[string]map[string]any{ + "night_quiet": { + "code": "night_quiet", + "title": "深夜免打扰", + "description": "夜间减少消息提醒打扰,帮助用户保持健康作息。", + "enforced": false, + }, + "content_filter": { + "code": "content_filter", + "title": "内容过滤", + "description": "对不适合青少年的内容进行识别和限制展示。", + "enforced": false, + }, + "payment_guard": { + "code": "payment_guard", + "title": "支付守护", + "description": "限制或确认支付、充值等敏感消费行为。", + "enforced": false, + }, + } + items := make([]map[string]any, 0, len(restrictions)) + for _, code := range restrictions { + code = strings.TrimSpace(code) + if code == "" { + continue + } + if item, ok := catalog[code]; ok { + items = append(items, item) + continue + } + items = append(items, map[string]any{ + "code": code, + "title": code, + "description": "", + "enforced": false, + }) + } + return items +} + func devicePushTokenPayload(token *types.DevicePushToken) map[string]any { return map[string]any{ "device_id": token.DeviceID, diff --git a/server/im_http_test.go b/server/im_http_test.go index d6894fc..95e77eb 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -3186,6 +3186,14 @@ func TestServeIMYouthModeEnableDisable(t *testing.T) { if data["enabled"] != true || data["policy_version"].(float64) != 2 { t.Fatalf("unexpected youth enable payload: %#v", data) } + items := data["restriction_items"].([]any) + if len(items) != 3 { + t.Fatalf("restriction_items: got %#v", items) + } + firstItem := items[0].(map[string]any) + if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != false { + t.Fatalf("unexpected restriction item: %#v", firstItem) + } security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"} expectIMBearer(t, ctrl, "token-disable", 7, 42)