
1. 银行卡号这类金融敏感字段到底需要做几层防护先说个实际场景。某支付项目做核心交易链路改造联调阶段测试人员在后台日志里随手搜了一下交易流水号结果打印出来的请求报文里用户银行卡号bankCardNo是完整的 16 位明文。当时在场的人都愣了一下紧接着就是合规同学的一通电话“这个卡号必须给我处理掉。”这样的问题在金融、电商、跨境支付领域非常普遍。银行卡号属于个人敏感信息一旦出现在日志、数据库备份、监控截图里就是合规事故。通常我们说的“处理”其实包含两个完全不同的动作加密和脱敏。这两个词经常被混用但在工程实现上逻辑不同、目的不同、落地位置也不同。这篇文章要聊的encryption-local就是一套专门解决“本地化敏感字段加解密 脱敏”的轻量方案我结合自己的落地经验和源码阅读心得把这一整套东西掰开揉碎讲清楚。这套方案适合谁大部分使用 Java 技术栈、基于 Spring Boot 构建业务系统的团队尤其是那些还没有引入专门加密中间件、又在合规整改压力下的项目。无论你是要处理数据库里的卡号存储还是要把日志里的卡号打码或者想在接口返回值里自动脱敏这套思路都能直接落地。先往下看我会把设计思路、核心用法、集成方案和源码链路一条一条拆开。1.1 先理清概念加密、解密、脱敏各管什么很多人一开始会问我把卡号加密了是不是就相当于脱敏了不是一回事。加密是把明文变成密文目的是防止数据在存储或传输过程中被未授权的人读取。加密之后的数据可以还原也就是解密业务上需要用到完整卡号的时候比如发起代扣、做银行卡鉴权必须能恢复成明文。脱敏是把敏感信息打码或截断比如6222020200112233445变成622202 **** **** 3445。脱敏后的数据不可逆而且脱敏只影响展示和输出不影响存储和内部处理。日志里当然要脱敏但数据库里如果只存脱敏后的卡号那业务就没法干了。所以业务系统里合理的设计一般是两层存储层数据库里存密文读取后按需解密。展示/输出层日志、接口响应、监控页面里的卡号一律脱敏。encryption-local这类的本地方案就是把这两件事合并到一个组件里通过注解驱动尽量让业务代码无感。后面讲源码的时候我会说明它是怎么把这两条链路串起来的。1.2 三个必须处理的场景存储、传输、展示具体到一个银行卡号的生命周期你必须盯住三个位置。第一个是数据库落盘。很多团队只把卡号加密存在某个字段里但关联的日志表、操作记录表、异步消息表里全是明文。要统一处理就需要统一拦截而不是靠开发自觉。第二个是接口调用链。服务 A 调服务 B中间经过网关、消息队列报文里如果没有脱敏任何一个环节抓包都能看到完整卡号。通常的做法是在出站前做脱敏在真正需要明文的下游再做解密必须做到“最小暴露”。第三个是日志打印和前端展示。这部分最容易被忽略也是合规检查中被逮住最多的位置。后端打印请求参数、异常堆栈里带了实体对象toString()一调卡号全出来了。这种问题靠代码 review 很难根治必须靠框架层的自动脱敏。encryption-local这类组件解决的就是让开发者在字段上标注一下剩下的加密存储、解密读取、日志打码都由组件自动化完成。1.3 为什么选择本地方案而不是集中式加密服务我跟不少人聊过有人觉得金融敏感信息加密应该搞一套独立的加密服务所有加解密请求都走远程调用。这个思路在大型体系里没毛病但对于大多数中小规模业务系统本地加密方案反而更合适。首先是性能问题。每笔交易里涉及卡号的操作可能很多次每次都走网络调用加密服务光网络开销就是几十毫秒到上百毫秒在高并发交易链路上这个代价太大。本地加密是纯内存计算一次 AES 加解密在微秒级。其次是可用性问题。集中式加密服务一旦不可用全链路业务都会被卡住。本地方案不依赖外部服务只要密钥配置正确应用自己就能完成加解密。第三是部署复杂度。独立加密服务需要单独维护、配置路由、做高可用对于一个 20 人以内的业务团队来说负担不小。用一个本地组件几百 KB 的 jar 包就能塞进项目里。当然本地方案不是没有短板最大的短板就是密钥管理。密钥不能写死在代码里也不能放在配置中心明文裸奔这个我在后面的问题排查部分会单独讲。总之先选型再动手别一上来就写一堆AESUtil工具类。2. encryption-local 快速上手注解驱动的加解密与脱敏使用encryption-local的第一步是先理解它的核心思想通过注解声明敏感字段由组件在合适的时机自动处理。这种做法的好处是业务代码里几乎不需要写显式的加解密调用原来怎么赋值、怎么 get现在还是怎么赋值、怎么 get。开发者只需要关心哪个字段是敏感的剩下的脏活累活组件全包了。2.1 依赖引入与基础配置以 Maven 项目为例引入依赖非常简单dependency groupIdcom.example.security/groupId artifactIdencryption-local/artifactId version1.2.0/version /dependency然后在application.yml里做基础配置encryption: local: enabled: true # 对称加密算法AES/GCM/NoPadding 或 SM4/GCM algorithm: AES/GCM/NoPadding # 密钥生产环境不要写在这里建议用环境变量或配置中心密文 secret-key: ${BANK_CARD_SECRET_KEY} # 加密字段的前缀出现该前缀的字段会被识别为密文 cipher-prefix: enc: # 是否开启日志脱敏 log-desensitize: true这里有一个容易被坑的点secret-key不要在配置文件里写死明文。开发环境图省事写在 yml 里能跑到了生产环境就必须换成环境变量或者配置中心的密钥引用。AES 的密钥长度不同算法要求不一样128 位对应 16 字节256 位对应 32 字节配错了启动时会直接报错。cipher-prefix这个配置比较巧妙。加密后的密文会带上一个前缀比如enc:U2FsdGVkX1/...这样组件在读取数据时能快速判断一个字段到底是明文还是密文。老数据没有前缀就当成明文处理这就为后续的平滑迁移留了口子。2.2 最简用法给字段加上注解假设有一个用户的银行卡绑定实体public class BankCardDO { private Long id; EncryptField private String bankCardNo; private String userId; private Integer status; }注意EncryptField标在bankCardNo字段上。组件会在写入数据库之前把明文卡号加密成密文在从数据库读出之后自动解密成明文。业务代码不需要做任何额外操作BankCardDO card new BankCardDO(); card.setBankCardNo(6222020200112233445); cardMapper.insert(card); // 数据库中实际存储的是 // enc:7d3f0a5e8c9b1e4f6a2c...查询时呢如果我在查询接口中希望拿到明文直接查就行组件自动解密。但如果希望控制某些场景不要解密比如批量导出时只为了统计不展示明文就可以用DecryptField(disable true)或者配合上下文标记来跳过解密。一般默认行为是自动解密特殊情况特殊处理。2.3 脱敏注解与内置的银行卡号策略再来看脱敏。如果没有脱敏需求直接用加密就够了但正如前面所说日志和接口返回里不能出现明文。于是有一个单独的注解Data public class BankCardVO { private Long id; /** * 银行卡号展示时自动脱敏 */ Desensitize(type DesensitizeType.BANK_CARD) private String bankCardNo; private String bankName; }DesensitizeType.BANK_CARD是内置的银行卡号脱敏策略默认保留前 6 位和后 4 位中间用星号代替6222020200112233445 - 622202******3445针对不同字段还内置了几种常见策略策略类型适用字段示例BANK_CARD银行卡号622202******3445MOBILE手机号138****8000ID_CARD身份证号110101********1234NAME姓名张*EMAIL邮箱zh***example.com如果内置策略不满足需求可以自己实现策略接口注册进去就行。这一点我会在源码剖析里展开这里先记住结论脱敏策略是可扩展的。特别提醒一句脱敏不是加密脱敏后的数据不可逆。不要试图把脱敏后的卡号拿去解密也不要拿脱敏结果去更新数据库这两个动作在业务逻辑上必须严格隔离。3. 把脱敏能力打进日志和接口返回集成方案注解标好之后下一步就是让脱敏真正在项目中生效。很多开发者遇到的实际问题是注解加上了但日志里打印的还是完整卡号。这种情况通常是组件没有跟日志框架、序列化框架真正打通。下面三个集成场景是我在实际项目中实测过路的按优先级排列。3.1 JSON 序列化时自动脱敏现在绝大多数接口返回都是 JSON以 Spring Boot 为例默认使用 Jackson。要把Desensitize注解能力注入 Jackson 序列化器组件里一般会提供一个SensitiveJsonSerializer或者通过Jackson2ObjectMapperBuilderCustomizer自动注册。配置好之后效果是这样的RestController public class CardController { GetMapping(/card/{id}) public ResultBankCardVO getCard(PathVariable Long id) { BankCardVO vo cardService.getById(id); return Result.ok(vo); } }接口返回{ code: 0, data: { id: 1001, bankCardNo: 622202******3445, bankName: 某银行 } }这个能力之所以重要是因为大多数项目里返回实体和数据库实体没做严格分层如果每次都在 Service 层手动调用脱敏工具类代码会变得非常啰嗦且容易遗漏。通过注解 序列化器统一处理新接手的开发只需要看一眼注解就知道哪个字段敏感。需要注意如果项目里同时用了 Fastjson 或者 Gson需要分别配置对应的序列化扩展。实际项目里我最建议只保留一种 JSON 框架混用会导致注解只在一处生效另一处还是明文输出。3.2 日志框架的敏感字段过滤日志里的明文卡号几乎都是从toString()或者参数打印中出来的。最常见的写法log.info(query card request: {}, cardVO);如果BankCardVO的toString()直接拼接了bankCardNo卡号就完整打出去了。这里有两个层面的解决办法。第一层实体类重写toString()时对敏感字段脱敏。这个做法简单直观但每个实体都要手工处理容易漏。第二层利用encryption-local提供的日志组件。比如 Logback 里可以配置一个SensitiveDataConverterconversionRule conversionWordmsg converterClasscom.example.security.log.SensitiveDataConverter /转换器会自动扫描日志消息中符合银行卡号格式的字符串并打码。这样即使某个老代码没有加注解只要日志内容里出现 16~19 位纯数字的卡号片段也会被自动处理。实测下来这个方案非常有效尤其适合历史代码多、来不及逐个改实体的系统。日志脱敏的一个原则是“宁可多打码不可漏一个”。有些团队把卡号中间 10 位全部打码只留后 4 位也有的保守到连前 6 位都不放只留后 4。安全性和易用性之间需要平衡我个人的做法是支付类场景保前 6 后 4鉴权类场景只保后 4具体根据合规要求来定。3.3 数据库字段加解密落地MyBatis 拦截器方案数据库字段加解密重点在写库和读库的拦截。以 MyBatis 举例组件通常提供EncryptInterceptor核心逻辑是执行insert/update时把带EncryptField的字段从明文加密成密文。执行select时把密文解密成明文。这样 Mapper 接口和 XML 里的 SQL 都不用改insert idinsert INSERT INTO bank_card (id, bank_card_no) VALUES (#{id}, #{bankCardNo}) /insert传入的bankCardNo是明文拦截器在 PreparedStatement 参数设置前完成加密。查询结果映射 POJO 时同样自动解密。这里要特别注意一个细节千万不要在 XML 里写函数对字段做加密也不要自己拼 SQL 字符串。encryption-local的职责边界是应用层的字段处理SQL 里做函数处理会让索引失效也不方便统一管理密钥。MyBatis 拦截器的顺序也很关键。EncryptInterceptor的Intercepts注解要声明拦截ParameterHandler和ResultSetHandler而且要确保它在其他参数处理插件之前执行否则可能拿到已经被处理过的数据导致密文不对。4. 源码浅析加密、解密、脱敏这三条链路怎么串起来的从使用角度看注解加得挺轻松但背后的源码设计值得深入了解。面向面试、面向自研组件扩展这部分很有参考价值。我读的这个版本源码结构很清晰核心包大致如下com.example.security.local ├── annotation │ ├── EncryptField.java │ ├── DecryptField.java │ └── Desensitize.java ├── strategy │ ├── DesensitizeStrategy.java │ ├── BankCardDesensitizeStrategy.java │ ├── MobileDesensitizeStrategy.java │ └── ... ├── interceptor │ ├── EncryptInterceptor.java │ ├── DecryptInterceptor.java │ └── SensitiveLogInterceptor.java ├── core │ ├── CryptoService.java │ ├── DefaultCryptoServiceImpl.java │ └── KeyLoader.java └── config └── EncryptionLocalAutoConfiguration.java核心类不多胜在职责清晰。下面选三个关键点展开。4.1 核心注解与元数据扫描EncryptField和Desensitize都是运行时注解Retention(RetentionPolicy.RUNTIME)。组件启动时会通过ClassPathScanningCandidateComponentProvider扫描项目中标注了注解的字段缓存到元数据仓库里。这样后续拦截时不需要每次都走反射性能损耗被控制到了最小。Target({ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) public interface EncryptField { /** * 加密时是否忽略空字符串 */ boolean ignoreEmpty() default true; /** * 注解字段对应的加密策略名 */ String strategy() default default; }Desensitize则多一个type()属性用来指定脱敏策略Target({ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) public interface Desensitize { DesensitizeType type(); }这里的元数据扫描发生在 Spring 容器初始化阶段缓存结果是ConcurrentHashMapClass?, ListSensitiveFieldMetadata。所以如果你用动态代理或者 CGLIB 生成了新类要确保注解信息能传递过去否则扫描不到就只能退化成手动处理了。4.2 加解密拦截器的工作时序这一节重点。以一个 MyBatis 插入操作为例完整时序大概是这样的SqlSession调用insert方法。EncryptInterceptor拦截到ParameterHandler的setParameters方法。组件遍历参数对象里的字段元数据发现bankCardNo标了EncryptField。调用DefaultCryptoServiceImpl.encrypt()得到带前缀的密文。把密文替换掉原字段值再交给 JDBC 执行。查询解密的过程相反从ResultSetHandler拿到结果对象后检测到EncryptField字段的值带enc:前缀就执行解密把明文值 set 回去。public class DefaultCryptoServiceImpl implements CryptoService { Override public String encrypt(String plainText) { if (StringUtils.isEmpty(plainText)) { return plainText; } byte[] cipherBytes doEncrypt(plainText.getBytes(StandardCharsets.UTF_8)); return cipherPrefix Base64.getEncoder().encodeToString(cipherBytes); } Override public String decrypt(String cipherText) { if (StringUtils.isEmpty(cipherText) || !cipherText.startsWith(cipherPrefix)) { return cipherText; } // 去掉前缀Base64 解码解密 } }注意这里有一个设计细节decrypt()遇到不带头前缀的字符串时直接原样返回。这个设计让老数据无须迁移也能被兼容新老共存期非常有用。但也带来一个风险如果一条明文卡号本身碰巧没有前缀但落库没加密查询时就不会报错容易掩盖存储层的加密遗漏。因此我建议接入组件之后最好做一次存量数据加密巡检不能只靠运行期兜底。4.3 脱敏策略如何做到可扩展脱敏这一块的源码设计是典型的策略模式。顶层接口public interface DesensitizeStrategy { /** * 是否支持该脱敏类型 */ boolean support(DesensitizeType type); /** * 执行脱敏 */ String desensitize(String source); }内置的BankCardDesensitizeStrategy核心逻辑public class BankCardDesensitizeStrategy implements DesensitizeStrategy { Override public boolean support(DesensitizeType type) { return type DesensitizeType.BANK_CARD; } Override public String desensitize(String source) { if (source null || source.length() 8) { return source; } int prefixLength 6; int suffixLength 4; return source.substring(0, prefixLength) ****** source.substring(source.length() - suffixLength); } }源码的逻辑不复杂但扩展性很强。如果你需要自定义一个脱敏类型比如对商户号的脱敏只要实现DesensitizeStrategy并注册为 Spring Bean组件在序列化时会自动检测到新的策略。这就是依赖接口而非依赖具体实现的好处也是我推荐值得借鉴的工程写法。值得一提的还有SensitiveLogInterceptor的实现思路。它没有在注解维度做拦截而是在日志输出层监听。Logback 的Converter拿到日志消息后用正则匹配卡号格式片段再打码。这里正则表达式需要平衡误伤和漏网我的经验是宁可误伤一些交易流水号也不要放过卡号。你可以通过配置白名单前缀来减少误伤但正常运行情况下流水号被打码一般不影响排查。5. 实战中的常见问题与排查技巧最后这部分是跟项目组一起踩坑踩出来的。大部分问题不是组件本身的 Bug而是使用姿势、集成方式不对导致的。整理成速查表大家可以对照检查。5.1 加密后的字段无法模糊查询怎么办这是所有字段加密方案都绕不开的问题。数据库里存的是密文而 AES 等对称加密算法的密文不具备可检索性你无法用LIKE %622202%去查某一个卡号段。业界的解法有好几种实测下来比较靠谱的有精确查询直接用完整卡号加密后做等值匹配。因为加密算法是确定的同一明文每次密文一致所以等值查询完全可行。模糊查询针对手机号、卡号这类定长字段可以在业务表中增加一个脱敏查询列比如bank_card_no_mask存储固定的脱敏值然后用这个列做模糊检索。密文索引对密文做哈希索引但只适合等值场景。千万不要图省事把明文存到一个查询专用字段里那相当于给数据开了个大后门。我见过一个项目为了支持模糊查询在表里加了一个bank_card_no_plain字段结果被合规审计直接打回理由是“明文冗余存储”。5.2 密钥管理从配置文件到 KMS本地加解密方案最大的风险点就在密钥上。密钥如果泄露整个方案形同虚设。我的分级做法是环境密钥存储方式说明开发环境配置文件 注释提醒方便调试但不准出现真实卡号测试环境环境变量避免密钥进入 Git 仓库生产环境配置中心密文 / KMS 服务启动时拉取密钥内存中持有如果公司已经有密钥管理平台encryption-local的KeyLoader支持自定义密钥加载逻辑。此时只需要实现一个SecretKeyProvider接口从 KMS 拉取密钥并缓存在内存中组件本身不需要感知密钥来自哪里。这里有一个严肃提醒禁止把密钥打印到日志中禁止把密钥硬编码在代码里。排查问题时写日志很容易顺手把配置对象打出来如果里面有secretKey字段等于把家底全漏了。我习惯在配置类上标注JsonIgnore/ToString.Exclude防止误打。5.3 脱敏边界前6后4就一定安全吗很多人默认银行卡号保留前 6 位后 4 位就是安全的。前 6 位是发卡行标识BIN后面 4 位是个人账号的一部分中间漏掉的部分加上前 6 位虽然不能还原全卡号但仍然可以被撞库或者社会工程利用。更严格的做法是整个卡号只保留后 4 位。另外要注意脱敏策略对短卡号和异常格式要特别处理。比如只有 10 位的卡号或者中间带了空格、横线的卡号直接按前后截取可能暴露过多信息。源码里BankCardDesensitizeStrategy做了一个基本判断长度小于 8 时原样返回这其实是安全性和易用性的妥协。我的建议是如果业务上允许对这些异常数据直接不展示或者统一打全码不要返回原始值。5.4 性能与缓存加解密要快还要稳AES-GCM 单次加密在主流服务器上大概几十微秒这个开销在业务链路里可以接受。真正影响性能的场景是频繁解密同一个卡号比如一次批处理任务要处理成千上万条记录每次都重新解密累积起来 CPU 开销就不小了。解决手段是加缓存。组件内部可以配置一个可选的CacheKey, StringcacheKey用密文或者密文哈希cacheValue存解密后的明文。需要注意缓存生命周期要跟业务会话绑定别把明文卡号长时间留存在全局缓存里否则内存里全是敏感信息遇到 dump 内存分析一样会出事。我实际测过开启本地缓存后重复解密同一密文的性能损耗下降了一个数量级。但对于高并发交易系统我不建议你做全局无界缓存应该用带过期时间的 Caffeine 或者直接不缓存把内存安全放在第一位。还有一个容易被忽略的性能坑拦截器反射遍历字段。如果一张表的实体类字段非常多每次读写都反射全字段性能损耗不小。encryption-local通过启动时元数据缓存已经规避了大部分反射开销但如果你自己扩展了拦截器建议也遵循同样的思路避免运行期反复getDeclaredFields()。5.5 常见问题速查表问题现象可能原因解决方案数据库里存的是明文插不进去加密值拦截器没有生效检查EncryptField是否加到字段上检查 MyBatis 插件注册顺序日志里打印了完整卡号使用了未标注注解的 DTO/Entity配置日志脱敏转换器兜底重写 toString解密时抛出 AEADBadTagException密钥不一致或密文被篡改核对各环境密钥确认加密算法参数一致接口返回里脱敏没生效JSON 序列化器没有注册检查是否引入了多个 JSON 框架确认自定义 Serializer 是否被覆盖老数据查询时解密后变成乱码历史明文被当成密文处理确认cipher-prefix配置正确解密遇到无前缀值应原样返回自定义脱敏策略不生效未注册为 Spring Bean实现DesensitizeStrategy接口并确保被 Spring 扫描到最后一个建议别只把目光放在代码上从技术实现来看encryption-local本身不复杂复杂的是把它放入一个真实业务系统后围绕密钥、日志、存储、查询衍生的各种边界问题。我个人在实际落地中的体会是先梳理敏感字段清单再谈技术选型先定好脱敏规范再写注解。银行卡号只是其中一个最典型的例子身份证号、手机号、邮箱、地址、甚至经纬度坐标都可以用同一套注解框架管理起来。另外一个小技巧接入之后建议在测试环境做一个“敏感信息扫描任务”定期把数据库和日志采样扫描一遍凡是出现 16~19 位连续数字且匹配银行卡 Luhn 校验的字符串全部告警出来。这样即使开发过程中有人忘了加注解也能在测试阶段发现问题而不是等到合规检查时被动整改。加密脱敏这件事技术上做到位只是第一步让整个团队形成习惯才是长久之计。