23 KiB
敏感词与发送处罚
对照:IM 文本发送机审(词库 + 分类处罚)
形态:模块化单体内独立业务moderation,挂在现有message/logic.SendMessage上
状态:v1 已落地(发送闸 + 文本机审 + Admin 词库/命中 + 封禁踢线)。部署前执行migrations/0028_moderation.sql、0029_sensitive_words_seed.sql。
硬约束:docs/architecture-v3.mdC1–C7。不上 Kafka、K8s、微服务。表一律t_*。
本文按 2026-09-29 代码 写成可实施说明,不是空想架构。HTTP 与 TCP 已共用 internal/module/message/logic;Gin / gnet 只绑参。logic 测试禁止连 MySQL。
0. 范围
0.1 做(v1)
| 能力 | 说明 |
|---|---|
| 文本机审 | 仅 content.t = 1 的 text.body(单聊 t_private_message / 群聊 t_group_message) |
| 分类 | 涉黑 / 涉毒 / 涉黄 / 涉赌 / 涉政 / 宗教 / 辱骂 / 广告 / 其他 |
| 处罚 | 按词条 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、视频抽帧、谐音/拼音/变形字模型
- 朋友圈
moment、广场square、昵称、群公告、文件名、位置名称(同一Scan二期再挂) - 独立审核微服务、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. 总览
客户端 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. 模块与目录
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:
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)
与现有函数对齐,插入点固定:
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
与注销踢线同序、不删用户行:
status = 0JWT.LogoutAllSession.KickUser- 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.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,从左到右:
- 丢掉:Unicode 类别
Cf(含 U+200B/U+FEFF)、Zs、Zl、Zp;ASCII 空白;常见标点(ASCII!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~以及,。!?、;:…·「」『』【】())。 - 保留则折叠:
unicode.ToLower;全角拉丁/数字收到半角(A→a,可用golang.org/x/text/width的Narrow,或一张小映射表)。不把一个 rune 拆成多个;若折叠结果多于一个 rune,只取第一个(极端兼容字,可测)。 - 输出
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):
- 收集所有命中的原文 byte 区间。
- 按起点排序,合并重叠或相邻区间。
- 每个合并区间替换成三个 ASCII
*(固定宽度,不按原词长度拉长)。 - 拼回新
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 增量
ALTER TABLE t_users
ADD COLUMN send_muted_until DATETIME(3) NULL
COMMENT '全站禁言截止时间,空或已过期表示可发消息';
7.2 t_sensitive_words
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
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. 实施顺序
- 迁移:
send_muted_until、t_sensitive_words、t_sensitive_hits;跑cmd/migrate pkg/sensitive+ entity enum + meta/i18n/OpenAPImoderationmodel 内存自动机 + logic Scan/ReloadAccountGate接入SendMessage;Admin 封禁补踢线- 文本 Filter:log / replace / reject
- mute / disable
- Admin 词库与 hits
- (二期)moment/square
Publish调同一 Scan;广场可打AuditPending - (二期)Action=hide + 人工审核队列
13. 默认拍板(实施按此,除非产品改口)
- 涉政 / 涉黑 / 涉毒默认 disable(踢线)。
- 客户端 不 返回 category。
- v1 不做 hide(enum 4 保留给二期)。
- 禁言列名
send_muted_until。 - 词面 UNIQUE 用规范化串。
14. 参考代码锚点
- 发送:
internal/module/message/logic/send.go、send_perm.go、send_persist.go - 注入:
internal/bootstrap/app.gomsglogic.NewService - 停用登录:
user/logic/login.goStatus != StatusActive - 踢线:
user/logic/delete.gorevokeAccess - Admin 封禁:
admin/logic/users.goBanByEmail - Hidden 过滤:
message/model/query.go - 群发言:
group/logic/members.goCanSend - 表名:
t_users、t_private_message、t_group_message