516 lines
22 KiB
Markdown
516 lines
22 KiB
Markdown
# 敏感词与发送处罚
|
||
|
||
> 对照:IM 文本发送机审(词库 + 分类处罚)
|
||
> 形态:模块化单体内独立业务 `moderation`,挂在现有 `message/logic.SendMessage` 上
|
||
> 状态:**v1 已落地**(发送闸 + 文本机审 + Admin 词库/命中 + 封禁踢线)。部署前执行 `migrations/0028_moderation.sql`、`0029_sensitive_words_seed.sql`。
|
||
> 硬约束:`docs/architecture-v3.md` C1–C7。不上 Kafka、K8s、微服务。表一律 `t_*`。
|
||
|
||
本文按 **2026-09-29 代码** 写成可实施说明,不是空想架构。HTTP 与 TCP 已共用 `internal/module/message/logic`;Gin / gnet 只绑参。`logic` 测试禁止连 MySQL。
|
||
|
||
---
|
||
|
||
# 0. 范围
|
||
|
||
## 0.1 做(v1)
|
||
|
||
| 能力 | 说明 |
|
||
| --- | --- |
|
||
| 文本机审 | 聊天 `text.body`;朋友圈/广场正文与评论;用户昵称;群公告。同一词库、同一 `GateText`/`Scan` |
|
||
| 分类 | 涉黑 / 涉毒 / 涉黄 / 涉赌 / 涉政 / 宗教 / 辱骂 / 广告 / 其他 |
|
||
| 处罚 | 按词条 `action` 执行;一条消息多命中取 **最严** action |
|
||
| 账号闸 | 发送任意类型前检查 `t_users.status` 与 `send_muted_until` |
|
||
| 封号踢线 | `disable` 与 Admin 封禁:`status=0` + JWT `LogoutAll` + TCP `KickUser` |
|
||
| 词库运营 | Admin CRUD + 进程内自动机热替换 |
|
||
| 审计 | `t_sensitive_hits`(片段,不存全文) |
|
||
|
||
## 0.2 明确不做(v1)
|
||
|
||
- 图片 OCR、语音 ASR、视频抽帧、谐音/拼音/变形字模型
|
||
- 文件名、位置名称、群名片昵称
|
||
- 独立审核微服务、Kafka、按词 `LIKE` 打发送热路径
|
||
- 把处罚做成群 `speak_muted` / `muted_all`(那是群维度,已由 `group/logic.CanSend` 处理)
|
||
- 用现有 Admin「白名单 / 异常标记」接口(二者目前是空实现,忽略写入)
|
||
- 给客户端回传命中原文(避免探测词库)
|
||
|
||
## 0.3 和现码的关系
|
||
|
||
| 现码 | 敏感词怎么接 |
|
||
| --- | --- |
|
||
| `SendMessage`:解 content → `prepareSend` → persist → `deliver:queue` → ACK | 在 persist **之前** 扫词;reject / mute / disable **不 INSERT、不 ACK**(C5) |
|
||
| HTTP `handler/message`、TCP `handleSendMessage` | 零改业务,只多注入依赖 |
|
||
| `FriendGate` / `GroupGate` | 并列增加 `AccountGate`、`Filter` |
|
||
| `users.status` 0/1;登录拒绝停用账号 | 发送侧补同一检查;停用 JWT 残留不能再发 |
|
||
| Admin `BanByEmail` 只改 status | 与 `disable` 共用踢线;封禁补 `LogoutAll`+`KickUser` |
|
||
| 注销 `revokeAccess` | 封号复用同一对接口 |
|
||
| `Message.StatusHidden=3`;历史 `status <> 3` | `hide`:落库 Hidden、**不** Push;会话同步已看不到 |
|
||
| 广场 `AuditPending/Approved` | **不要**和 IM Hidden 混用;广场二期自己打 Audit |
|
||
| `friend.Report` | 用户举人对人,与词库命中分流;hits 给运营 |
|
||
| `reqctx.RealIP` | 命中审计写 PROXY IP(C6) |
|
||
| `GET /v1/meta/enums` | 增加 category / action / envelope `40302` |
|
||
| 表前缀 | `t_sensitive_words`、`t_sensitive_hits`;用户列加在 `t_users` |
|
||
|
||
| V3 | 怎么守 |
|
||
| --- | --- |
|
||
| C1 | 聊天仍是 MsgId;词库行 id 自增;hits 用 Snowflake |
|
||
| C2 | 扫词只在 TaskPool 的 SEND 上;EventLoop 不碰词库 |
|
||
| C5 | 未 COMMIT+enqueue 不 ACK;reject 路径无行 |
|
||
| C6 | hits.real_ip 用 RealIP |
|
||
| C7 | 不写会话 ZSET;hide 不 fanout |
|
||
|
||
---
|
||
|
||
# 1. 总览
|
||
|
||
```text
|
||
客户端 SEND_MESSAGE / POST 发消息
|
||
│ TaskPool(已有)
|
||
▼
|
||
message/logic.SendMessage
|
||
│
|
||
├─ AccountGate.CanSend(uid) 停用 / 全站禁言未到期 → 403
|
||
├─ prepareSend 好友 / 群禁言(已有)
|
||
├─ 若文本:Filter.Scan(body)
|
||
│ └─ Apply(hit):处罚写库 → 改写或拒绝
|
||
├─ persistSend CreateAndBump
|
||
└─ DeliverQueue.Push hide 跳过
|
||
│
|
||
Admin ── moderation/logic ── t_sensitive_words
|
||
内存 AC 自动机(热替换)
|
||
```
|
||
|
||
匹配器无 I/O:`internal/pkg/sensitive`(stdlib)。词表从 MySQL 加载到进程;单进程单体改词后直接 Rebuild,不上 Redis PubSub。
|
||
|
||
---
|
||
|
||
# 2. 模块与目录
|
||
|
||
```text
|
||
internal/pkg/sensitive/ Aho-Corasick + 文本规范化(无 DSN)
|
||
internal/module/moderation/
|
||
entity/ Category, Action, Word, Hit, ScanResult
|
||
model/ word.go, hit.go, matcher.go(持有自动机;TableName → t_*)
|
||
logic/ scan.go, apply.go, words.go, hits.go, reload.go, service.go
|
||
internal/transport/http/handler/admin/ 词库与命中(薄 MVC)
|
||
```
|
||
|
||
`message/logic.Service` 增加字段,**不** import `moderation/model`:
|
||
|
||
```go
|
||
type AccountGate interface {
|
||
AssertCanSend(ctx context.Context, userID int64) error
|
||
}
|
||
|
||
type TextFilter interface {
|
||
// Scan 无命中返回 (nil, nil)。
|
||
Scan(ctx context.Context, text string) (*moderationentity.ScanResult, error)
|
||
}
|
||
|
||
type Penalty interface {
|
||
// Apply 在拒绝发送前落处罚。失败返回 error(不 ACK)。
|
||
Apply(ctx context.Context, userID, conversationID int64, in *moderationentity.ApplyInput) error
|
||
}
|
||
```
|
||
|
||
`bootstrap`:`moderation.NewService(...)` → 注入 `NewService(..., account, filter, penalty)`。`filter`/`penalty` 可为同一 `*moderation.Service`。测试用内存词表 stub。
|
||
|
||
`logic` 一文件一职能:`scan.go`、`apply.go`、`words_admin.go`、`hits.go`。
|
||
|
||
---
|
||
|
||
# 3. 发送时序(改 `send.go`)
|
||
|
||
与现有函数对齐,插入点固定:
|
||
|
||
```text
|
||
ready / validate / UnmarshalContent // 已有
|
||
AccountGate.AssertCanSend(userID) // 新增:所有消息类型
|
||
prepareSend // 已有:好友、群 CanSend
|
||
checkReplyTo / checkMentions // 已有
|
||
若 content.Type == TypeText && Text != nil:
|
||
res, err := Filter.Scan(ctx, Text.Body)
|
||
按 res.Action:
|
||
log → Apply 只记 hits,body 不变
|
||
replace → 改 Text.Body 为打码串,再 Marshal
|
||
reject → Apply 记 hits,return 40302
|
||
hide → Apply 记 hits;msg.Status = StatusHidden;Marshal 原文或打码(见 5.4)
|
||
mute → Apply 写 send_muted_until + hits,return 40302
|
||
disable → Apply 停用+踢线 + hits,return 40302
|
||
Marshal(replace 之后必须重新 Marshal)
|
||
stampTargets / assignID / persistSend // 已有
|
||
若 Status != Hidden:enqueueMessage // hide 不入队
|
||
SEND_ACK
|
||
```
|
||
|
||
**幂等:** `client_msg_id` 在 reject 时无行,客户端重试会再次 Scan。`replace` 成功后重试走现有 `persistSend` 的 Duplicate → 返回已落库行,不再二次打码。
|
||
|
||
**处罚写库失败:** 返回 `50001`,不 persist 消息(与 C5 一致:没有成功处罚就当发送失败)。
|
||
|
||
**多命中:** `Scan` 返回全部 hit,`Action = max(severity)`;hits 表一行一词或一行一消息带 JSON word_ids(v1 建议 **一行一词** 便于按类统计)。
|
||
|
||
---
|
||
|
||
# 4. 闭集 enum
|
||
|
||
数字稳定,不回收。entity `Valid()` + OpenAPI `$ref` + `GET /v1/meta/enums` + i18n `enum.sensitive_category.*` / `enum.sensitive_action.*`。
|
||
|
||
## 4.1 Category(词条分类,运营统计)
|
||
|
||
| 值 | name | 中文 |
|
||
| --- | --- | --- |
|
||
| 1 | `crime` | 涉黑 |
|
||
| 2 | `drug` | 涉毒 |
|
||
| 3 | `porn` | 涉黄 |
|
||
| 4 | `gambling` | 涉赌 |
|
||
| 5 | `politics` | 涉政 |
|
||
| 6 | `religion` | 宗教 |
|
||
| 7 | `abuse` | 辱骂 |
|
||
| 8 | `ad` | 广告 |
|
||
| 9 | `other` | 其他 |
|
||
|
||
与 `friend.ReportReason`(1 垃圾 2 骚扰 3 假身份 4 其他)**不是同一套**,禁止复用。
|
||
|
||
## 4.2 Action(处罚,按严厉程度递增)
|
||
|
||
| 值 | name | 行为摘要 |
|
||
| --- | --- | --- |
|
||
| 1 | `log` | 发送成功;只审计 |
|
||
| 2 | `replace` | 命中处替换为 `***` 后发送 |
|
||
| 3 | `reject` | 不落库;40302 |
|
||
| 4 | `hide` | 落库 `status=3`;不 deliver |
|
||
| 5 | `mute` | 全站禁言至 `now+mute_seconds`;不落库;40302 |
|
||
| 6 | `disable` | `status=0` + LogoutAll + KickUser;不落库;40302 |
|
||
|
||
比较用整数大小即可(1<…<6)。
|
||
|
||
## 4.3 类别默认 action(词条可覆盖)
|
||
|
||
实施时写入种子数据或 Admin 缺省,**不是**写死在 Scan 里:
|
||
|
||
| Category | 默认 Action | mute_seconds |
|
||
| --- | --- | --- |
|
||
| 涉政 / 涉黑 / 涉毒 | `disable` | — |
|
||
| 涉赌 / 宗教 / 涉黄 | `reject` | — |
|
||
| 辱骂 | `replace` | — |
|
||
| 广告 | `log` | — |
|
||
| 其他 | `reject` | — |
|
||
|
||
产品若改「涉政先禁言再人工」,只改默认/词条,不改发送链。
|
||
|
||
---
|
||
|
||
# 5. 处罚细节(对齐现码)
|
||
|
||
## 5.1 AccountGate(所有消息类型)
|
||
|
||
`t_users.status != 1` → `40301` 或与登录一致的 `ErrForbidden`(停用账号)。
|
||
`send_muted_until != NULL && until > now` → `40303` `send_muted`(与内容违规分开,便于客户端展示「禁言至某时」)。无该列或已到期则放行。
|
||
|
||
发送图片时若仍在禁言期,同样拦截(禁言是账号能力,不是「只禁文本」)。
|
||
|
||
## 5.2 mute
|
||
|
||
`UPDATE t_users SET send_muted_until = GREATEST(IFNULL(send_muted_until,0), :until)` 避免短禁言覆盖长禁言。`mute_seconds` 来自 **胜出那条词**;多词 mute 取最长秒数。
|
||
|
||
不调用群 `speak_muted`。
|
||
|
||
## 5.3 disable
|
||
|
||
与注销踢线同序、不删用户行:
|
||
|
||
1. `status = 0`
|
||
2. `JWT.LogoutAll`
|
||
3. `Session.KickUser`
|
||
4. KICK 文案新 key `notify.kick.banned`(现有仅 replaced / logout / deleted)
|
||
|
||
Admin `BanByEmail` **改为同一套**,否则运营手点封禁与机审封号行为不一致。
|
||
|
||
## 5.4 hide
|
||
|
||
- `msg.Status = StatusHidden` 后 `CreateAndBump`(会话 last_msg 是否 bump:建议 **仍 bump**,否则对端游标异常;但 Push 跳过,对端靠同步也过滤 Hidden,last 预览可能短暂指向不可见消息——v1 接受;二期可 hide 不 bump)。
|
||
- **不** `queue.Push`。
|
||
- 发送方历史同样 `status <> 3`,自己也看不到。v1 不做成「仅自己可见」。
|
||
- body:默认存原文便于运营抽查 Admin IM;客户端不可见即达到目的。
|
||
|
||
## 5.5 replace
|
||
|
||
按 §6 把规范化命中映回原文 byte 区间,合并重叠后每段换成 `***`。映射断言失败则按 reject。重新 `content.Marshal()` 再入库。
|
||
|
||
## 5.6 log
|
||
|
||
正常 persist + enqueue;只写 hits。
|
||
|
||
---
|
||
|
||
# 6. 匹配
|
||
|
||
发送热路径禁止「对每个词 `strings.Contains` / SQL `LIKE` / 用户正则」。词是字面量;一次线性扫描同时找全部词。匹配发生在 **规范化后的 rune 流** 上,再把命中区间 **映回原文**,供 `replace` 和 `excerpt`。
|
||
|
||
## 6.1 目标与不做
|
||
|
||
| 要挡住 | 例子 | v1 是否覆盖 |
|
||
| --- | --- | --- |
|
||
| 连续子串 | `网络赌博` 含词 `赌博` | 是 |
|
||
| 夹空白 / 标点 | `赌 博`、`赌。博` | 是(规范化时丢掉分隔符) |
|
||
| 全角 / 兼容字 | `賭博`、`AD` | 是(逐 rune 折叠) |
|
||
| 大小写英文 | `Casino` | 是(ToLower) |
|
||
| 零宽插入 | `赌\u200b博` | 是(丢掉 Cf) |
|
||
| 英文词中词 | `ass` ⊂ `class` | **否**(无词边界;中文 IM 优先)。二期可加 `whole_word` |
|
||
| 谐音 / 拆字 / 火星文模型 | `杜博`、`氵+者` | **否** |
|
||
| 图片里的字 | — | **否** |
|
||
|
||
词条 **只存字面**,没有正则,避免 ReDoS。
|
||
|
||
## 6.2 两段式流水线
|
||
|
||
```text
|
||
text.body(UTF-8 原文,最多按现有消息体上限截断扫描)
|
||
│
|
||
▼ FoldAndIndex(词库入库与 Scan 同一函数)
|
||
norm runes + origFrom[] / origTo[]
|
||
│ origFrom[i] = 第 i 个保留 rune 在原文的 byte 起点
|
||
│ origTo[i] = 该 rune 的 byte 终点(开区间)
|
||
▼
|
||
Aho-Corasick.Find(norm) 输出 [n0, n1) 规范化下标 + word_id
|
||
│
|
||
▼
|
||
映回原文 [origFrom[n0], origTo[n1-1])
|
||
多命中:审计保留全部;处罚取 max(action)
|
||
replace:原文区间合并后再打码
|
||
```
|
||
|
||
**禁止**先对整句做 NFKC 再匹配:兼容分解会改变长度(`fi`→`fi`),区间无法稳定映回,`replace` 只能整句 `***`。v1 用 **逐 rune 折叠 + 丢弃分隔**,映射一一对应。
|
||
|
||
## 6.3 FoldAndIndex(规范化)
|
||
|
||
对原文按 UTF-8 解成 rune,从左到右:
|
||
|
||
1. **丢掉**:Unicode 类别 `Cf`(含 U+200B/U+FEFF)、`Zs`、`Zl`、`Zp`;ASCII 空白;常见标点(ASCII ``!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~`` 以及 `,。!?、;:…·「」『』【】()`)。
|
||
2. **保留则折叠**:`unicode.ToLower`;全角拉丁/数字收到半角(`A`→`a`,可用 `golang.org/x/text/width` 的 `Narrow`,或一张小映射表)。不把一个 rune 拆成多个;若折叠结果多于一个 rune,只取第一个(极端兼容字,可测)。
|
||
3. 输出 `norm []rune` 与平行数组 `origFrom` / `origTo`。
|
||
|
||
**入库:** Admin 提交的 `word_raw` 走同一函数得到 `word`(规范化串)。`word` 为空(全是标点)→ 400 拒绝。`uk_word` 建在规范化串上,避免「赌博」与「赌 博」两条。
|
||
|
||
**扫描:** `text.body` 同样 Fold;`len(norm)==0` → 无命中。
|
||
|
||
**最短词:** 允许 1 个 rune,但种子词建议 ≥2。单字「黄」误伤由运营自己负责。
|
||
|
||
## 6.4 自动机
|
||
|
||
标准 **Aho-Corasick**:
|
||
|
||
- 模式 = 各 `enabled=1` 的 `word`(已是规范化串)。
|
||
- 输出链:某位置结束时,报告所有落在该节点上的词(含后缀,例如同时命中 `赌` 与 `赌博`)。
|
||
- 复杂度:建树 O(Σ|word|);扫一句 O(|norm| + 命中次数)。千~万词、单条约 2KB 正文,CPU 可接受。
|
||
- 实现放 `internal/pkg/sensitive`,无 DSN。节点上挂 `[]patternID`(下标进内存词表:id/category/action/mute_seconds)。
|
||
|
||
`enabled=0` 不进树。Reload 时整树重建,`atomic.Pointer` 交换,Scan 无锁读。
|
||
|
||
## 6.5 命中如何变成处罚和打码
|
||
|
||
对每次 AC 命中 `(word_id, n0, n1)`:
|
||
|
||
- 原文区间:`[origFrom[n0], origTo[n1-1])`(byte)。
|
||
- `excerpt`:原文该区间,两端最多再扩 8 rune,总长截断 128(审计用,不回客户端)。
|
||
- `ScanResult.Hits` 保留全部;`ScanResult.Action = max(hits.action)`;mute 秒数取 **胜出 action 为 mute 的那些词里最长**。
|
||
|
||
**replace(仅当最严 action 就是 replace):**
|
||
|
||
1. 收集所有命中的原文 byte 区间。
|
||
2. 按起点排序,合并重叠或相邻区间。
|
||
3. 每个合并区间替换成三个 ASCII `*`(固定宽度,不按原词长度拉长)。
|
||
4. 拼回新 `body`,再 `content.Marshal()`。
|
||
|
||
若最严已是 reject/mute/disable,不打码、不落库。
|
||
|
||
**映射失败:** Fold 保证 `n1-1 < len(origTo)`;实现上断言失败则该次 Scan 当 reject(保守),打日志。不要静默放行。
|
||
|
||
## 6.6 词边界(v1 明确放弃)
|
||
|
||
中文无空格,用「规范化后的连续子串」= 产品语义。英文 `scunthorpe` 类误伤不在 v1 修。运营用更长短语,或二期 `whole_word`:仅当命中两端在原文上为非字母。
|
||
|
||
## 6.7 扫描上限
|
||
|
||
与消息体校验同一上限(现有 content 长度闸)。Matcher 再设硬顶(例如 8192 rune),超长截断再扫——只为绑 CPU,截断本身不算绕过(超长消息应已被发送校验拒绝)。
|
||
|
||
## 6.8 热更新
|
||
|
||
Admin 写 `t_sensitive_words` 成功后 `Reload()`:`WHERE enabled=1` 取出全部 `word`,Fold 校验与库内一致(不一致则跳过该行并打错误日志),建新 AC,交换指针。单进程,不上 Redis。logic 测试用内存 `[]Word` 调 `Build`,不连 MySQL。
|
||
|
||
## 6.9 包职责
|
||
|
||
| 包 | 职责 |
|
||
| --- | --- |
|
||
| `pkg/sensitive` | `FoldAndIndex`、`Matcher.Find`、区间合并;纯函数 + 结构体 |
|
||
| `moderation/model` | 持有 `atomic` 自动机、从 MySQL 装词 |
|
||
| `moderation/logic` | Scan → entity.ScanResult;Apply 处罚 |
|
||
| `message/logic` | 调 Filter,不关心 AC |
|
||
|
||
## 6.10 匹配用例(实施时写成 `pkg/sensitive` 测试)
|
||
|
||
| 原文 | 词库 | 期望 |
|
||
| --- | --- | --- |
|
||
| `今晚赌博` | `赌博` | 命中;replace 后含 `***` |
|
||
| `今晚赌 博啊` | `赌博` | 命中(空白已丢) |
|
||
| `賭博` | `赌博` | 命中(繁简若未折则 **不** 命中:v1 **不做简繁转换**,简繁当两条词) |
|
||
| `Casino` | `casino` | 命中 |
|
||
| `class` | `ass` | **命中**(v1 无词边界,文档接受) |
|
||
| `你好` | `赌博` | 无命中 |
|
||
| `赌\u200b博` | `赌博` | 命中 |
|
||
| 空串 / 纯标点 | 任意 | 无命中 |
|
||
|
||
---
|
||
|
||
# 7. 表
|
||
|
||
迁移新文件(当前最大约 `0027`,实施时接下一个序号)。`cmd/migrate` 全量重放。列 COMMENT 必填。GORM `TableName()` 返回 `t_*`。
|
||
|
||
## 7.1 `t_users` 增量
|
||
|
||
```sql
|
||
ALTER TABLE t_users
|
||
ADD COLUMN send_muted_until DATETIME(3) NULL
|
||
COMMENT '全站禁言截止时间,空或已过期表示可发消息';
|
||
```
|
||
|
||
## 7.2 `t_sensitive_words`
|
||
|
||
```sql
|
||
CREATE TABLE t_sensitive_words (
|
||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增',
|
||
word_raw VARCHAR(64) NOT NULL COMMENT '运营录入原文,展示用',
|
||
word VARCHAR(64) NOT NULL COMMENT 'FoldAndIndex 后的词面,全局唯一,进自动机',
|
||
category TINYINT NOT NULL COMMENT '1涉黑 2涉毒 3涉黄 4涉赌 5涉政 6宗教 7辱骂 8广告 9其他',
|
||
action TINYINT NOT NULL COMMENT '1log 2replace 3reject 4hide 5mute 6disable',
|
||
mute_seconds INT NOT NULL DEFAULT 0 COMMENT 'action=mute 时的禁言秒数',
|
||
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0下线(不进自动机)',
|
||
created_at DATETIME(3) NOT NULL COMMENT '创建时间',
|
||
updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uk_word (word)
|
||
) COMMENT='敏感词;发送热路径使用进程内自动机,本表为真源';
|
||
```
|
||
|
||
`word` 最长 64;更长的短语运营拆词。
|
||
|
||
## 7.3 `t_sensitive_hits`
|
||
|
||
```sql
|
||
CREATE TABLE t_sensitive_hits (
|
||
id BIGINT NOT NULL COMMENT 'Snowflake',
|
||
user_id BIGINT NOT NULL COMMENT '发送者 uid',
|
||
conversation_id BIGINT NOT NULL DEFAULT 0 COMMENT '会话 id,拒绝时已 prepareSend 则有值',
|
||
word_id BIGINT NOT NULL COMMENT 't_sensitive_words.id',
|
||
category TINYINT NOT NULL COMMENT '命中时的类别快照',
|
||
action TINYINT NOT NULL COMMENT '本行对应词的 action;消息级最严 action 另见同批 rows',
|
||
excerpt VARCHAR(128) NOT NULL DEFAULT '' COMMENT '命中片段,截断,禁止整篇正文',
|
||
real_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'PROXY RealIP',
|
||
created_at DATETIME(3) NOT NULL COMMENT '命中时间',
|
||
PRIMARY KEY (id),
|
||
KEY idx_user_created (user_id, created_at),
|
||
KEY idx_created (created_at),
|
||
KEY idx_word (word_id)
|
||
) COMMENT='敏感词命中审计,供 Admin 查询';
|
||
```
|
||
|
||
hits 的 `id` 用 Snowflake(与 MsgId 同类,时间有序)。`word_id` 允许词已删除后仍留快照(不建 FK,避免运营删词失败)。
|
||
|
||
---
|
||
|
||
# 8. 错误码与 i18n
|
||
|
||
| code | name | 何时 |
|
||
| --- | --- | --- |
|
||
| `40301` | forbidden | 停用账号、好友/群权限(已有) |
|
||
| `40302` | content_blocked | reject / mute / disable(内容或因此触发的拒绝发送) |
|
||
| `40303` | send_muted | 仅「禁言未到期、尚未扫词」 |
|
||
|
||
`40302` 的 `msg`:`i18n` key `error.40302` / 业务 `moderation.blocked`:「消息包含违规内容」。
|
||
`data`:v1 空对象。不返回 word、不返回 category。
|
||
|
||
`GET /v1/meta/enums` 的 `envelope_code` 增加 40302、40303。
|
||
|
||
踢线:`notify.kick.banned`。
|
||
|
||
---
|
||
|
||
# 9. Admin HTTP
|
||
|
||
挂在现有 `/api/admin/spark` 与 `/api/admin/pop`(与 music 相同双前缀),JWT AdminAuth。Handler 只 bind + 调 `moderation/logic`。
|
||
|
||
| 方法 | 路径 | 作用 |
|
||
| --- | --- | --- |
|
||
| GET | `/sensitive/words` | 分页;query:`q`、`category`、`enabled` |
|
||
| POST | `/sensitive/words` | 创建;body:word、category、action、mute_seconds、enabled |
|
||
| PATCH | `/sensitive/words/:id` | 改分类/处罚/启用 |
|
||
| DELETE | `/sensitive/words/:id` | 物理删或 enabled=0(推荐软下线) |
|
||
| GET | `/sensitive/hits` | 分页;query:uid、category、since、until |
|
||
| POST | `/sensitive/reload` | 强制 Rebuild 自动机(写入接口已自动 Reload 时可省略) |
|
||
|
||
校验:`oneof` 与 enum 数字一致;`label`/`errmsg` i18n。
|
||
|
||
OpenAPI 写在 `openapi.yaml` 的 `admin-music` tag;路径前缀 `/api/admin/spark/sensitive*`(`pop` 别名)。
|
||
|
||
词库变更后必须 Reload;失败则接口返回 500,避免 DB 与自动机分叉。
|
||
|
||
---
|
||
|
||
# 10. 客户端(IM)
|
||
|
||
- 发送失败认 `40302` / `40303`,展示服务器 `msg`。
|
||
- `replace` / `log` / `hide`:发送接口仍成功 ACK(hide 也 ACK,但自己同步看不到——产品需知)。若 hide 要让发送方感知失败,应改用 reject;**v1 hide 仅用于「落库留证、对端不可见」**,与 ACK 成功并存,文档需让客户端不要用 ACK 当「对端已收到」。
|
||
**更干净的 v1:砍掉 hide,运营留证用 hits + reject。** 推荐 **v1 不实现 hide**,避免 ACK 语义撕裂。需要留证时 reject 已有 excerpt。`StatusHidden` 留给以后人工审核。
|
||
|
||
修订:**v1 Action 不含 hide**(表 enum 仍占位 4,词条禁止配置或配置后当 reject)。二期人工审核再启用 hide。
|
||
|
||
---
|
||
|
||
# 11. 测试
|
||
|
||
`make test`:logic ≥65%。
|
||
|
||
| 包 | 用例 |
|
||
| --- | --- |
|
||
| `pkg/sensitive` | 规范化、多词、最严 action、enabled=0 不匹配、空串 |
|
||
| `moderation/logic` | 内存词表 Scan;Apply mute/disable 调 fake user/session |
|
||
| `message/logic` | stub Filter:命中 reject 无 persist、无 queue;replace 后 content 含 `***`;AccountGate 失败不 persist |
|
||
| HTTP | 可选:发文本 40302 |
|
||
|
||
禁止 moderation logic 测试连真实 MySQL。
|
||
|
||
---
|
||
|
||
# 12. 实施顺序
|
||
|
||
1. 迁移:`send_muted_until`、`t_sensitive_words`、`t_sensitive_hits`;跑 `cmd/migrate`
|
||
2. `pkg/sensitive` + entity enum + meta/i18n/OpenAPI
|
||
3. `moderation` model 内存自动机 + logic Scan/Reload
|
||
4. `AccountGate` 接入 `SendMessage`;Admin 封禁补踢线
|
||
5. 文本 Filter:log / replace / reject
|
||
6. mute / disable
|
||
7. Admin 词库与 hits
|
||
8. 朋友圈/广场正文与评论、昵称、群公告走 `GateText`(会话 id 记 0)
|
||
9. (二期)Action=hide + 人工审核队列
|
||
|
||
---
|
||
|
||
# 13. 默认拍板(实施按此,除非产品改口)
|
||
|
||
1. 涉政 / 涉黑 / 涉毒默认 **disable**(踢线)。
|
||
2. 客户端 **不** 返回 category。
|
||
3. **v1 不做 hide**(enum 4 保留给二期)。
|
||
4. 禁言列名 `send_muted_until`。
|
||
5. 词面 UNIQUE 用规范化串。
|
||
|
||
---
|
||
|
||
# 14. 参考代码锚点
|
||
|
||
- 发送:`internal/module/message/logic/send.go`、`send_perm.go`、`send_persist.go`
|
||
- 注入:`internal/bootstrap/app.go` `msglogic.NewService`
|
||
- 停用登录:`user/logic/login.go` `Status != StatusActive`
|
||
- 踢线:`user/logic/delete.go` `revokeAccess`
|
||
- Admin 封禁:`admin/logic/users.go` `BanByEmail`
|
||
- Hidden 过滤:`message/model/query.go`
|
||
- 群发言:`group/logic/members.go` `CanSend`
|
||
- 表名:`t_users`、`t_private_message`、`t_group_message`
|