diff --git a/docs/admin-adfahi-research/sql/商户内容审核开关.sql b/docs/admin-adfahi-research/sql/商户内容审核开关.sql new file mode 100644 index 0000000..9f1e540 --- /dev/null +++ b/docs/admin-adfahi-research/sql/商户内容审核开关.sql @@ -0,0 +1,30 @@ +-- 商户级内容审核开关。 +-- 可重复执行;新商户默认关闭,现有 default 商户保持当前审核行为。 + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'aliyun_moderation_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `aliyun_moderation_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用阿里云内容审核'' AFTER `max_group_members`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'local_moderation_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `local_moderation_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用本地内容审核'' AFTER `aliyun_moderation_enabled`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +UPDATE `im_tenant_config` c +JOIN `im_tenant` t ON t.`id` = c.`tenant_id` +SET c.`aliyun_moderation_enabled` = 1, + c.`local_moderation_enabled` = 1 +WHERE t.`code` = 'default'; diff --git a/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md b/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md index db8f507..6c261d6 100644 --- a/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md +++ b/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md @@ -1,100 +1,122 @@ -# 服务端消息加密与内容审核改造文档 +# 服务端内容审核与消息加密改造文档 -> 文档状态:待开发 -> 编制日期:2026-09-13 -> 适用项目:`im_manager`(Admin Server)、`im_server`(IM Server) -> 本文档是后续数据库设计、后端开发、联调、迁移和验收的共同依据。 +> 文档状态:分阶段实施 +> +> 更新日期:2026-09-16 +> +> 适用项目:`im_manager`(Admin Server)、`im_server`(IM Server) -## 1. 建设目标 +## 实施顺序 -本次改造同时建设两项能力: +本项目拆成两个相互独立的实施部分,并统一保存在本文档中: -1. 内容审核:IM Server 在保存或修改内容前,根据商户策略执行本地敏感词检查和阿里云文本内容审核。 -2. 消息加密存储:审核通过的消息在 IM Server 内部使用 AES-256-GCM 加密,国内 IM 业务库只保存密文;经授权的后台查询由 IM Server 解密后返回明文。 +1. 第一部分先完成内容审核,独立开发、灰度、验收和稳定观察;本阶段不修改消息存储格式。 +2. 第二部分在内容审核稳定后实施消息加密;该阶段单独立项、单独发布,并对消息表和历史数据进行分批迁移。 -本次不做以下内容: +两部分禁止同批切换。第二部分只接管“内容审核通过后的消息存储”,不得反向修改第一部分已经验收的审核决策。 -- 不做客户端 AES 二次加密。 -- 不做端到端加密(E2EE)。 +```text +第一部分: +客户端 -> TLS -> IM Server -> 内容审核 -> 现有明文存储 + +第二部分: +客户端 -> TLS -> IM Server -> 内容审核 -> AES-256-GCM -> 密文存储 +``` + +## 第二部分启动门禁 + +消息加密开发必须同时满足: + +1. 内容审核已全量上线并通过业务验收。 +2. 误拦截率、阿里云耗时、超时降级和熔断策略达到生产要求。 +3. 消息发送、编辑、资料修改和多 Pod 配置同步已经稳定。 +4. IM 数据库全量备份及恢复演练通过。 +5. 历史消息规模、迁移时间和数据库负载完成评估。 +6. 加密双读、密钥轮换和回滚方案已在测试环境演练通过。 + +## 第一部分:服务端内容审核(优先实施) + +> 文档状态:第一阶段,优先开发 +> +> 编制日期:2026-09-16 +> +> 适用项目:`im_manager`(Admin Server)、`im_server`(IM Server) +> +> 本阶段不修改消息存储格式,不实施消息加密。 + +### 1. 目标 + +Admin Server 提供内容审核配置和商户策略管理,IM Server 在内容写入前根据策略执行: + +1. 本地敏感词补充检查。 +2. 阿里云 `TextModerationPlus` 文本审核。 +3. 按风险等级放行、拦截或仅审计。 +4. 记录审核结果、请求耗时和处置结果,不保存审核明文。 + +第一阶段只为 `tenant.code=default` 的上架默认商户开启阿里云审核。其他团队商户默认关闭,不产生阿里云调用和费用。 + +### 2. 不在本阶段实施的内容 + +- 不改造 `im_messages.content` 和 `im_group_messages.content`。 +- 不建设 AES 加解密模块。 +- 不建设密钥管理表、密钥轮换和历史消息迁移。 +- 不做客户端加密或端到端加密。 - 不改变现有 HTTPS/WSS 传输协议。 -- 不允许 Admin Server 直接读取 IM 数据库并自行解密。 -- 不在日志、Kafka 事件、死信或监控标签中保存消息明文。 +- 图片、视频和文件本体继续走独立的文件审核链路。 -## 2. 安全边界和部署前提 +### 3. 现有实现和差距 -### 2.1 部署分工 - -| 区域 | 系统 | 保存数据 | +| 能力 | 现状 | 改造目标 | | --- | --- | --- | -| 国外 | HotGo / Admin Server | 商户审核策略、阿里云配置、字典根密钥、商户消息密钥 | -| 国内 | IM Server | 用户、会话、消息密文、审核结果和处置记录 | +| 阿里云文本审核 | 文档已人工验证,IM Server 无 Go 调用实现 | 实现 `TextModerationPlus` 客户端并接入业务链路 | +| 审核服务配置 | `im_external_config` 已有 `moderation` 占位 | 补齐阿里云参数、真实连通性检查和发布 | +| 商户审核策略 | `im_security_policy.sensitive_word_policy` 可保存 JSON | 定义固定结构并提供后台配置 | +| 本地敏感词 | Admin 已有 CRUD,IM Server 未消费 | 作为可选补充规则接入 IM 运行时 | +| 配置同步 | 已有 internal config、Kafka 配置事件和缓存失效 | 复用现有机制扩展审核策略 | +| 消息审核 | 消息直接保存和广播 | 在保存和广播前审核 | +| 昵称审核 | 无 | 覆盖用户昵称、群昵称和群名称的所有修改入口 | -该方案主要防护目标是:任意一侧数据库单独泄露时,不能直接恢复消息明文。 - -已知边界:HotGo 数据库、IM 数据库和完整代码同时泄露不在本方案的主要防护范围内。 - -### 2.2 传输安全 - -- 客户端调用必须使用 `https://` 和 `wss://`。 -- 阿里云审核请求必须使用 HTTPS。 -- Admin Server 与 IM Server 的内部配置和查询接口必须使用 HTTPS,并保留现有签名鉴权;跨公网部署时建议叠加 mTLS 或固定出口 IP 白名单。 -- TLS 只解决传输保护,AES-256-GCM 解决消息落库后的静态保护。 - -## 3. 总体架构 +### 4. 总体链路 ```text 客户端 - | HTTPS/WSS明文业务数据(TLS通道内不可见) + | HTTPS/WSS v IM Server - |-- 实时权限、宵禁、用户状态等校验 - |-- 获取商户审核策略 - |-- 本地敏感词补充检查 - |-- 阿里云 TextModerationPlus 审核 - |-- AES-256-GCM 加密 - `-- 密文写入国内IM库 - -Admin Server(国外HotGo) - |-- 管理阿里云配置 - |-- 管理商户审核策略 - |-- 管理商户消息密钥 - |-- 通过内部接口下发配置/密钥 - `-- 通过IM内部查询接口查看解密后消息 + |-- 权限、宵禁、用户状态等现有校验 + |-- 根据tenant读取审核策略 + |-- 提取用户可见文本 + |-- 本地敏感词检查(可选) + |-- 阿里云TextModerationPlus(可选) + |-- 映射风险结果和处置动作 + |-- block:返回业务错误,不入库、不广播 + `-- allow/audit_only:按原逻辑入库和广播 ``` -## 4. 内容审核设计 - -### 4.1 审核范围 +### 5. 审核场景 | 场景 | 阿里云 Service | 审核时机 | | --- | --- | --- | | 用户昵称 | `nickname_detection_pro` | 更新用户资料前 | +| 用户签名 | `nickname_detection_pro` | 更新用户资料前 | | 群昵称 | `nickname_detection_pro` | 更新群成员资料前 | | 群名称 | `nickname_detection_pro` | 创建群或修改群资料前 | -| 用户签名 | `nickname_detection_pro` | 更新用户资料前 | | 单聊文本 | `chat_detection_pro` | 消息入库及广播前 | | 群聊文本 | `chat_detection_pro` | 消息入库及广播前 | | 编辑后消息 | `chat_detection_pro` | 新版本入库前 | | 群公告 | `chat_detection_pro` | 发布或修改前 | -图片、视频和文件继续走文件审核链路,不属于本文本审核接口范围。 +系统消息、WebRTC 信令、已签名的服务端消息和空文本不调用文本审核。 -### 4.2 商户范围 +### 6. Admin Server 改造 -- 第一阶段只为 `tenant.code=default` 的上架默认商户开启阿里云内容审核。 -- 其他团队商户默认关闭,不调用阿里云,不产生审核费用。 -- 数据模型保留按商户开启的能力,但只有平台管理员可变更。 -- 商户端管理员不得看到阿里云 AK/SK,不得修改平台级审核服务配置。 +#### 6.1 阿里云服务配置 -### 4.3 后台配置项 - -#### 4.3.1 阿里云服务配置 - -复用 `im_external_config` 的 `service=moderation`,由平台管理员维护: +复用 `im_external_config` 的 `service=moderation`,只允许平台管理员维护: | 配置 | 说明 | | --- | --- | -| `provider` | 固定为 `aliyun_green` | +| `provider` | 固定 `aliyun_green` | | `endpoint` | 默认 `green-cip.cn-shanghai.aliyuncs.com` | | `enabled` | 平台总开关 | | `config_json.region_id` | 默认 `cn-shanghai` | @@ -105,17 +127,23 @@ Admin Server(国外HotGo) | `secret_json.access_key_id` | 只写不回显 | | `secret_json.access_key_secret` | 只写不回显 | -“检查配置”必须真实调用阿里云测试文本,不能只检查字段非空。返回仅包含成功与否、请求耗时、RequestID 和脱敏错误。 +“检查配置”必须真实调用阿里云测试文本,返回成功与否、耗时、RequestID 和脱敏错误,不能只检查字段非空。 -#### 4.3.2 商户审核策略 +#### 6.2 商户审核开关与策略 -可在 `im_security_policy.sensitive_word_policy` 中保存,避免新增重复策略表。JSON 结构固定为: +`im_tenant_config` 增加两个商户级权威开关,并在“商户管理”的新增/编辑页面维护: + +| 字段 | 说明 | +| --- | --- | +| `aliyun_moderation_enabled` | 是否对该商户启用阿里云内容审核 | +| `local_moderation_enabled` | 是否对该商户启用本地敏感词审核 | + +两个开关默认关闭;现有 `default` 商户迁移时保持开启。只要任一开关开启,该商户即进入内容审核流程。安全策略不再重复维护审核引擎开关,只维护场景、风险动作、第三方失败策略和拦截提示。 + +复用 `im_security_policy.sensitive_word_policy`,统一 JSON 结构: ```json { - "enabled": true, - "aliyun_enabled": true, - "local_words_enabled": true, "scenes": { "user_nickname": true, "user_signature": true, @@ -136,60 +164,263 @@ Admin Server(国外HotGo) } ``` -策略发布后使用现有 `im.config.event` 发布配置事件,IM Server 按商户清理本地缓存。 +规则: -### 4.4 文本提取 +- 审核引擎是否启用以 `im_tenant_config` 的两个商户开关为准。 +- 每个商户可以独立开启阿里云审核、本地审核,或同时开启。 +- 商户管理员不得查看或修改阿里云 AK/SK。 +- 策略发布使用现有 `im.config.event` 通知 IM Server 按商户失效缓存。 -审核输入不能直接对任意 JSON 执行字符串化,应按 Drafty 或消息类型提取用户可见文本: +#### 6.3 本地敏感词 + +- 现有 `im_sensitive_words` 和分组管理作为补充规则保留。 +- 只有商户配置 `local_moderation_enabled=1` 时才加载。 +- 敏感词和分组按商户隔离,平台管理员及当前商户管理员按权限维护。 +- 词库更新继续发布 `sensitive_words.updated` 事件。 + +#### 6.4 内部接口 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/internal/im-config/moderation/provider` | 拉取阿里云审核服务配置 | +| GET | `/internal/im-config/tenants/{code}/moderation-policy` | 拉取商户审核策略 | +| GET | `/internal/im-config/tenants/{code}/sensitive-words` | 拉取商户有效敏感词 | + +接口复用现有内部服务签名鉴权、时间窗、RequestID 和重放校验,禁止浏览器直接调用。 + +### 7. IM Server 改造 + +#### 7.1 内部模块 + +```text +server/moderation/ + provider.go 审核Provider接口 + policy.go 商户策略和缓存 + extractor.go 文本提取 + decision.go 风险结果映射 + local/words.go 本地敏感词匹配 + aliyun/text.go TextModerationPlus实现 +``` + +统一接口需支持:商户、场景、操作人、业务对象、文本和 TraceID,返回 `allow/block/audit_only`、风险等级、标签和可审计的脱敏结果。 + +#### 7.2 消息发送接入 + +在 `Topic.handlePubBroadcast` 中,于 `saveAndBroadcastMessage` 之前: + +1. 排除 WebRTC/系统信令。 +2. 从 `msg.Pub.Content` 提取审核文本。 +3. 执行本地规则和阿里云审核。 +4. `block` 时通过 Tinode ctrl 返回业务错误,不调用保存和广播。 +5. `allow/audit_only` 时继续现有消息链路。 + +消息编辑必须重新审核编辑后内容,不复用原消息结果。 + +#### 7.3 资料修改接入 + +用户昵称/签名、群名称/公告、群昵称的 HTTP 和 Tinode meta 修改通道必须接入同一 moderation service,防止通过另一个协议入口绕过审核。 + +#### 7.4 文本提取 - 普通文本:提取完整文本。 -- Drafty 富文本:提取 `txt` 及按钮、链接等用户可见标题。 -- 图片/文件/语音:审核文件名、说明和附带文本,文件本体走文件审核。 -- 系统消息、音视频信令和已签名服务端消息不调用文本审核。 -- 空文本直接放行。 +- Drafty 富文本:提取 `txt` 及按钮、链接的用户可见标题。 +- 图片/文件/语音:提取文件名、说明和附带文本。 +- 禁止将任意 JSON 直接字符串化后送审,避免审核协议元数据。 -### 4.5 阿里云结果处置 - -IM Server 统一映射为: +#### 7.5 阿里云结果映射 | 内部结果 | 动作 | | --- | --- | -| `allow` | 继续加密、入库和广播 | +| `allow` | 继续保存和广播 | | `block` | 不入库、不广播,返回统一业务错误 | -| `audit_only` | 允许发送,但写入审核命中日志 | +| `audit_only` | 允许业务继续,写审核日志 | | `retryable_error` | 根据 `failure_mode` 放行或拦截 | -不只依赖 `RiskLevel/Label` 一个字段,需保留与验证阿里云原始结果中的扩展判断,针对引流、诈骗等样本建立回归测试集。 +阿里云已测试样本表明,部分引流诈骗话术可能只出现在扩展判断中。实现时不能只查看单一 `RiskLevel/Label`,需保留脱敏原始响应以便调试,并使用真实样本建立回归集。 -### 4.6 超时与降级 +#### 7.6 超时、降级和熔断 -- 阿里云请求不得无限等待。 -- 建议默认 `failure_mode=allow`,阿里云异常时允许发送并记录告警,防止第三方故障造成 IM 全局不可用。 -- 连续失败达到阈值后进入短时熔断;熔断期间按失败策略处理。 -- 阿里云调用不得在数据库事务中执行。 +- 阿里云请求不得在数据库事务中执行。 +- 必须设置独立超时,禁止无限等待。 +- 默认 `failure_mode=allow`,第三方异常时放行并告警,避免 IM 全局不可用。 +- 连续失败达阈值后短时熔断,熔断期间按 `failure_mode` 处理。 +- 本地敏感词检查不受阿里云熔断影响。 -## 5. 消息加密设计 +#### 7.7 缓存和多 Pod -### 5.1 算法 +- 阿里云全局配置、商户策略和敏感词分别缓存。 +- 策略缓存键至少包含 `tenant_code + policy_version`。 +- Kafka 配置事件和词库事件使所有 IM Pod 同时失效。 +- 事件消费必须幂等,丢失事件后仍能通过 TTL 重新加载。 + +### 8. 审核日志 + +在 IM 业务库增加 `im_content_moderation_logs`: + +| 字段 | 说明 | +| --- | --- | +| `id` | 主键 | +| `request_id` | 内部审核请求 ID | +| `scene` | 用户昵称、单聊、群聊等 | +| `topic` / `seqid` | 关联消息,拦截时 `seqid` 可为空 | +| `actor_id` | 内容提交人 | +| `provider` | `local` / `aliyun_green` | +| `provider_request_id` | 阿里云 RequestID | +| `risk_level` | 统一风险等级 | +| `risk_labels` | 命中标签 JSON | +| `action` | `allow` / `block` / `audit_only` | +| `content_hash` | 原文 SHA-256 摘要 | +| `latency_ms` | 审核耗时 | +| `error_code` | 脱敏错误码 | +| `created_at` | 创建时间 | + +表中不保存原始明文、AK/SK 或完整第三方响应中的敏感信息。 + +### 9. 错误码 + +| 错误 | 说明 | 客户端处理 | +| --- | --- | --- | +| `CONTENT_REJECTED` | 内容审核拒绝 | 保留未发送内容,提示用户修改 | +| `CONTENT_REVIEW_UNAVAILABLE` | 审核不可用且策略为拦截 | 提示稍后重试 | + +不向客户端返回阿里云原始错误、命中词详情、AK/SK 或内部堆栈。 + +### 10. 监控 + +- 按商户/场景统计请求量、放行数、拦截数、审计数和降级数。 +- 阿里云耗时 P50/P95/P99。 +- 超时率、错误率、熔断状态和熔断持续时间。 +- 按风险等级和标签统计命中数。 +- 配置版本、各 Pod 最后刷新时间和缓存命中率。 +- 监控标签不得放入原始消息文本。 + +### 11. 开发任务拆分 + +#### 11.1 Admin Server + +1. 补齐 `moderation` 外部服务配置和真实连通性检查。 +2. 实现商户审核策略表单和发布。 +3. 限制默认商户以外的敏感词入口和后端权限。 +4. 增加阿里云配置、商户策略和词库 internal API。 +5. 发布并验证 Kafka 配置失效事件。 + +#### 11.2 IM Server + +1. 实现 moderation 统一接口。 +2. 实现阿里云 `TextModerationPlus` 客户端。 +3. 实现商户策略、本地敏感词和阿里云配置缓存。 +4. 接入消息发送、消息编辑、用户资料和群资料入口。 +5. 增加审核日志表、错误映射、监控和告警。 +6. 补齐单元测试、集成测试和阿里云沙箱/测试账号回归。 + +### 12. 测试重点 + +- 文本、Drafty、链接、表情、中英文混合和空内容提取。 +- 同一内容在单聊、群聊和编辑入口处置一致。 +- HTTP 和 Tinode meta 入口不能相互绕过。 +- `default` 商户开启,其他商户关闭。 +- 阿里云超时、500、限流、鉴权失败和熔断恢复。 +- 本地词库命中、阿里云命中、双重命中和无命中。 +- 多 Pod 收到配置发布后同步生效。 +- 拦截消息不入库、不广播,不占用有效消息序号。 +- 日志、Kafka、死信和监控中不出现消息明文和 AK/SK。 + +### 13. 验收标准 + +1. `default` 商户的用户昵称、群昵称、群名称、单聊和群聊均真实调用对应阿里云 Service。 +2. 其他商户关闭审核时不调用阿里云。 +3. 拦截内容不进入消息表,不向任何订阅者广播。 +4. 本地敏感词与阿里云开关可独立启停。 +5. 阿里云超时和异常严格按 `failure_mode` 处理。 +6. 策略和词库发布后,所有 IM Pod 在规定时间内生效。 +7. 日志能追溯商户、场景、用户、RequestID、风险结果和处置动作,但不保存明文。 +8. 审核上线后独立稳定运行和观察,不依赖消息加密功能。 + +### 14. 发布顺序 + +1. 先发布 Admin Server 配置表单、internal API 和配置事件。 +2. 发布 IM Server 审核模块,但保持所有商户策略关闭。 +3. 对阿里云连通性、超时降级和多 Pod 配置同步进行生产前验证。 +4. 仅对测试用户或小流量开启 `default` 商户审核。 +5. 观察误拦截率、阿里云耗时、错误率和消息总延迟。 +6. 完成全量开启和验收,进入独立稳定观察期。 +7. 审核功能稳定并通过验收后,才能启动消息加密阶段。 + +## 第二部分:服务端消息加密存储(审核稳定后实施) + +> 文档状态:第二阶段,内容审核稳定后实施 +> +> 编制日期:2026-09-16 +> +> 适用项目:`im_manager`(Admin Server)、`im_server`(IM Server) +> +> 该改造会影响消息写入、历史同步、后台查询和数据迁移,必须单独发布和验收。 + +### 1. 实施前提 + +本阶段必须在《服务端内容审核改造文档》完成开发、全量上线和稳定观察后启动。 + +启动条件: + +1. 内容审核的误拦截率、耗时、超时和降级策略已验收。 +2. 审核与消息保存的边界已稳定,加密模块只接管“审核通过后的存储”。 +3. 已完成 IM 数据库全量备份和可恢复性演练。 +4. 已对 `im_messages`、`im_message_revisions`、`im_group_messages` 的数据量、存储量和迁移耗时完成评估。 + +### 2. 目标和非目标 + +#### 2.1 目标 + +- 审核通过的新消息在 IM Server 使用 AES-256-GCM 加密后落库。 +- 国内 IM 业务库不保存可读消息正文。 +- HotGo 库保存根密钥和加密后的商户数据密钥。 +- 客户端协议不变,IM Server 对历史消息解密后按原协议返回。 +- Admin Server 通过 IM internal-query 查看授权范围内的解密消息。 +- 支持密钥版本、轮换、退役和历史数据迁移。 + +#### 2.2 非目标 + +- 不做客户端 AES 加密。 +- 不做端到端加密(E2EE)。 +- 不改变 HTTPS/WSS 传输安全方案。 +- 不允许 Admin Server 直连 IM 数据库后自行解密。 +- 不在日志、Kafka、死信和监控标签中保存消息明文。 + +### 3. 安全边界和部署 + +| 区域 | 系统 | 保存数据 | +| --- | --- | --- | +| 国外 | HotGo / Admin Server | 字典根密钥、商户消息密钥、加密开关和版本 | +| 国内 | IM Server | 用户、会话、消息密文和密钥版本 | + +防护目标:任意一侧数据库单独泄露时,不能直接恢复消息明文。 + +已知边界:HotGo 数据库、IM 数据库和完整代码同时泄露不在本方案的主要防护范围内。 + +Admin Server 和 IM Server 跨公网通信必须使用 HTTPS、现有签名鉴权和重放防护,建议叠加 mTLS 或固定出口 IP 白名单。 + +### 4. 算法和存储格式 + +#### 4.1 算法 - 算法:AES-256-GCM。 -- DEK 长度:32 字节。 +- DEK:32 字节强随机密钥。 - Nonce:每次加密使用 `crypto/rand` 生成的独立 12 字节随机值。 - GCM 标签:使用 Go 标准库默认 16 字节认证标签。 -- 明文编码:将原 `content` JSON 规范化序列化为 UTF-8 字节。 -- 不允许固定 Nonce,不允许 AES-ECB,不使用未认证的 AES-CBC/CTR。 +- 明文:将原 `content` JSON 规范序列化为 UTF-8 字节。 +- 禁止固定 Nonce、AES-ECB 和未认证的 AES-CBC/CTR。 -### 5.2 AAD +#### 4.2 AAD -每条消息加密时绑定以下 AAD: +每条消息绑定: ```text tenant_id | topic | seqid | from | client_msg_id | key_version | content ``` -`content` 是字面标识,用于与未来可能加密的 `head` 区分。AAD 按固定字段顺序和长度前缀编码,禁止使用可产生拼接歧义的简单字符串。 +`content` 是固定字段标识。AAD 使用固定字段顺序和长度前缀编码,不使用可形成拼接歧义的简单字符串。 -### 5.3 密钥层级 +#### 4.3 密钥层级 ```text HotGo字典根密钥(KEK) @@ -197,17 +428,21 @@ HotGo字典根密钥(KEK) -> DEK加密该商户消息 ``` -根密钥使用 HotGo `hg_sys_dict_data` 保存,但必须满足: +### 5. HotGo 密钥管理 -1. 使用独立的内部字典类型,不出现在通用字典管理页。 -2. 后端字典列表、选项和导出接口必须对该类型做服务端拒绝,不能仅依赖菜单隐藏。 -3. 根密钥为 32 字节强随机值的 Base64 编码,不使用口令、UUID 或可读文本。 -4. 根密钥只由 Admin Server 密钥服务读取,不通过通用配置接口返回。 -5. 根密钥不写日志,不在后台页面回显,不进入 Kafka 消息。 +#### 5.1 字典根密钥 -### 5.4 商户密钥管理表 +根密钥使用 `hg_sys_dict_data` 保存,必须满足: -在 HotGo 库新增 `im_message_encryption_keys`: +1. 使用独立内部字典类型,不出现在通用字典管理页。 +2. 后端字典列表、选项、导出和缓存接口必须服务端拒绝该类型,不得仅隐藏菜单。 +3. 根密钥是 32 字节强随机值的 Base64 编码,不使用口令、UUID 或可读文本。 +4. 根密钥只由 Admin Server 专用密钥服务读取。 +5. 根密钥不回显、不写日志、不进入 Kafka,不通过通用配置接口返回。 + +#### 5.2 商户密钥表 + +新增 `im_message_encryption_keys`: | 字段 | 类型 | 说明 | | --- | --- | --- | @@ -228,343 +463,239 @@ HotGo字典根密钥(KEK) - 唯一键:`tenant_id + key_version`。 - 每个商户同时只能有一把 `active` 密钥。 -- `retired` 密钥仍可用于解密历史消息,不得再用于新消息加密。 -- `disabled` 只用于安全事故应急,禁用前必须完成历史消息转密钥。 +- `retired` 密钥可解密历史数据,不得加密新消息。 +- `disabled` 只用于安全事故应急,禁用前必须完成历史转密钥。 -### 5.5 IM 消息表改造 +#### 5.3 后台管理页 -`im_messages` 和 `im_message_revisions` 新增: +只允许最高权限平台管理员: + +- 查看商户加密状态、当前密钥版本和最后轮换时间。 +- 初始化商户密钥。 +- 启用或暂停消息加密。 +- 轮换密钥。 +- 查看历史迁移进度。 + +页面不展示根密钥、DEK、`encrypted_dek` 和任何可恢复密钥的内容。 + +### 6. IM 数据库改造 + +#### 6.1 `im_messages` 和 `im_message_revisions` + +新增: | 字段 | 类型 | 说明 | | --- | --- | --- | -| `content_ciphertext` | longblob | 消息密文,包含 GCM 认证标签 | -| `content_nonce` | varbinary(12) | 本条消息随机 Nonce | +| `content_ciphertext` | longblob | 密文,包含 GCM 认证标签 | +| `content_nonce` | varbinary(12) | 每条消息独立随机 Nonce | | `key_version` | int | 商户 DEK 版本 | | `encryption_alg` | varchar(32) | `AES-256-GCM` | | `encrypted` | tinyint | `0` 明文兼容,`1` 密文 | -迁移完成前保留现有 `content JSON`,规则如下: +迁移期规则: -- 新写入:`content=NULL`、`encrypted=1`,只保存密文。 -- 旧数据:`content IS NOT NULL`、`encrypted=0`,按原逻辑读取。 -- 解密成功后还原为原 `any` JSON 结构,不修改对客户端的协议。 -- `head` 第一阶段不加密,但禁止在 `head` 中放置消息正文、联系方式等敏感明文。 +- 新加密数据:`content=NULL`、`encrypted=1`。 +- 旧数据:`content IS NOT NULL`、`encrypted=0`。 +- 解密后必须恢复为原 `any` JSON 结构,不修改客户端协议。 +- `head` 第一阶段不加密,但不得放置消息正文、联系方式等敏感明文。 -`im_group_messages` 是群消息派生查询表,不得另存一份明文。实现时二选一: +#### 6.2 `im_group_messages` -1. 推荐:仅保存索引、状态和统计字段,通过 `topic + seqid` 关联 `im_messages` 解密。 -2. 兼容:同步增加密文字段,但加密输入和密文必须与主消息表保持一致。 +`im_group_messages` 是群消息派生查询表,不得另存一份明文。优先方案: -不允许 `im_messages` 已加密而 `im_group_messages.content` 仍保留明文。 +- 仅保存索引、状态、审核和统计字段。 +- 通过 `topic + seqid` 关联 `im_messages` 获取密文并解密。 +- 迁移后清空或删除派生表中的明文 `content`。 -### 5.6 加密和解密时机 +如果必须保留独立内容列,则必须同步改为密文字段,禁止主表已加密而派生表仍保留明文。 -#### 新消息 +#### 6.3 Schema 一致性 + +- MySQL 普通库和共享库/多商户 adapter 同时改造。 +- 新建库 schema、启动 ensure 迁移和手工 SQL 脚本保持一致。 +- `types.Message` 和 `types.MessageRevision` 增加密文元数据。 +- Save、GetOne、GetAll、编辑、撤回、删除、修订记录和后台查询全部覆盖。 + +### 7. IM Server 加解密模块 ```text -校验消息 - -> 提取审核文本 - -> 内容审核 - -> 分配seqid/组装AAD - -> 序列化content - -> 使用当前active DEK加密 - -> 事务写入密文和消息元数据 - -> 向在线订阅者广播原内存明文 +server/messagecrypto/ + cipher.go AES-256-GCM加解密 + aad.go AAD稳定编码 + key_provider.go 密钥获取接口 + key_cache.go tenant/version内存缓存 + serializer.go content规范序列化 ``` -#### 历史消息和离线同步 +不将 AES 逻辑或 HotGo 调用直接写入 MySQL adapter。adapter 负责存储密文模型,加解密由业务存储服务边界统一执行。 + +#### 7.1 新消息 ```text -从数据库读取密文 - -> 按tenant_id + key_version获取DEK +完成现有校验 + -> 完成内容审核 + -> 分配seqid并组装AAD + -> 序列化content + -> 使用active DEK加密 + -> 事务写入密文和元数据 + -> 向在线订阅者广播内存中的原明文 +``` + +审核必须发生在加密之前。广播使用请求内存中的明文,不执行“加密后立即解密”。 + +#### 7.2 历史消息和离线同步 + +```text +读取数据库记录 + -> encrypted=0:读原content + -> encrypted=1:按tenant + key_version获取DEK -> 重建AAD -> GCM验签并解密 -> JSON反序列化 -> 按原Tinode协议返回 ``` -任何验签失败均必须当作数据篡改或密钥错误,禁止返回部分内容或尝试忽略错误。 +GCM 验签失败必须当作数据篡改或密钥错误,不得忽略错误或返回部分内容。 -### 5.7 密钥获取和缓存 +### 8. 密钥下发、缓存和轮换 -- IM Server 不得每发送一条消息都跨境请求 HotGo。 -- IM Server 按 `tenant_id + key_version` 在内存中缓存已解密 DEK。 -- 新消息使用当前 `active` 密钥,历史消息按记录中的 `key_version` 取密钥。 -- 密钥变更发布 `message.encryption.key.updated` 配置事件,IM Server 按商户刷新。 -- HotGo 短时不可用时继续使用内存密钥。 -- IM Server 重启后如无法取得当前密钥,该商户禁止写入新消息,不得降级为明文落库。 - -### 5.8 密钥轮换 - -1. Admin Server 生成新 DEK,使用 KEK 加密后以 `pending` 状态入库。 -2. 通知 IM Server 拉取并验证新密钥。 -3. 验证成功后,在事务中将旧密钥设为 `retired`,新密钥设为 `active`。 -4. 新消息立即使用新版本,旧消息仍按原版本解密。 -5. 历史转密钥作为单独低速任务执行,不阻塞即时通信。 - -## 6. Admin Server 改造 - -### 6.1 内容审核页面 - -将现有“审核插件”配置补齐为可用功能: - -- 阿里云基础配置。 -- 真实连通性检查。 -- 商户审核开关和场景开关。 -- 风险等级与处置动作映射。 -- 异常降级策略。 -- 配置发布版本和生效时间。 - -非默认商户显示“未开启内容审核”,不显示阿里云密钥编辑项。 - -### 6.2 消息加密管理页面 - -增加平台级“消息加密”页面,只允许最高权限平台管理员使用: - -- 查看商户加密状态、当前密钥版本和最后轮换时间。 -- 初始化商户密钥。 -- 启用/暂停商户消息加密。 -- 轮换密钥。 -- 查看历史迁移进度。 -- 密钥明文、根密钥和 `encrypted_dek` 均不展示。 - -### 6.3 内部接口 - -建议增加: +#### 8.1 内部接口 | 方法 | 路径 | 用途 | | --- | --- | --- | -| GET | `/internal/im-config/tenants/{code}/moderation-policy` | IM Server 拉取商户审核策略 | -| GET | `/internal/im-config/moderation/provider` | IM Server 拉取阿里云配置 | -| GET | `/internal/im-config/tenants/{code}/message-keys/active` | 获取当前消息 DEK | +| GET | `/internal/im-config/tenants/{code}/message-keys/active` | 获取当前 DEK | | GET | `/internal/im-config/tenants/{code}/message-keys/{version}` | 获取指定版本 DEK | | POST | `/internal/im-config/tenants/{code}/message-keys/ack` | IM Server 确认新密钥可用 | -密钥接口返回的是服务端使用的 DEK,不允许从浏览器或通用 Admin API 调用。接口必须校验服务身份、时间窗、签名、请求 ID 和重放。 +这些接口只允许 IM Server 服务身份调用,不得暴露给浏览器或通用 Admin API。 -### 6.4 后台消息查询 +#### 8.2 缓存 -- Admin Server 继续调用 IM Server internal admin-query,不直连 IM 业务库解密。 -- IM Server 在商户数据权限通过后批量解密当页消息。 -- 每次查看明文记录管理员 ID、商户、IP、查询条件、返回数量、时间和 TraceID。 -- 列表只解密当页,禁止一次加载全部历史消息。 +- IM Server 不得每条消息都跨境请求 HotGo。 +- 按 `tenant_id + key_version` 在内存缓存已解密 DEK。 +- 新消息使用 `active` 密钥,历史消息按存储版本取密钥。 +- 每个 Pod 持有本地缓存,Kafka 事件使全部 Pod 失效。 +- HotGo 短时不可用时,已有内存密钥的 Pod 可继续工作。 +- 无可用密钥时禁止写入新消息,不得降级为明文落库。 -## 7. IM Server 改造 +#### 8.3 轮换 -### 7.1 新增内部模块 +1. Admin Server 生成新 DEK,使用 KEK 加密后以 `pending` 入库。 +2. 通知 IM Server 拉取、解包并完成加解密自检。 +3. IM Server 回传 ACK 后,在事务中将旧版本设为 `retired`,新版本设为 `active`。 +4. 新消息立即使用新版本,旧消息仍按旧版本解密。 +5. 历史转密钥作为独立低速任务,不阻塞即时通信。 -```text -server/moderation/ - provider.go 审核Provider接口 - policy.go 商户策略和缓存 - text.go 文本提取与结果映射 - aliyun/text.go TextModerationPlus实现 +### 9. Admin 后台查看明文 -server/messagecrypto/ - cipher.go AES-256-GCM加解密 - key_provider.go 密钥获取接口 - key_cache.go tenant/version内存缓存 - serializer.go content规范序列化 -``` +- Admin Server 调用 IM internal admin-query,不直连 IM 库解密。 +- IM Server 完成商户数据权限校验后,只解密当前分页数据。 +- 记录管理员 ID、商户、IP、查询条件、返回数量、时间和 TraceID。 +- 列表解密、详情解密和导出解密使用不同审计动作。 +- 默认不提供明文批量导出。后续开放时必须增加独立权限和审批。 -不将加密和阿里云调用直接写进 MySQL adapter;adapter 只保存已加密的存储模型和解密所需元数据。 +### 10. 关键词查询 -### 7.2 消息发送接入点 +加密后 MySQL 无法对密文执行 `LIKE '%keyword%'`。第一阶段采用: -在 `Topic.handlePubBroadcast` 中,于 `saveAndBroadcastMessage` 之前执行: +1. 查询必须限定商户和时间范围,并尽量限定用户或群号。 +2. IM Server 使用明文元数据缩小候选集。 +3. 对有上限的候选集批量解密,在内存中执行关键词匹配。 +4. 超过时间范围或候选数限制时返回明确提示。 -1. 过滤 WebRTC/系统信令等不审核内容。 -2. 提取文本。 -3. 根据商户策略执行审核。 -4. 拦截时通过 Tinode ctrl 回复业务错误,不进入保存和广播。 -5. 放行时进入加密保存。 +第一版不建设明文分词索引。如后续必须支持全库高性能检索,再单独评审 HMAC 盲索引。 -进程内广播使用本次请求已有明文,不为广播执行“加密后立即解密”。 +### 11. 历史数据迁移 -### 7.3 资料修改接入点 +#### 11.1 发布顺序 -用户昵称/签名、群名称/公告、群昵称的 HTTP 和 Tinode meta 修改通道都必须接入同一 moderation service,不能只修改某一个 HTTP API 而留下协议绕过路径。 - -### 7.4 存储层改造 - -- `types.Message` 和 `types.MessageRevision` 增加密文及密钥元数据字段。 -- MySQL 的普通库和共享库/多商户 adapter 必须同时改造。 -- 新建库 schema、启动 ensure 迁移和手工 SQL 脚本必须保持一致。 -- Save、GetOne、GetAll、编辑、撤回、删除、历史修订、管理后台查询均需覆盖。 -- 解密层应在业务存储服务边界统一执行,避免上层遗漏。 - -### 7.5 缓存与多 Pod - -- 每个 Pod 保持本地密钥和审核策略缓存。 -- Kafka 配置事件使所有 Pod 同时失效。 -- 事件处理必须幂等,重复消息不得导致重复创建密钥。 -- 新旧密钥切换期间,所有 Pod 必须同时具备旧版本解密和新版本加密能力。 - -## 8. 审核和解密日志 - -在 IM 业务库增加 `im_content_moderation_logs`: - -| 字段 | 说明 | -| --- | --- | -| `id` | 主键 | -| `request_id` | 审核请求 ID | -| `scene` | 昵称、单聊、群聊等 | -| `topic` / `seqid` | 关联消息,拦截时 `seqid` 可为空 | -| `actor_id` | 内容提交人 | -| `provider` | `local` / `aliyun_green` | -| `provider_request_id` | 第三方 RequestID | -| `risk_level` | 统一风险等级 | -| `risk_labels` | 命中标签 JSON | -| `action` | `allow` / `block` / `audit_only` | -| `content_hash` | SHA-256 摘要,不保存明文 | -| `latency_ms` | 审核耗时 | -| `error_code` | 脱敏错误码 | -| `created_at` | 创建时间 | - -后台明文查看继续写入统一审计事件,必须区分“列表解密”、“详情解密”和“导出解密”。默认不提供明文批量导出;需要开放时必须增加独立权限和审批。 - -## 9. 关键词查询限制 - -消息加密后,MySQL 无法对密文执行原有 `LIKE '%keyword%'`。第一阶段采用: - -1. 后台查询必须先提供商户、时间范围,并尽量提供用户或群号。 -2. IM Server 先用明文元数据缩小候选集。 -3. 对有上限的候选集批量解密,再在内存中匹配关键词。 -4. 限制查询最大时间范围和候选消息数,超限返回明确提示。 - -不建议在第一阶段建设明文分词索引,否则会降低加密保护效果。若后续必须支持全库高性能检索,再单独评审 HMAC 盲索引方案。 - -## 10. 历史数据迁移 - -### 10.1 发布顺序 - -1. 发布 HotGo 表结构、根密钥和密钥管理能力。 -2. 为默认商户初始化第 1 版 DEK,但不开启加密。 -3. 发布 IM Server 双读兼容版本,验证密钥获取、加解密和内容审核。 -4. 开启新消息加密写入,保持旧数据明文可读。 -5. 启动历史数据分批迁移。 -6. 对比数量、抽样解密和 GCM 验签,完成后清空明文 `content`。 +1. 发布 HotGo 密钥结构、密钥服务和管理页。 +2. 为默认商户初始化第 1 版 DEK,但不开启加密写入。 +3. 发布 IM Server 双读版本,验证密钥获取、加解密和所有读写链路。 +4. 小流量开启新消息加密,旧数据仍可明文读取。 +5. 全量开启新消息加密后,启动历史数据分批迁移。 +6. 数量对账、抽样解密和 GCM 验签通过后,逐批清空明文 `content`。 7. 观察期结束后,再决定是否删除原明文字段。 -### 10.2 迁移任务要求 +#### 11.2 迁移任务 - 按主键或 `topic + seqid` 分批,不长时锁表。 -- 每批使用独立事务,记录游标、成功数、失败数和最后错误。 -- 重复执行必须幂等,`encrypted=1` 的记录直接跳过。 -- 先写入密文并验证可解密,再将明文字段置空。 -- 失败记录保留明文并进入重试列表,不得丢失原数据。 -- 迁移速率可配置,并监控数据库负载和消息延迟。 +- 每批独立事务,记录游标、成功数、失败数和最后错误。 +- 重复执行幂等,`encrypted=1` 直接跳过。 +- 先写密文并验证可解密,再将明文置空。 +- 失败记录保留原数据并进入重试列表,不得丢失数据。 +- 限制迁移速率,监控数据库负载和即时消息延迟。 -## 11. 错误码和客户端兼容 +### 12. 错误处理 -客户端不参与加解密,不修改消息协议。新增业务错误建议: - -| 错误 | 说明 | 客户端处理 | +| 错误 | 说明 | 客户端/后台处理 | | --- | --- | --- | -| `CONTENT_REJECTED` | 内容审核拒绝 | 保留未发送文本并提示修改 | -| `CONTENT_REVIEW_UNAVAILABLE` | 审核不可用且策略要求拦截 | 提示稍后重试 | -| `MESSAGE_ENCRYPTION_UNAVAILABLE` | 未取得加密密钥 | 提示服务暂时不可用 | -| `MESSAGE_DECRYPT_FAILED` | 历史消息验签/解密失败 | 展示“消息暂时无法加载”,不展示乱码 | +| `MESSAGE_ENCRYPTION_UNAVAILABLE` | 未取得当前加密密钥 | 暂停新消息写入,不明文降级 | +| `MESSAGE_DECRYPT_FAILED` | 验签或解密失败 | 显示“消息暂时无法加载”,不返回乱码 | +| `MESSAGE_KEY_VERSION_NOT_FOUND` | 历史密钥版本不存在 | 报警并阻止该记录解密 | -不向客户端返回阿里云原始错误、风险标签详情、密钥版本内部信息或解密堆栈。 +客户端不参与加解密,不返回密钥版本、Nonce、密文、解密堆栈或内部错误。 -## 12. 监控指标 - -### 12.1 内容审核 - -- 按商户/场景统计请求量、放行数、拦截数、降级数。 -- 阿里云耗时 P50/P95/P99。 -- 超时率、错误率、熔断状态。 -- 各风险等级和标签命中数。 - -### 12.2 消息加密 +### 13. 监控和审计 - 加密/解密成功数、失败数和耗时。 - 各商户当前密钥版本。 -- 密钥获取成功率、缓存命中率和缓存最后刷新时间。 +- 密钥获取成功率、缓存命中率和最后刷新时间。 - 明文历史记录剩余数、迁移速率和迁移失败数。 -- GCM 验签失败必须产生高优先级告警。 +- GCM 验签失败产生高优先级告警。 +- 后台明文查看记录管理员、商户、IP、查询范围、返回数量和 TraceID。 +- 监控标签和日志不得保存消息正文、密文、Nonce、DEK 或 KEK。 -监控标签不得使用消息正文、密文、Nonce、DEK 或 KEK。 +### 14. 开发任务拆分 -## 13. 验收标准 +#### 14.1 Admin Server -### 13.1 审核验收 +1. 根密钥字典隔离和通用接口防护。 +2. `im_message_encryption_keys` 及业务逻辑。 +3. 消息加密管理页面和最高权限控制。 +4. 密钥下发、ACK 和 Kafka 失效事件。 +5. 密钥初始化、轮换、退役和审计。 -- `default` 商户开启后,单聊、群聊、用户昵称、群名称和群昵称均实际调用对应阿里云 Service。 -- 其他商户关闭时不调用阿里云。 -- 拦截内容不进入 `im_messages` 和 `im_group_messages`,也不向订阅者广播。 -- 本地敏感词与阿里云开关可独立启停。 -- 阿里云超时严格按 `failure_mode` 处理。 -- 配置发布后所有 IM Pod 在规定时间内生效。 +#### 14.2 IM Server -### 13.2 加密验收 +1. AES-256-GCM、AAD 和规范序列化模块。 +2. 密钥获取、多版本缓存和多 Pod 失效。 +3. 新消息加密写入和双读解密。 +4. 单聊、群聊、修订、离线同步和后台查询覆盖。 +5. 历史数据迁移、进度统计和失败重试。 +6. 加解密监控、验签告警和后台解密审计。 -- 开启商户的新消息在数据库中不存在可读正文。 -- 相同内容连续发送两次,Nonce 和密文必须不同。 -- 修改密文、Nonce、AAD 关联字段的任意一处后,解密必须失败。 -- 在线发送、离线同步、多端漫游、编辑、回复、撤回、删除保持原有业务行为。 -- Admin 有权限账号可查看解密明文,无权限账号被拒绝。 -- 查看明文必须形成完整审计记录。 -- 旧密钥退役后仍可解密旧版本消息,新消息只使用新版本。 -- HotGo 暂时断开时,已有内存密钥的 IM Pod 仍可工作;无密钥时绝不允许明文降级落库。 +### 15. 测试重点 -### 13.3 性能验收 +- 相同明文连续加密两次,Nonce 和密文不同。 +- 修改密文、Nonce、AAD 或 `key_version` 任意一项后解密失败。 +- JSON 字符串、数组、对象、Drafty、空值和超长内容往返一致。 +- 在线发送、离线同步、多端漫游、编辑、回复、撤回和删除行为不变。 +- 新旧消息混合分页和历史同步。 +- 新旧密钥版本并存和多 Pod 切换。 +- HotGo 暂时不可用、IM Pod 重启且无缓存、密钥版本缺失。 +- 历史迁移中断、重试、幂等和数量对账。 +- 旧密钥退役后可读旧消息,新消息只使用新密钥。 +- 日志、Kafka、错误响应和监控中无明文和密钥。 -- 纯 AES 加密不得成为消息链路主要瓶颈。 -- 密钥缓存命中时不发生跨境 HotGo 请求。 -- 审核关闭的商户不增加阿里云网络耗时。 -- 审核开启时应分别统计本地处理、阿里云调用和消息入库耗时。 -- 历史迁移期间即时消息发送和数据库负载不得超过上线阈值。 +### 16. 验收标准 -## 14. 开发分批建议 +1. 开启商户的新消息在数据库中不存在可读正文。 +2. 相同内容的 Nonce 和密文不同。 +3. 篡改密文、Nonce 或 AAD 后必须验签失败。 +4. 所有在线和历史消息功能保持原行为。 +5. Admin 有权限账号可查看当页解密明文,无权限账号被拒绝。 +6. 后台明文查看形成完整审计记录。 +7. 旧密钥退役后仍可解密旧消息,新消息只使用新版本。 +8. HotGo 短时断开时,有内存密钥的 Pod 可工作;无密钥时不得明文降级。 +9. 历史数据迁移完成后,`im_messages`、`im_message_revisions`、`im_group_messages` 无明文副本。 +10. 纯 AES 加解密不得成为消息链路主要瓶颈,密钥缓存命中时不产生跨境请求。 -### 批次一:配置和密钥基础 +### 17. 上线和回滚 -- Admin 阿里云内容审核配置、商户策略、密钥表和密钥管理页。 -- 字典根密钥隔离和专用密钥服务。 -- Admin internal config/key API 及 Kafka 失效事件。 - -### 批次二:IM 文本审核 - -- 阿里云 `TextModerationPlus` Go 客户端。 -- 策略缓存、文本提取、消息和资料修改接入。 -- 审核日志、错误码、告警和回归测试。 - -### 批次三:新消息加密 - -- AES-256-GCM 模块、密钥缓存和存储模型。 -- 新消息加密写入和历史消息解密读取。 -- 单聊、群聊、修订、后台查询的一致性验证。 - -### 批次四:历史迁移和轮换 - -- 历史明文分批加密。 -- 迁移仪表盘、少量失败重试和数据对账。 -- 密钥轮换和历史转密钥测试。 -- 完成观察后清理明文。 - -## 15. 开发前必须确认的决策 - -| 决策 | 文档默认值 | -| --- | --- | -| 阿里云异常时放行还是拦截 | 放行并告警 | -| 是否审核系统消息 | 不审核 | -| 是否加密 `head` | 第一阶段不加密 | -| 第一阶段启用商户 | 仅 `code=default` | -| 是否允许无密钥时明文降级 | 不允许 | -| 后台是否允许明文导出 | 默认不允许 | -| 历史消息是否迁移 | 是,分批迁移 | -| 根密钥位置 | HotGo 内部字典数据,严禁通用接口暴露 | - -## 16. 现有实现对照 - -| 能力 | 现状 | 本次改造 | -| --- | --- | --- | -| 阿里云文本审核 | 文档已人工验证,IM Server 无 Go 调用实现 | 增加 TextModerationPlus 客户端和业务接入 | -| 本地敏感词 | Admin 已有 CRUD,IM Server 未消费 | 作为可选补充审核 | -| 审核配置 | `im_external_config` 已有 `moderation` 占位 | 增加真实检查、商户策略和发布 | -| 配置同步 | 已有 internal config 客户端、Kafka 配置事件和缓存失效 | 复用并扩展新的策略/密钥类型 | -| 消息存储 | `im_messages.content` 和修订表 `content` 为 JSON 明文 | 改为 AES-256-GCM 密文且双读迁移 | -| 群消息派生表 | `im_group_messages.content` 为 JSON 明文 | 去明文副本或同步密文化 | -| 后台消息查询 | IM internal-query 返回库内内容 | 增加权限校验、解密和查看审计 | +1. 第一次只为测试商户或小流量用户开启新消息加密。 +2. 加密开关必须按商户生效,不做全局一次性切换。 +3. 观察写入失败、解密失败、同步延迟、数据库负载和密钥缓存。 +4. 未开始历史迁移前,可停止新加密写入,但已加密消息仍必须保持可解密读取。 +5. 禁止通过删除旧密钥实现回滚。 +6. 历史迁移开始后,回滚必须保留双读能力,不允许只回滚数据库结构或只回滚代码。 diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 4633ef2..be24174 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -58,6 +58,64 @@ type ThirdPartyRes struct { Values map[string]string `json:"values" dc:"配置值"` } +type ModerationProviderReq struct { + g.Meta `path:"/internal/im-config/moderation/provider" method:"get" tags:"IM内部配置" summary:"获取内容审核服务配置"` +} + +type ModerationProviderRes struct { + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + Enabled bool `json:"enabled"` + RegionID string `json:"region_id"` + NicknameService string `json:"nickname_service"` + ChatService string `json:"chat_service"` + TimeoutMS int `json:"timeout_ms"` + FailureMode string `json:"failure_mode"` + AccessKeyID string `json:"access_key_id"` + AccessKeySecret string `json:"access_key_secret"` +} + +type ModerationPolicyReq struct { + g.Meta `path:"/internal/im-config/tenants/{code}/moderation-policy" method:"get" tags:"IM内部配置" summary:"获取商户内容审核策略"` + Code string `json:"code" in:"path" dc:"商户编码"` +} + +type ModerationPolicyRes struct { + TenantID int64 `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + PolicyVersion uint `json:"policy_version"` + Enabled bool `json:"enabled"` + AliyunEnabled bool `json:"aliyun_enabled"` + LocalWordsEnabled bool `json:"local_words_enabled"` + Scenes map[string]bool `json:"scenes"` + RiskActions map[string]string `json:"risk_actions"` + FailureMode string `json:"failure_mode"` + BlockMessage string `json:"block_message"` +} + +type SensitiveWordsReq struct { + g.Meta `path:"/internal/im-config/tenants/{code}/sensitive-words" method:"get" tags:"IM内部配置" summary:"获取商户有效敏感词"` + Code string `json:"code" in:"path" dc:"商户编码"` +} + +type SensitiveWordsRes struct { + TenantID int64 `json:"tenant_id"` + TenantCode string `json:"tenant_code"` + Version int64 `json:"version"` + Items []*SensitiveWordModel `json:"items"` +} + +type SensitiveWordModel struct { + ID int64 `json:"id"` + Word string `json:"word"` + MatchType string `json:"match_type"` + Scenes []string `json:"scenes"` + Action string `json:"action"` + Replacement string `json:"replacement"` + RiskLevel string `json:"risk_level"` + Priority int `json:"priority"` +} + type TenantLoginIPWhitelistReq struct { g.Meta `path:"/internal/im-config/tenants/by-id/{id}/login-ip-whitelist" method:"get" tags:"IM内部配置" summary:"获取租户渠道登录IP白名单"` Id int64 `json:"id" in:"path" dc:"租户ID"` diff --git a/server/go.mod b/server/go.mod index b483c1f..a2117d6 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,10 +4,11 @@ go 1.24.4 require ( github.com/IBM/sarama v1.45.2 - github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7 + github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6 - github.com/alibabacloud-go/tea v1.3.9 - github.com/alibabacloud-go/tea-utils/v2 v2.0.7 + github.com/alibabacloud-go/green-20220302/v3 v3.5.4 + github.com/alibabacloud-go/tea v1.5.2 + github.com/alibabacloud-go/tea-utils/v2 v2.0.9 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/apache/rocketmq-client-go/v2 v2.1.2 github.com/casbin/casbin/v2 v2.108.0 diff --git a/server/go.sum b/server/go.sum index 216392c..690b20e 100644 --- a/server/go.sum +++ b/server/go.sum @@ -23,8 +23,8 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.2/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ= -github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7 h1:ASXSBga98QrGMxbIThCD6jAti09gedLfvry6yJtsoBE= -github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.7/go.mod h1:TBpgqm3XofZz2LCYjZhektGPU7ArEgascyzbm4SjFo4= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= @@ -37,6 +37,8 @@ github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6 h1:UTl97mt2qfavxveqCkaVg4 github.com/alibabacloud-go/dysmsapi-20170525/v3 v3.0.6/go.mod h1:UWpcGrWwTbES9QW7OQ7xDffukMJ/l7lzioixIz8+lgY= github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk= +github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ= github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= @@ -48,16 +50,15 @@ github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= -github.com/alibabacloud-go/tea v1.3.8/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= -github.com/alibabacloud-go/tea v1.3.9 h1:bjgt1bvdY780vz/17iWNNtbXl4A77HWntWMeaUF3So0= -github.com/alibabacloud-go/tea v1.3.9/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= +github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU= +github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0= github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4= github.com/alibabacloud-go/tea-utils/v2 v2.0.3/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ= github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= -github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= -github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index 32a1a55..60e6d79 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -2,7 +2,9 @@ package imconfig import ( "context" + "encoding/json" api "hotgo/api/admin/imconfiginternal" + "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" @@ -14,6 +16,18 @@ import ( "github.com/gogf/gf/v2/util/gconv" ) +const defaultModerationTenantCode = "default" + +var defaultModerationScenes = map[string]bool{ + "user_nickname": true, "user_signature": true, "group_name": true, + "group_nickname": true, "private_message": true, "group_message": true, + "group_announcement": true, +} + +var defaultModerationRiskActions = map[string]string{ + "high": "block", "medium": "block", "low": "audit_only", "none": "allow", +} + var Config = cConfig{} type cConfig struct{} @@ -91,6 +105,177 @@ func (c *cConfig) ThirdParty(ctx context.Context, req *api.ThirdPartyReq) (res * return &api.ThirdPartyRes{Values: values}, nil } +func (c *cConfig) ModerationProvider(ctx context.Context, req *api.ModerationProviderReq) (res *api.ModerationProviderRes, err error) { + tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, defaultModerationTenantCode) + if err != nil { + return nil, err + } + var row struct { + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + Enabled int `json:"enabled"` + ConfigJSON string `json:"config_json"` + SecretJSON string `json:"secret_json"` + } + if err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). + Fields("provider", "endpoint", "enabled", "CAST(config_json AS CHAR) config_json", "CAST(secret_json AS CHAR) secret_json"). + Where("tenant_id", tenant.Id).Where("service", consts.ImExternalServiceModeration).Scan(&row); err != nil { + return nil, gerror.Wrap(err, "获取内容审核服务配置失败") + } + config := map[string]any{} + secret := map[string]any{} + _ = json.Unmarshal([]byte(row.ConfigJSON), &config) + _ = json.Unmarshal([]byte(row.SecretJSON), &secret) + res = &api.ModerationProviderRes{ + Provider: row.Provider, Endpoint: row.Endpoint, Enabled: row.Enabled == 1, + RegionID: stringValue(config, "region_id", "cn-shanghai"), + NicknameService: stringValue(config, "nickname_service", "nickname_detection_pro"), + ChatService: stringValue(config, "chat_service", "chat_detection_pro"), + TimeoutMS: intValue(config, "timeout_ms", 1500), + FailureMode: moderationFailureMode(stringValue(config, "failure_mode", "allow")), + AccessKeyID: stringValue(secret, "access_key_id", ""), + AccessKeySecret: stringValue(secret, "access_key_secret", ""), + } + return res, nil +} + +func (c *cConfig) ModerationPolicy(ctx context.Context, req *api.ModerationPolicyReq) (res *api.ModerationPolicyRes, err error) { + tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code)) + if err != nil { + return nil, err + } + res = defaultModerationPolicy(tenant) + var tenantConfig struct { + AliyunModerationEnabled int `json:"aliyun_moderation_enabled"` + LocalModerationEnabled int `json:"local_moderation_enabled"` + } + if err = imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx). + Fields("aliyun_moderation_enabled", "local_moderation_enabled"). + Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Scan(&tenantConfig); err != nil { + return nil, gerror.Wrap(err, "获取商户内容审核开关失败") + } + res.AliyunEnabled = tenantConfig.AliyunModerationEnabled == 1 + res.LocalWordsEnabled = tenantConfig.LocalModerationEnabled == 1 + res.Enabled = res.AliyunEnabled || res.LocalWordsEnabled + columns := dao.ImSecurityPolicy.Columns() + var row struct { + PolicyVersion uint `json:"policy_version"` + SensitiveWordPolicy string `json:"sensitive_word_policy"` + } + if err = imtenant.RegistryDB().Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx). + Fields(columns.PolicyVersion, "CAST("+columns.SensitiveWordPolicy+" AS CHAR) sensitive_word_policy"). + Where(columns.TenantId, tenant.Id).Where(columns.PublishStatus, consts.ImSecurityPublishPublished). + WhereNull(columns.DeletedAt).OrderDesc(columns.PolicyVersion).Limit(1).Scan(&row); err != nil { + return nil, gerror.Wrap(err, "获取商户内容审核策略失败") + } + if row.PolicyVersion == 0 || strings.TrimSpace(row.SensitiveWordPolicy) == "" { + return res, nil + } + var policy struct { + Scenes map[string]bool `json:"scenes"` + RiskActions map[string]string `json:"risk_actions"` + FailureMode string `json:"failure_mode"` + BlockMessage string `json:"block_message"` + } + if err = json.Unmarshal([]byte(row.SensitiveWordPolicy), &policy); err != nil { + return nil, gerror.Wrap(err, "商户内容审核策略格式错误") + } + res.PolicyVersion = row.PolicyVersion + for scene, enabled := range policy.Scenes { + res.Scenes[scene] = enabled + } + for risk, action := range policy.RiskActions { + if action == "allow" || action == "block" || action == "audit_only" { + res.RiskActions[risk] = action + } + } + res.FailureMode = moderationFailureMode(policy.FailureMode) + if strings.TrimSpace(policy.BlockMessage) != "" { + res.BlockMessage = strings.TrimSpace(policy.BlockMessage) + } + return res, nil +} + +func (c *cConfig) SensitiveWords(ctx context.Context, req *api.SensitiveWordsReq) (res *api.SensitiveWordsRes, err error) { + tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, strings.TrimSpace(req.Code)) + if err != nil { + return nil, err + } + res = &api.SensitiveWordsRes{TenantID: tenant.Id, TenantCode: tenant.Code, Items: []*api.SensitiveWordModel{}} + columns := dao.ImSensitiveWords.Columns() + groupColumns := dao.ImSensitiveWordGroups.Columns() + var rows []struct { + ID int64 `json:"id"` + Word string `json:"word"` + MatchType string `json:"match_type"` + SceneJSON string `json:"scene_json"` + Action string `json:"action"` + Replacement string `json:"replacement"` + RiskLevel string `json:"risk_level"` + Priority int `json:"priority"` + UpdatedAt *gtime.Time `json:"updated_at"` + } + err = imtenant.RegistryDB().Model(dao.ImSensitiveWords.Table()+" w").Safe().Ctx(ctx). + LeftJoin(dao.ImSensitiveWordGroups.Table()+" g", "g."+groupColumns.Id+"=w."+columns.GroupId). + Fields("w."+columns.Id, "w."+columns.Word, "w."+columns.MatchType, "CAST(w."+columns.SceneJson+" AS CHAR) scene_json", "w."+columns.Action, "w."+columns.Replacement, "w."+columns.RiskLevel, "w."+columns.Priority, "w."+columns.UpdatedAt). + Where("w."+columns.TenantId, tenant.Id).Where("w."+columns.Status, consts.StatusEnabled).WhereNull("w."+columns.DeletedAt). + Where("g."+groupColumns.Status, consts.StatusEnabled).WhereNull("g." + groupColumns.DeletedAt). + OrderDesc("w." + columns.Priority).OrderAsc("w." + columns.Id).Scan(&rows) + if err != nil { + return nil, gerror.Wrap(err, "获取商户有效敏感词失败") + } + for _, row := range rows { + var scenes []string + _ = json.Unmarshal([]byte(row.SceneJSON), &scenes) + res.Items = append(res.Items, &api.SensitiveWordModel{ID: row.ID, Word: row.Word, MatchType: row.MatchType, Scenes: scenes, Action: row.Action, Replacement: row.Replacement, RiskLevel: row.RiskLevel, Priority: row.Priority}) + if row.UpdatedAt != nil && row.UpdatedAt.TimestampMilli() > res.Version { + res.Version = row.UpdatedAt.TimestampMilli() + } + } + return res, nil +} + +func defaultModerationPolicy(tenant *entity.ImTenant) *api.ModerationPolicyRes { + return &api.ModerationPolicyRes{TenantID: tenant.Id, TenantCode: tenant.Code, Enabled: false, + AliyunEnabled: false, LocalWordsEnabled: false, Scenes: cloneBoolMap(defaultModerationScenes), + RiskActions: cloneStringMap(defaultModerationRiskActions), FailureMode: "allow", + BlockMessage: "内容不符合平台规范,请修改后重试"} +} + +func cloneBoolMap(src map[string]bool) map[string]bool { + dst := make(map[string]bool, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} +func cloneStringMap(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} +func stringValue(values map[string]any, key, fallback string) string { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} +func intValue(values map[string]any, key string, fallback int) int { + value := gconv.Int(values[key]) + if value <= 0 { + return fallback + } + return value +} +func moderationFailureMode(value string) string { + if strings.TrimSpace(value) == "block" { + return "block" + } + return "allow" +} + func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLoginIPWhitelistReq) (res *api.TenantLoginIPWhitelistRes, err error) { if _, err = getTenant(ctx, dao.ImTenant.Columns().Id, req.Id); err != nil { return nil, err diff --git a/server/internal/library/immoderation/aliyun.go b/server/internal/library/immoderation/aliyun.go new file mode 100644 index 0000000..c04ff19 --- /dev/null +++ b/server/internal/library/immoderation/aliyun.go @@ -0,0 +1,57 @@ +package immoderation + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + green "github.com/alibabacloud-go/green-20220302/v3/client" + "github.com/alibabacloud-go/tea/dara" +) + +type AliyunConfig struct { + AccessKeyID string + AccessKeySecret string + RegionID string + Endpoint string + Service string + Timeout time.Duration +} + +type CheckResult struct { + RequestID string + Latency time.Duration +} + +func CheckAliyunText(ctx context.Context, config AliyunConfig) (*CheckResult, error) { + if strings.TrimSpace(config.AccessKeyID) == "" || strings.TrimSpace(config.AccessKeySecret) == "" { + return nil, errors.New("AccessKey ID或AccessKey Secret未配置") + } + clientConfig := &openapi.Config{AccessKeyId: dara.String(config.AccessKeyID), AccessKeySecret: dara.String(config.AccessKeySecret), RegionId: dara.String(config.RegionID), Protocol: dara.String("https")} + if endpoint := strings.TrimSpace(config.Endpoint); endpoint != "" { + clientConfig.Endpoint = dara.String(endpoint) + } + client, err := green.NewClient(clientConfig) + if err != nil { + return nil, err + } + if config.Timeout <= 0 { + config.Timeout = 1500 * time.Millisecond + } + ctx, cancel := context.WithTimeout(ctx, config.Timeout) + defer cancel() + params, _ := json.Marshal(map[string]string{"content": "内容审核连通性测试", "dataId": "admin-config-check"}) + started := time.Now() + resp, err := client.TextModerationPlusWithContext(ctx, &green.TextModerationPlusRequest{Service: dara.String(config.Service), ServiceParameters: dara.String(string(params))}, &dara.RuntimeOptions{}) + latency := time.Since(started) + if err != nil { + return nil, err + } + if resp == nil || resp.Body == nil || dara.Int32Value(resp.Body.Code) != 200 { + return nil, errors.New("阿里云内容审核返回失败") + } + return &CheckResult{RequestID: dara.StringValue(resp.Body.RequestId), Latency: latency}, nil +} diff --git a/server/internal/logic/sys/im_external_config.go b/server/internal/logic/sys/im_external_config.go index e742b12..4984233 100644 --- a/server/internal/logic/sys/im_external_config.go +++ b/server/internal/logic/sys/im_external_config.go @@ -5,16 +5,22 @@ import ( "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/contexts" + "hotgo/internal/library/immoderation" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "encoding/json" + "fmt" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "strings" + "time" ) type sSysImExternalConfig struct{} @@ -36,7 +42,13 @@ func (s *sSysImExternalConfig) List(ctx context.Context, in *sysin.ImExternalCon if err = s.ensureRows(ctx, tenant.Id); err != nil { return } + if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" { + return nil, 0, gerror.New("第一阶段仅默认商户可配置内容审核服务") + } mod := s.model(ctx).Where("tenant_id", tenant.Id) + if tenant.Code != "default" { + mod = mod.WhereNot("service", consts.ImExternalServiceModeration) + } if in.Service != "" { mod = mod.Where("service", in.Service) } @@ -68,6 +80,9 @@ func (s *sSysImExternalConfig) View(ctx context.Context, in *sysin.ImExternalCon if err = s.ensureRows(ctx, tenant.Id); err != nil { return nil, err } + if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" { + return nil, gerror.New("第一阶段仅默认商户可查看内容审核服务") + } if err = s.model(ctx).Where("tenant_id", tenant.Id).Where("service", in.Service).Scan(&res); err != nil { return nil, gerror.Wrap(err, "获取IM外部服务配置详情失败,请稍后重试!") } @@ -91,6 +106,36 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC if err = s.ensureRows(ctx, tenant.Id); err != nil { return err } + if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" { + return gerror.New("第一阶段仅默认商户可配置内容审核服务") + } + if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) { + return gerror.New("只有平台管理员可以维护内容审核服务") + } + if in.Service == consts.ImExternalServiceModeration { + if in.Provider != "aliyun_green" { + return gerror.New("内容审核服务商必须为aliyun_green") + } + if strings.TrimSpace(in.Endpoint) == "" { + in.Endpoint = "green-cip.cn-shanghai.aliyuncs.com" + } + if in.ConfigJson == nil { + in.ConfigJson = gjson.New(map[string]any{"region_id": "cn-shanghai", "nickname_service": "nickname_detection_pro", "chat_service": "chat_detection_pro", "timeout_ms": 1500, "failure_mode": "allow"}) + } + config := in.ConfigJson.Map() + if timeout := gconv.Int(config["timeout_ms"]); timeout < 200 || timeout > 10000 { + return gerror.New("内容审核超时必须为200至10000毫秒") + } + if mode := gconv.String(config["failure_mode"]); mode != "allow" && mode != "block" { + return gerror.New("内容审核异常策略不合法") + } + if in.SecretJson != nil && in.SecretJson.String() != "" && in.SecretJson.String() != "null" && in.SecretJson.String() != "{}" { + secret := in.SecretJson.Map() + if strings.TrimSpace(gconv.String(secret["access_key_id"])) == "" || strings.TrimSpace(gconv.String(secret["access_key_secret"])) == "" { + return gerror.New("AccessKey ID和AccessKey Secret必须同时填写") + } + } + } configJson := "{}" if in.ConfigJson != nil && in.ConfigJson.String() != "" && in.ConfigJson.String() != "null" { configJson = in.ConfigJson.String() @@ -113,6 +158,9 @@ func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalC _ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_update", "im_external_config", 0, "success", g.Map{ "service": in.Service, "provider": in.Provider, "enabled": in.Enabled, }) + if in.Service == consts.ImExternalServiceModeration { + _ = publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "third_party.updated", "moderation", 0) + } return nil } @@ -127,6 +175,12 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo if err = s.ensureRows(ctx, tenant.Id); err != nil { return err } + if in.Service == consts.ImExternalServiceModeration && tenant.Code != "default" { + return gerror.New("第一阶段仅默认商户可检查内容审核服务") + } + if in.Service == consts.ImExternalServiceModeration && !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) { + return gerror.New("只有平台管理员可以检查内容审核服务") + } var row gdb.Record if row, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). Where("tenant_id", tenant.Id).Where("service", in.Service).One(); err != nil { @@ -137,6 +191,8 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo if row == nil || row["enabled"].Int() != 1 || row["provider"].String() == "" { status = consts.ImExternalConfigStatusUnconfigured result = "未启用或服务商为空" + } else if in.Service == consts.ImExternalServiceModeration { + status, result = s.checkModeration(ctx, row) } if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx). Where("tenant_id", tenant.Id).Where("service", in.Service). @@ -150,6 +206,43 @@ func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalCo return nil } +func (s *sSysImExternalConfig) checkModeration(ctx context.Context, row gdb.Record) (string, string) { + if row["provider"].String() != "aliyun_green" { + return consts.ImExternalConfigStatusUnconfigured, "内容审核服务商必须为aliyun_green" + } + config := map[string]any{} + secret := map[string]any{} + _ = json.Unmarshal([]byte(row["config_json"].String()), &config) + _ = json.Unmarshal([]byte(row["secret_json"].String()), &secret) + timeoutMS := gconv.Int(config["timeout_ms"]) + if timeoutMS <= 0 { + timeoutMS = 1500 + } + check, err := immoderation.CheckAliyunText(ctx, immoderation.AliyunConfig{ + AccessKeyID: gconv.String(secret["access_key_id"]), AccessKeySecret: gconv.String(secret["access_key_secret"]), + RegionID: firstExternalValue(gconv.String(config["region_id"]), "cn-shanghai"), Endpoint: row["endpoint"].String(), + Service: firstExternalValue(gconv.String(config["nickname_service"]), "nickname_detection_pro"), Timeout: time.Duration(timeoutMS) * time.Millisecond, + }) + if err != nil { + return consts.ImExternalConfigStatusUnconfigured, "连通性检查失败:" + sanitizeExternalError(err) + } + return consts.ImExternalConfigStatusConfigured, fmt.Sprintf("检查成功,耗时%dms,RequestID=%s", check.Latency.Milliseconds(), check.RequestID) +} + +func firstExternalValue(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} +func sanitizeExternalError(err error) string { + value := strings.ReplaceAll(err.Error(), "\n", " ") + if len([]rune(value)) > 120 { + value = string([]rune(value)[:120]) + } + return value +} + func (s *sSysImExternalConfig) model(ctx context.Context) *gdb.Model { return imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).Fields( "tenant_id", diff --git a/server/internal/logic/sys/im_security_policy.go b/server/internal/logic/sys/im_security_policy.go index eec3393..b54c44e 100644 --- a/server/internal/logic/sys/im_security_policy.go +++ b/server/internal/logic/sys/im_security_policy.go @@ -92,7 +92,7 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol if err = in.Filter(ctx); err != nil { return } - tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + tenantID, tenantCode, err := resolveImPolicyTenant(ctx, in.TenantId) if err != nil { return 0, err } @@ -123,7 +123,7 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol } version = uint(maxValue) + 1 } - data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: policyJSON(in.SensitiveWordPolicy, "{}"), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator} + data := g.Map{c.TenantId: tenantID, c.PolicyVersion: version, c.PolicyName: in.PolicyName, c.PublishStatus: consts.ImSecurityPublishDraft, c.PasswordPolicy: policyJSON(in.PasswordPolicy, "{}"), c.LoginRateLimit: policyJSON(in.LoginRateLimit, "{}"), c.SensitiveWordPolicy: normalizedModerationPolicy(in.SensitiveWordPolicy, tenantCode), c.FileReviewPolicy: policyJSON(in.FileReviewPolicy, "{}"), c.RealnamePolicy: policyJSON(in.RealnamePolicy, "{}"), c.RiskChallengePolicy: policyJSON(in.RiskChallengePolicy, "{}"), c.GlobalFriendEnabled: in.GlobalFriendEnabled, c.FriendDisabledPolicy: policyJSON(in.FriendDisabledPolicy, `{"hide_profile":true,"block_qrcode":true,"block_group_member_entry":true,"blocked_entries":["search","profile_card","group_member","qrcode","invite_auto_add","recommendation"],"message":"当前时段暂不允许添加好友"}`), c.MessageCurfewEnabled: in.MessageCurfewEnabled, c.Remark: in.Remark, c.UpdatedAt: now, c.UpdatedBy: operator} if in.Id > 0 { if _, updateErr := tx.Model(dao.ImSecurityPolicy.Table()).Safe().Ctx(ctx).Where(c.Id, in.Id).Data(data).Update(); updateErr != nil { return updateErr @@ -158,6 +158,20 @@ func (s *sSysImSecurityPolicy) Save(ctx context.Context, in *sysin.ImSecurityPol return id, nil } +func normalizedModerationPolicy(value *gjson.Json, _ string) string { + policy := map[string]any{ + "scenes": map[string]bool{"user_nickname": true, "user_signature": true, "group_name": true, "group_nickname": true, "private_message": true, "group_message": true, "group_announcement": true}, + "risk_actions": map[string]string{"high": "block", "medium": "block", "low": "audit_only", "none": "allow"}, + "failure_mode": "allow", "block_message": "内容不符合平台规范,请修改后重试", + } + if value != nil && value.String() != "" && value.String() != "null" { + for key, item := range value.Map() { + policy[key] = item + } + } + return gjson.New(policy).String() +} + func (s *sSysImSecurityPolicy) Publish(ctx context.Context, in *sysin.ImSecurityPolicyPublishInp) error { if err := in.Filter(ctx); err != nil { return err diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index 5a95f35..b2ac7c6 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -25,6 +25,21 @@ import ( type sSysImTenant struct{} +const ( + tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" + tenantConfigLocalModerationEnabled = "local_moderation_enabled" +) + +type imTenantConfigModeration struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenant_id"` + MaxUsers uint64 `json:"max_users"` + MaxGroups uint64 `json:"max_groups"` + MaxGroupMembers uint `json:"max_group_members"` + AliyunModerationEnabled int `json:"aliyun_moderation_enabled"` + LocalModerationEnabled int `json:"local_moderation_enabled"` +} + func NewSysImTenant() *sSysImTenant { return &sSysImTenant{} } @@ -104,13 +119,13 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan ids = append(ids, item.Id) } - var configs []*entity.ImTenantConfig + var configs []*imTenantConfigModeration if err = s.configModel(ctx). WhereIn(dao.ImTenantConfig.Columns().TenantId, ids). Scan(&configs); err != nil { return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") } - configMap := make(map[int64]*entity.ImTenantConfig, len(configs)) + configMap := make(map[int64]*imTenantConfigModeration, len(configs)) for _, item := range configs { configMap[item.TenantId] = item } @@ -132,6 +147,8 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan item.MaxUsers = conf.MaxUsers item.MaxGroups = conf.MaxGroups item.MaxGroupMembers = conf.MaxGroupMembers + item.AliyunModerationEnabled = conf.AliyunModerationEnabled + item.LocalModerationEnabled = conf.LocalModerationEnabled } item.DbDsnConfigured = dsnMap[item.Id] != "" } @@ -144,7 +161,8 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err return } - return imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + tenantID := in.Id + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil { return } @@ -157,7 +175,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err WherePri(in.Id).Data(in).Update(); err != nil { return gerror.Wrap(err, "修改IM租户失败,请稍后重试!") } - return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId) + return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, in.AliyunModerationEnabled, in.LocalModerationEnabled, userId) } in.CreatedBy = userId @@ -168,8 +186,14 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err if err != nil { return gerror.Wrap(err, "新增IM租户失败,请稍后重试!") } - return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId) + tenantID = id + return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, in.AliyunModerationEnabled, in.LocalModerationEnabled, userId) }) + if err != nil { + return err + } + _ = publishImPolicyEvent(ctx, tenantID, in.Code, "tenant.features.updated", "moderation", 0) + return nil } func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) { @@ -187,7 +211,7 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin return } -func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, userId int64) (err error) { +func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, aliyunModerationEnabled, localModerationEnabled int, userId int64) (err error) { var conf *entity.ImTenantConfig if err = s.configModel(ctx). Where(dao.ImTenantConfig.Columns().TenantId, tenantId). @@ -199,6 +223,8 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers dao.ImTenantConfig.Columns().MaxUsers: maxUsers, dao.ImTenantConfig.Columns().MaxGroups: maxGroups, dao.ImTenantConfig.Columns().MaxGroupMembers: maxGroupMembers, + tenantConfigAliyunModerationEnabled: aliyunModerationEnabled, + tenantConfigLocalModerationEnabled: localModerationEnabled, } if conf != nil && conf.Id > 0 { data[dao.ImTenantConfig.Columns().UpdatedBy] = userId @@ -207,6 +233,8 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers dao.ImTenantConfig.Columns().MaxUsers, dao.ImTenantConfig.Columns().MaxGroups, dao.ImTenantConfig.Columns().MaxGroupMembers, + tenantConfigAliyunModerationEnabled, + tenantConfigLocalModerationEnabled, dao.ImTenantConfig.Columns().UpdatedBy, ). WherePri(conf.Id).Data(data).Update(); err != nil { @@ -224,6 +252,8 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers dao.ImTenantConfig.Columns().MaxUsers, dao.ImTenantConfig.Columns().MaxGroups, dao.ImTenantConfig.Columns().MaxGroupMembers, + tenantConfigAliyunModerationEnabled, + tenantConfigLocalModerationEnabled, dao.ImTenantConfig.Columns().CreatedBy, dao.ImTenantConfig.Columns().UpdatedBy, ). @@ -246,7 +276,7 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res return } - var conf *entity.ImTenantConfig + var conf *imTenantConfigModeration if err = s.configModel(ctx). Where(dao.ImTenantConfig.Columns().TenantId, in.Id). Scan(&conf); err != nil { @@ -257,6 +287,8 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res res.MaxUsers = conf.MaxUsers res.MaxGroups = conf.MaxGroups res.MaxGroupMembers = conf.MaxGroupMembers + res.AliyunModerationEnabled = conf.AliyunModerationEnabled + res.LocalModerationEnabled = conf.LocalModerationEnabled } res.DbDsnConfigured = res.DbDsn != "" return diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go index e74ece2..274be13 100644 --- a/server/internal/model/input/sysin/im_tenant.go +++ b/server/internal/model/input/sysin/im_tenant.go @@ -43,27 +43,33 @@ type ImTenantInsertFields struct { // ImTenantConfigUpdateFields 修改IM租户配置字段过滤 type ImTenantConfigUpdateFields struct { - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` } // ImTenantConfigInsertFields 新增IM租户配置字段过滤 type ImTenantConfigInsertFields struct { - TenantId int64 `json:"tenantId" dc:"租户ID"` - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` } // ImTenantEditInp 修改/新增IM租户 type ImTenantEditInp struct { entity.ImTenant - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` } func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { @@ -79,6 +85,10 @@ func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { if !dict.HasOptionKey(consts.ImTenantStateOptions, in.State) { return gerror.New("租户状态不正确") } + if (in.AliyunModerationEnabled != 0 && in.AliyunModerationEnabled != 1) || + (in.LocalModerationEnabled != 0 && in.LocalModerationEnabled != 1) { + return gerror.New("内容审核开关值不正确") + } return } @@ -95,14 +105,16 @@ func (in *ImTenantViewInp) Filter(ctx context.Context) (err error) { type ImTenantViewModel struct { entity.ImTenant - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` - TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` - TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` + TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` } // ImTenantListInp 获取IM租户列表 @@ -121,22 +133,24 @@ func (in *ImTenantListInp) Filter(ctx context.Context) (err error) { } type ImTenantListModel struct { - Id int64 `json:"id" dc:"ID"` - Code string `json:"code" dc:"租户编码"` - Name string `json:"name" dc:"租户名称"` - TenantDesc string `json:"tenantDesc" dc:"租户描述"` - DbName string `json:"dbName" dc:"业务库名"` - State int `json:"state" dc:"状态"` - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + TenantDesc string `json:"tenantDesc" dc:"租户描述"` + DbName string `json:"dbName" dc:"业务库名"` + State int `json:"state" dc:"状态"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` } // ImTenantStatusInp 更新IM租户状态 diff --git a/web/src/views/imExternalConfig/edit.vue b/web/src/views/imExternalConfig/edit.vue index 889b61b..3aeb4ad 100644 --- a/web/src/views/imExternalConfig/edit.vue +++ b/web/src/views/imExternalConfig/edit.vue @@ -8,7 +8,11 @@ > - + @@ -16,7 +20,42 @@ - + + - + formValue.value.service === 'moderation'); + const moderationConfig = ref({ + region_id: 'cn-shanghai', + nickname_service: 'nickname_detection_pro', + chat_service: 'chat_detection_pro', + timeout_ms: 1500, + failure_mode: 'allow', + }); + const moderationSecret = ref({ access_key_id: '', access_key_secret: '' }); function closeForm() { showModal.value = false; @@ -86,8 +134,14 @@ async function handleSubmit() { submitLoading.value = true; try { - const configJson = parseJson(formValue.value.configJson || '{}', '普通配置') || {}; - const secretJson = parseJson(formValue.value.secretJson, '敏感配置'); + const configJson = isModeration.value + ? moderationConfig.value + : parseJson(formValue.value.configJson || '{}', '普通配置') || {}; + const secretJson = isModeration.value + ? moderationSecret.value.access_key_id || moderationSecret.value.access_key_secret + ? moderationSecret.value + : null + : parseJson(formValue.value.secretJson, '敏感配置'); await Update({ tenantId: formValue.value.tenantId, service: formValue.value.service, @@ -111,6 +165,15 @@ formValue.value = newState(state); formValue.value.secretJson = ''; if (!formValue.value.configJson) formValue.value.configJson = '{}'; + if (formValue.value.service === 'moderation') { + formValue.value.provider = 'aliyun_green'; + formValue.value.endpoint ||= 'green-cip.cn-shanghai.aliyuncs.com'; + moderationConfig.value = { + ...moderationConfig.value, + ...(parseJson(formValue.value.configJson, '普通配置') || {}), + }; + moderationSecret.value = { access_key_id: '', access_key_secret: '' }; + } showModal.value = true; } diff --git a/web/src/views/imTenant/edit.vue b/web/src/views/imTenant/edit.vue index da8a7de..f6483a5 100644 --- a/web/src/views/imTenant/edit.vue +++ b/web/src/views/imTenant/edit.vue @@ -82,6 +82,24 @@ /> + + + + + + + + + + 0 ? row.maxGroupMembers : '不限'; }, }, + { + title: '阿里云审核', + key: 'aliyunModerationEnabled', + align: 'left', + width: 120, + render(row: State) { + return h( + NTag, + { type: row.aliyunModerationEnabled === 1 ? 'success' : 'default', bordered: false }, + { default: () => (row.aliyunModerationEnabled === 1 ? '已启用' : '未启用') } + ); + }, + }, + { + title: '本地审核', + key: 'localModerationEnabled', + align: 'left', + width: 120, + render(row: State) { + return h( + NTag, + { type: row.localModerationEnabled === 1 ? 'success' : 'default', bordered: false }, + { default: () => (row.localModerationEnabled === 1 ? '已启用' : '未启用') } + ); + }, + }, { title: '创建者', key: 'createdBy',