邮件正文不该进log日志:通过【lombok注解语法糖】实现从 整块屏蔽 到 安全预览
一次邮件 DTO 日志脱敏的取舍 —— 三条泄露路径、两种方案,以及为什么"少打印一点"比"完全不打印"更可取
背景:发邮件是一个独立的中间服务
在这套体系里,邮件发送不是某个业务系统的内部功能,而是一个独立的中间服务。业务系统只负责组装邮件参数,再通过 RPC 调用这个服务去完成投递:
业务系统 A ─┐
业务系统 B ─┼──▶ 邮件服务(独立中间服务) ──▶ SMTP ──▶ 收件人
业务系统 C ─┘
这个架构决定了三件事,也是后面所有取舍的前提:
- 承载邮件参数的 DTO 是跨系统的传输对象。 为了同时满足不同业务方的诉求,它必须自洽地带齐全套字段 —— 业务类型、业务线、发件邮箱标识、收件人、抄送、标题、正文、附件、自定义发件人配置。业务系统把参数填完就丢过来,邮件服务不再回头找业务方要数据。
- 打印它的地方天然分散。 每个业务系统都会在"组装完参数、准备调用"的位置顺手打一条日志,日志点因此遍布各个业务方,而不集中在邮件服务内部。想靠"挨个改调用点"来整改,既不现实,也一定会漏。
- 正文和发件人配置必须能序列化传输。 这条约束会在第三章直接排掉一个看起来很自然的写法(
@JSONField(serialize = false))。
于是问题的性质就清楚了:这个被所有业务方共享的 DTO,一旦把正文全文打进日志,泄露面就横跨整个服务集群,而不是局限在某一个应用内部 —— 这也正是它值得单独写一篇的原因。
一、先说结论
邮件正文不应该以全文形式出现在日志里。 要改,推荐第二种做法:
| 方案 | 日志里的样子 | 评价 |
|---|---|---|
| 方案一:不打印 | EmailMessageDTO(..., configDTO=null) |
安全,但丢失可观测性 |
| 方案二:打印一部分 | EmailMessageDTO(..., content=尊敬的客户:您好!附件为与贵司确认的客户信息表,请查收...(291)) |
推荐 —— 同等安全,且保住排查能力 |
两者的实现都只在 DTO 里改一处,所有已存在的日志调用点一个都不用动。
二、为什么"邮件正文进日志"本身就该被去掉
2.1 排查邮件问题时,正文几乎从来不是关键信息
真正用来定位一封邮件是否正常发出、发给了谁、走的哪个发件箱,靠的是这些字段:
bizType 业务类型(哪类邮件)
product 业务线
senderTag 发件邮箱标识(哪个发件箱)
receivers 收件人
title 标题
attaches 附件(路径/文件名)
configDTO 自定义发件人配置(host/port,确认用的是哪个 SMTP)
正文全文对定位问题的贡献极低,但它带来的成本很高。
2.2 正文里承载的是"用户可见的敏感信息"
这是问题的核心。邮件正文与其他业务字段不同 —— 它天生就是给人看的,所以内容里必然包含凭据类信息:
| 场景 | 正文里的典型内容 |
|---|---|
| 登录异常提醒邮件 | 企业/账号名称、登录账号 |
| 开通账号 / 重置密码邮件 | 初始密码 |
| 业务通知、推荐类邮件 | 客户名称、手机号、公司名称、职位 |
| 审批 / 工单通知邮件 | 申请内容、附件说明 |
而且问题不止于正文 —— 这类 DTO 里往往还挂着一个嵌套对象:
private SmtpConfigDTO configDTO; // 内含 username + pwd(邮箱密码/授权码)
configDTO.pwd 里装的是真实发件邮箱的授权码,而设置它的代码通常就紧挨着打日志的那一行。只处理 content 是不够的。
2.3 体量
HTML 邮件正文动辄几千字符。一条 log.info("{}", dto) 就能往日志里灌几 KB,在高频发信场景下足以把日志文件撑爆,也会显著拖慢日志输出。
三、动手之前:先搞清日志的"三条路径"
这是整件事里最容易被低估的一步。同一个 DTO,可能被三种方式打印,而它们对注解的反应完全不同:
| 路径 | 写法 | @ToString.Exclude 是否有效 |
说明 |
|---|---|---|---|
| toString | log.info("{}", dto) |
✅ 有效 | 走的就是 Lombok 生成的 toString() |
| JSON 序列化 | log.info("{}", JSON.toJSONString(dto)) |
❌ 完全无效 | fastjson 绕过 toString() 直接序列化字段 |
| 嵌套对象 | dto 内的 configDTO.pwd |
❌ 无效 | @Data 会把嵌套对象整体 toString() |
这两种打印方式在实际代码里往往是混用的:一部分调用点直接传对象,另一部分套了 JSON.toJSONString。只加注解,只能覆盖一半。
一个必须提前确认的约束
content 和 configDTO 都是要经 RPC 传给发信服务的字段,所以:
@JSONField(serialize = false) // ❌ 绝对不能用
private String content;
一旦禁掉序列化,邮件正文根本传不到发信服务,邮件直接发不出去。
这个约束决定了一件事:JSON 序列化这条路径,注解层面堵不住,只能靠"日志里不要序列化这个对象"来收敛。所以那些 JSON.toJSONString(dto) 的调用点要改成直接传对象,把打印路径统一收敛到受保护的 toString():
// 改造前
log.info("发送邮件参数:{}", JSON.toJSONString(emailMessage));
// 改造后 —— 参数个数、占位符都不变,日志格式从 JSON 变成 key=value
log.info("发送邮件参数:{}", emailMessage);
四、方案一:不打印
做法
在 DTO 的 content 字段上加一个 Lombok 注解:
/**
* 邮件正文:可能包含验证码、初始密码等敏感信息。
* 字段真实值不参与 toString(@ToString.Exclude),日志里不再输出。
*/
@ToString.Exclude
@ApiModelProperty(value = "邮件正文")
@NotBlank(message = "邮件正文不能为空")
private String content;
效果
EmailMessageDTO(contentType=TEXT, bizType=ALERT, product=CAR, senderTag=SEHENBIANYUN,
receivers=[ops@example.com], cc=[], title=预警邮件, attaches=null,
configDTO=SmtpConfigDTO(username=ops@example.com, host=smtp.exmail.qq.com, protocol=smtp, port=25))
content 从 toString() 输出里彻底消失。字段本身完好,getContent() 照常返回全文,发信与 RPC 均不受影响。
代价:可观测性归零
这是它的问题。屏蔽之后,日志里再也看不出正文是否正常。几个真实会遇到的排查场景:
- 正文是空串(模板变量没拼上)—— 日志上看不出"本来就是空"还是"字段没传"
- 正文长度异常(平时 2000 字符,这次只有 200)—— 完全看不出来
- 拼接乱码 / 模板发错 —— 完全看不出来
- 想评估一次故障影响的正文规模 —— 没有数据
结果往往是:真出问题的时候,得临时把注解摘掉、重新发版才能看。这正是脱敏改造最常见的"副作用"。
五、方案二:打印一部分(推荐)
思路是:字段真实值依然不进 toString(),但额外提供一个"预览方法"参与输出。
做法
/**
* 邮件正文:可能包含验证码、初始密码等敏感信息。
* 字段真实值不参与 toString(@ToString.Exclude),日志里只输出 contentForLog() 的截断预览。
*/
@ToString.Exclude
@ApiModelProperty(value = "邮件正文")
@NotBlank(message = "邮件正文不能为空")
private String content;
/** 日志里正文保留的字符数,超出部分省略 */
private static final int LOG_CONTENT_LIMIT = 50;
/** 省略号之后的长度标注格式,形如 ...(180) */
private static final String LOG_CONTENT_LENGTH_FORMAT = "...(%d)";
/**
* 邮件正文的日志预览:只保留前 50 个字符,超长时附上原长度。
* 例:尊敬的客户:您好!附件为与贵司确认的客户信息表,请查收...(291)。
* 短内容原样返回,null 返回 null。
* log.info("{}", dto) 走的就是本方法的返回值,因此新增日志无需再手工截断。
* 方法名刻意不带 get 前缀,避免被 fastjson / Jackson / Dubbo 识别成属性而写进 RPC 报文。
*/
@ToString.Include(name = "content")
private String contentForLog() {
if (content == null || content.length() <= LOG_CONTENT_LIMIT) {
return content;
}
return content.substring(0, LOG_CONTENT_LIMIT) + String.format(LOG_CONTENT_LENGTH_FORMAT, content.length());
}
核心机制:Lombok 的 @ToString.Include 除了能标注字段,也能标注方法 —— 标注后 toString() 里输出的就是这个方法的返回值。
效果
EmailMessageDTO(contentType=TEXT, bizType=ALERT, ..., title=预警邮件, attaches=null, configDTO=null,
content=尊敬的客户:您好!附件为与贵司沟通确认的客户信息表,请您确认信息表中...(291))
一个 291 字符的正文(末尾还带着邮箱密码和手机号),在日志里变成了「50 字符预览 + 原长度」。
为什么它比方案一更靠谱
1. 保住了可观测性 —— 这是它最主要的优势
有了预览和长度,前面那些"看不出来"的场景全都可判断:
| 现象 | 日志表现 | 判断 |
|---|---|---|
| 正文为空 | content= |
模板变量没拼上 |
| 正文长度骤降 | ...(200)(平时 ...(2000)) |
模板渲染异常 |
| 发错模板 | 开头 50 字符就能认出来 | 一眼看出 |
| 编码乱码 | 预览开头就是乱码 | 一眼看出 |
| 正文为空 vs 字段缺失 | content= vs content=null |
能区分 |
2. 安全性基本不降级
敏感数据在这类通知邮件里通常出现在中后段("您的验证码是 162346,5 分钟内有效"、"请立即修改密码"),50 字符的预览不足以泄露完整凭据。同时它也天然限定了单条日志的体量:任何正文最多输出 50 字符 + 一个长度标注。
3. 调用点零改动
和方案一一样,改的是 DTO 一处;所有已存在的日志自动生效,以后新写的日志也不会漏。
它的边界(必须说清楚)
方案二不是万无一失的。如果某个模板把敏感信息放在了正文开头 —— 例如"开通账号"类邮件的首句就是账号和初始密码 —— 那 50 个字符刚好会把它漏出去。
对这类"开头即敏感"的场景,有两个办法:
// 办法 A:把阈值调小到安全范围
private static final int LOG_CONTENT_LIMIT = 10;
// 办法 B:只输出长度,不输出内容(介于方案一和二之间的变体)
@ToString.Include(name = "content")
private String contentForLog() {
return content == null ? "null" : "(len=" + content.length() + ")";
}
// 输出:content=(len=291)
判断标准很简单:看该模板的正文前 N 个字符是什么。开头是"尊敬的客户""您好"这类模板套话 → 方案二直接用;开头就是凭据 → 调小阈值或改用办法 B。
两个可选调整
- 想让短内容也带长度(如
abc(3)):把content.length() <= LOG_CONTENT_LIMIT分支改成同样拼接长度即可。 - 按邮件类型区分阈值:把
LOG_CONTENT_LIMIT做成静态可配字段。
六、别忘了嵌套对象
处理完正文后还有一个更隐蔽的泄露点。发件人配置那个嵌套 DTO 通常也是 @Data:
@Data
public class SmtpConfigDTO implements Serializable {
private String username; // 发件邮箱账号
private String pwd; // 密码 / 授权码 ← 真实凭据
private String host;
private String protocol = "smtp";
private Integer port = 465;
}
只要外层 DTO 被打印,嵌套的 configDTO 就会跟着 toString(),pwd 明文进日志。而设置它的代码,往往就在打日志的上一行:
smtpConfig.setPwd(mailProperties.getPassword()); // ← 真实密码
emailMessage.setConfigDTO(smtpConfig);
...
log.info("发送邮件参数:dto={}", emailMessage); // ← 密码跟着出去了
同样加一个注解即可:
/**
* 密码/授权码:含敏感信息,禁止进日志(@ToString.Exclude 让 log.info("{}", dto) 不输出本字段)
*/
@ToString.Exclude
private String pwd;
这条经验值得记下来:排除敏感字段时,必须再向外问一层 ——「这个对象里还有没有别的对象」。 嵌套 DTO 是脱敏改造最常漏的地方。
七、落地时的三个注意点
1. 方法名绝不能带 get 前缀
@ToString.Include(name = "content")
private String contentForLog() { ... } // ✅
@ToString.Include(name = "content")
private String getContentForLog() { ... } // ❌ fastjson/Jackson/Dubbo 会把它当属性
带 get 前缀会被序列化框架识别成属性,RPC 报文里会平白多出一个字段,还可能影响反序列化。用 contentForLog() 这种形式。
2. name = "content" 不能省
不加的话日志里会显示成 contentForLog=...,而不是 content=...,可读性变差。
3. content 在 toString() 里的位置会变
Lombok 的生成顺序是「先所有字段,再 Include 方法」,所以 content 会从原来 title 后面挪到整行末尾。如果必须保持原位置,只能手写整个 toString()(字段一多,维护成本高,不划算)。
小结
- 邮件正文不该进日志 —— 排查价值低,泄露风险高,体量还大;同一个 DTO 里往往还藏着邮箱授权码。
- 先摸清有几条路径 ——
toString走注解,「JSON.toJSONString序列化」和「嵌套对象」两条都堵不住,需要分别处置。而且这些字段要经 RPC 传输,不能用@JSONField(serialize = false)。 - 推荐方案二 ——
@ToString.Exclude管住真实值,再用@ToString.Include标注一个返回截断预览的私有方法。安全性不打折,却把"正文是否为空、长度是否异常、模板是否发错"这些排查能力留了下来。 - 配套要做两件事 —— 嵌套 DTO 的密码字段一起排除;
JSON.toJSONString(dto)的日志统一改成直接传对象。 - 唯一要评估的边界 —— 正文开头即敏感信息的模板,需要调小阈值或改成只输出长度。
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/23032584
浙公网安备 33010602011771号