buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

邮件正文不该进log日志:通过【lombok注解语法糖】实现从 整块屏蔽 到 安全预览

一次邮件 DTO 日志脱敏的取舍 —— 三条泄露路径、两种方案,以及为什么"少打印一点"比"完全不打印"更可取

背景:发邮件是一个独立的中间服务

在这套体系里,邮件发送不是某个业务系统的内部功能,而是一个独立的中间服务。业务系统只负责组装邮件参数,再通过 RPC 调用这个服务去完成投递:

业务系统 A ─┐
业务系统 B ─┼──▶   邮件服务(独立中间服务)   ──▶   SMTP   ──▶   收件人
业务系统 C ─┘

这个架构决定了三件事,也是后面所有取舍的前提:

  1. 承载邮件参数的 DTO 是跨系统的传输对象。 为了同时满足不同业务方的诉求,它必须自洽地带齐全套字段 —— 业务类型、业务线、发件邮箱标识、收件人、抄送、标题、正文、附件、自定义发件人配置。业务系统把参数填完就丢过来,邮件服务不再回头找业务方要数据。
  2. 打印它的地方天然分散。 每个业务系统都会在"组装完参数、准备调用"的位置顺手打一条日志,日志点因此遍布各个业务方,而不集中在邮件服务内部。想靠"挨个改调用点"来整改,既不现实,也一定会漏。
  3. 正文和发件人配置必须能序列化传输。 这条约束会在第三章直接排掉一个看起来很自然的写法(@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只加注解,只能覆盖一半。

一个必须提前确认的约束

contentconfigDTO 都是要经 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))

contenttoString() 输出里彻底消失。字段本身完好,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. contenttoString() 里的位置会变

Lombok 的生成顺序是「先所有字段,再 Include 方法」,所以 content 会从原来 title 后面挪到整行末尾。如果必须保持原位置,只能手写整个 toString()(字段一多,维护成本高,不划算)。


小结

  1. 邮件正文不该进日志 —— 排查价值低,泄露风险高,体量还大;同一个 DTO 里往往还藏着邮箱授权码。
  2. 先摸清有几条路径 —— toString 走注解,「JSON.toJSONString 序列化」和「嵌套对象」两条都堵不住,需要分别处置。而且这些字段要经 RPC 传输,不能用 @JSONField(serialize = false)
  3. 推荐方案二 —— @ToString.Exclude 管住真实值,再用 @ToString.Include 标注一个返回截断预览的私有方法。安全性不打折,却把"正文是否为空、长度是否异常、模板是否发错"这些排查能力留了下来。
  4. 配套要做两件事 —— 嵌套 DTO 的密码字段一起排除;JSON.toJSONString(dto) 的日志统一改成直接传对象。
  5. 唯一要评估的边界 —— 正文开头即敏感信息的模板,需要调小阈值或改成只输出长度。

posted on 2026-09-18 22:48  buguge  阅读(21)  评论(0)    收藏  举报