正确入口是json.Marshaler接口,必须用指针接收器实现防递归别名类型、闭包捕获权限、非空占位符替代,禁用字符串替换和反射遍历,HTTP响应需包装ResponseWriter流式解析脱敏。

用 json.Marshaler 控制字段级脱敏,别碰 JSON 字节流
直接对 json.Marshal 返回的 []byte 做 strings.ReplaceAll 或正则替换,99% 会出问题:把 URL 里的 "phone"、错误信息里的数字、甚至 JSON 的引号和转义符全搞乱。更糟的是嵌套结构体或 nil 指针直接 panic。
正确入口只有一个:json.Marshaler 接口。它让结构体自己决定怎么被序列化,原始字段值完全不动,脱敏只发生在输出端。
- 必须用指针接收器:
func (u *User) MarshalJSON() ([]byte, error),否则无法处理u.Profile.Phone这类嵌套字段或nil指针 - 防无限递归:内部定义别名类型
type Alias User,再用(*Alias)(u)转换 - 脱敏值不能设空字符串:如果字段带
json:",omitempty",脱敏后变空就会整个消失;改用"*** **** ***"或"[REDACTED]" - 权限判断不能塞进方法签名:
json.Marshal不传context.Context,得靠闭包捕获开关,比如redactEnabled := isRedactEnabled(ctx)再在闭包里用
手机号和邮箱掩码要按类型走预编译规则,别用全局正则扫字符串
用 regexp.ReplaceAllString 扫一段日志或响应体去“找手机号”,极易误伤:身份证号中间连续 11 位数字、时间戳、订单号都可能被错掩码。Go 里真正可控的方式是先识别字段类型,再走对应掩码逻辑。
例如:
立即学习“go语言免费学习笔记(深入)”;
- 手机号:保留前 3 位 + 后 4 位,中间补
"****"→"138****1234" - 邮箱:保留用户名首尾字符 + 域名,中间掩码 →
a***@b.com - 身份证:只掩码中间 8 位(18 位)或中间 4 位(15 位),前后保留 →
"110101******1234"
这些规则必须在结构体字段层面绑定,而不是靠运行时扫描字符串。类型不明确时,宁可不脱敏,也不能赌正则匹配精度。
HTTP 响应脱敏必须包装 http.ResponseWriter,中间件读不到原始 body
gin/echo 等框架默认不缓存响应体。c.Next() 执行完、WriteHeader 一发,字节就推到 TCP 连接了。你试图在中间件里读 c.Writer.Body,多数框架根本不提供该字段;就算有,也早已 flush,拿不到原始 JSON。
必须在 handler 执行前替换 c.Writer,实现自定义 ResponseWriter:
- 重写
Write([]byte),仅对Content-Type: application/json做解析,其他类型直通 - 用
json.Decoder.Token()流式解析,匹配到目标 key 路径(如user.phone)后跳过原值并注入掩码,避免全量解码 - 不要尝试
io.TeeReader拦 request body——response 是单向写流,拦不住
日志脱敏靠 slog.Handler 装饰器,不是打日志前手动删字段
zap、zerolog、log 这些库不会识别敏感语义。你传进去什么,它就记什么。panic 时打印的结构体、HTTP 错误响应里的 error 字段、SQL 日志,都可能明文泄露。
Go 1.21+ 的 slog 提供了天然拦截点:Handler 是日志输出前的最后一站。可以写一个 RedactHandler 包装原有 Handler:
- 在
Handle方法中遍历slog.Record.Attrs - 对预设敏感键名(如
"phone"、"email")递归脱敏,非空占位符替代,避免丢字段 - 不修改原始
Record,而是新建一个并添加过滤后属性——slog.Record应视为不可变
最易被忽略的是 panic 场景:panic(user) 触发后,默认用 fmt.Sprint 打印,不走任何 String() 或 MarshalJSON(),必须用 *SafeError 封装后再 panic,recover 时统一处理。


















