不能只靠gin中间件拦截请求体,因其Request.Body为单次读取流,读取后后续handler无法获取原始数据,且strings.Contains匹配效率低、无法处理变体与模糊绕过,也缺乏审计留痕能力。

直接用 gin 的中间件做敏感词过滤,效果有限——它没法高效匹配变体、无法支持模糊或拼音绕过、更难做审计留痕。真正落地得组合前缀树(Trie)+ 职责链模式 + 请求上下文增强,否则上线后第一波运营投诉就来自“为啥‘草莓’被干掉了”。
为什么不能只靠 gin 中间件拦截请求体?
gin 的 gin.Context.Request.Body 是单次读取流,中间件里读一次后后续 handler 就拿不到原始数据了;而且字符串 strings.Contains 匹配既慢又漏(比如“和-谐”、“he xie”),更没法记录谁、什么时候、在哪条接口触发了哪个词。
- 必须用
io.MultiReader或提前复制bytes.Buffer保留原始 body 供后续使用 - 纯正则或子串匹配在万级敏感词下性能断崖式下跌,
Trie查找复杂度是 O(m),m 是待检文本长度 - 审计日志需要绑定
requestID、用户 ID、路径、匹配词、时间戳——这些信息分散在不同中间件阶段,得靠context.WithValue透传
用前缀树(Trie)构建可更新的敏感词引擎
别手写 Trie 节点,用成熟封装如 github.com/ziyoubiancheng/godir 或轻量自建(参考知识库中 “bad/evil/danger” 的树结构)。关键不是存词,而是支持动态 reload 和部分匹配(如“炸”要命中“炸弹”“炸鸡”,但不误杀“爆炸工程师”)。
- 插入时统一转小写、去除空格、可选展开常见拼音/形近字映射(如“谐→xie→he”)
- 匹配函数返回
[]struct{Word string; Start, End int},方便后续替换或高亮 - 避免用全局变量存 root,改用
sync.RWMutex保护,reload 时原子替换指针 - 示例调用:
matches := trie.FindAll(text),不是trie.Contains(text)
用职责链模式串联过滤与审计逻辑
把“检测→脱敏→记录→告警”拆成独立 Handler,按需编排。gin 中间件只负责启动链,不掺杂业务判断——这样测试、开关、灰度都容易。
- 每个 Handler 实现统一接口:
func(ctx *gin.Context, next func()),内部决定是否调用next - 审计 Handler 必须在链尾执行,且只在匹配到词时写入日志(避免刷爆磁盘),建议异步投递到
chan logEntry - 脱敏 Handler 可配置策略:
replace(***)、mask(草*莓)、block(400 返回) - 注意:链中任一 Handler
ctx.Abort()后,后续 Handler 不会执行,但审计仍需兜底触发(用ctx.OnAbort注册回调)
审计日志字段设计与落盘避坑
安全审计不是记“某人发了敏感词”,而是支撑溯源和合规检查。字段缺一不可,但写法稍有不慎就会拖慢整个请求。
- 必含字段:
request_id、user_id(从 token 解析,非 header 原样)、path、method、matched_words(数组)、timestamp(纳秒级)、client_ip(用ctx.ClientIP(),防伪造) - 禁止同步写文件或直连数据库——用内存缓冲 + 定时刷盘,或投递到本地
filebeat/fluent-bit - 敏感词本身要脱敏存储(如存哈希或 ID 映射),避免日志二次泄露
- 特别注意:审计日志若含用户输入原文,必须先做 Unicode 规范化(
unicode.NFC),否则“abc”(全角)和“abc”会被当成两个词
最易被忽略的是词库热更新后的状态一致性——Trie 树 reload 瞬间,正在处理的请求可能命中旧树或新树,导致漏判或重复告警。解决方案不是加锁阻塞请求,而是让每个请求绑定当前生效的 *Trie 实例指针,reload 时仅切换指针,不中断运行中请求。


















