feat(transfer): 转账收款人敏感字段加密存储(AES-256-GCM) + 展示脱敏 + 重试强制重填

- 三通道转账单(支付宝/抖音/微信)收款人账号/姓名接入 DataEncryptTypeHandler 加密存储
- 新增 PayeeDesensitizeUtil: 姓名首尾脱敏, 手机号/邮箱按收款类型脱敏, openid等伪匿名标识原样返回
- TransferAdminService/MchTransferService 查询出口(page+findById)统一脱敏
- SecureAesGcmEncryptor.decrypt 容错增强: 非密文格式(历史明文)原样透传, 新增加密字段零风险上线
- 迁移检查脚本 update-transfer-sensitive-encrypt.sql(历史明文渐进迁移用)
This commit is contained in:
daxpay
2026-08-08 11:10:25 +08:00
parent 512ae29f77
commit b712a91d9e
8 changed files with 258 additions and 21 deletions

View File

@@ -0,0 +1,64 @@
-- =====================================================================
-- 转账收款人敏感字段加密迁移检查与说明
-- =====================================================================
-- 背景: pay_transfer_order_{alipay,douyin,wechat} 三表的收款人账号/姓名字段
-- 已接入 DataEncryptTypeHandler (AES-256-GCM), 密文格式 v{version}:{base64}。
--
-- SecureAesGcmEncryptor.decrypt 已做容错: 非密文格式(历史明文)原样透传返回,
-- 不会变 null, 故上线零风险, 历史数据可正常读取。迁移仅用于"把明文也加密落库",
-- 属合规优化, 非上线阻塞项。
--
-- 迁移方式: AES-GCM 加密需随机 IV + 密钥版本前缀, 纯 SQL 无法完成,
-- 必须通过应用层(MyBatis updateById)触发 TypeHandler 加密写入。
-- 迁移完成后, 下方检查语句查询结果应均为 0。
-- =====================================================================
-- 1. 检查历史明文数量(未加密 = 不以 v 开头且非空)
-- 支付宝转账单: 收款人账号 + 收款人姓名
SELECT 'pay_transfer_order_alipay.payee_account' AS field,
count(*) AS plaintext_count
FROM pay_transfer_order_alipay
WHERE payee_account IS NOT NULL AND payee_account NOT LIKE 'v%'
UNION ALL
SELECT 'pay_transfer_order_alipay.payee_name',
count(*)
FROM pay_transfer_order_alipay
WHERE payee_name IS NOT NULL AND payee_name NOT LIKE 'v%';
-- 抖音转账单: 收款人账号 + 收款人姓名
SELECT 'pay_transfer_order_douyin.payee_account' AS field,
count(*) AS plaintext_count
FROM pay_transfer_order_douyin
WHERE payee_account IS NOT NULL AND payee_account NOT LIKE 'v%'
UNION ALL
SELECT 'pay_transfer_order_douyin.payee_name',
count(*)
FROM pay_transfer_order_douyin
WHERE payee_name IS NOT NULL AND payee_name NOT LIKE 'v%';
-- 微信转账单: 收款人 openid + 收款人姓名(userName)
SELECT 'pay_transfer_order_wechat.payee_openid' AS field,
count(*) AS plaintext_count
FROM pay_transfer_order_wechat
WHERE payee_openid IS NOT NULL AND payee_openid NOT LIKE 'v%'
UNION ALL
SELECT 'pay_transfer_order_wechat.user_name',
count(*)
FROM pay_transfer_order_wechat
WHERE user_name IS NOT NULL AND user_name NOT LIKE 'v%';
-- 2. 迁移方法(应用层一次性执行)
--
-- 启用加密(daxpay.platform.config.encrypt.enable=true + 配置 keys)后,
-- 遍历历史记录执行 updateById 即可触发 TypeHandler 加密写入:
--
-- // 伪代码示例(三表同理)
-- alipayTransferOrderManager.list().forEach(e -> {
-- e.setPayeeAccount(e.getPayeeAccount()); // 自身赋值触发 dirty
-- e.setPayeeName(e.getPayeeName());
-- alipayTransferOrderManager.updateById(e); // TypeHandler 写入密文
-- });
--
-- 可封装为 CommandLineRunner / 一次性 admin 接口, 随 profile 触发, 执行后下线。
-- 迁移完成后重新执行上方检查语句, plaintext_count 应全部为 0。

View File

@@ -24,6 +24,7 @@ import cn.daxpay.open.payment.trade.transfer.result.WechatTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferCloseService;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferStartService;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferSyncService;
import cn.daxpay.open.payment.trade.transfer.util.PayeeDesensitizeUtil;
import cn.daxpay.open.platform.common.translate.service.TransService;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.rest.param.PageParam;
@@ -56,6 +57,8 @@ public class TransferAdminService {
Page<WechatTransferOrder> page = wechatTransferOrderManager.page(pageParam, query);
PageResult<WechatTransferOrderResult> pageResult = toPageResult(page, WechatTransferOrderConvert.CONVERT::toResult);
transService.translate(pageResult);
// 收款人信息脱敏
pageResult.getRecords().forEach(PayeeDesensitizeUtil::desensitize);
return pageResult;
}
@@ -65,6 +68,8 @@ public class TransferAdminService {
.orElseThrow(() -> new DataNotExistException("pay.error.transfer.notFound"));
WechatTransferOrderResult result = WechatTransferOrderConvert.CONVERT.toResult(entity);
transService.translate(result);
// 收款人信息脱敏
PayeeDesensitizeUtil.desensitize(result);
return result;
}
@@ -75,6 +80,8 @@ public class TransferAdminService {
Page<AlipayTransferOrder> page = alipayTransferOrderManager.page(pageParam, query);
PageResult<AlipayTransferOrderResult> pageResult = toPageResult(page, AlipayTransferOrderConvert.CONVERT::toResult);
transService.translate(pageResult);
// 收款人信息脱敏
pageResult.getRecords().forEach(PayeeDesensitizeUtil::desensitize);
return pageResult;
}
@@ -84,6 +91,8 @@ public class TransferAdminService {
.orElseThrow(() -> new DataNotExistException("pay.error.transfer.notFound"));
AlipayTransferOrderResult result = AlipayTransferOrderConvert.CONVERT.toResult(entity);
transService.translate(result);
// 收款人信息脱敏
PayeeDesensitizeUtil.desensitize(result);
return result;
}
@@ -94,6 +103,8 @@ public class TransferAdminService {
Page<DouyinTransferOrder> page = douyinTransferOrderManager.page(pageParam, query);
PageResult<DouyinTransferOrderResult> pageResult = toPageResult(page, DouyinTransferOrderConvert.CONVERT::toResult);
transService.translate(pageResult);
// 收款人信息脱敏
pageResult.getRecords().forEach(PayeeDesensitizeUtil::desensitize);
return pageResult;
}
@@ -103,6 +114,8 @@ public class TransferAdminService {
.orElseThrow(() -> new DataNotExistException("pay.error.transfer.notFound"));
DouyinTransferOrderResult result = DouyinTransferOrderConvert.CONVERT.toResult(entity);
transService.translate(result);
// 收款人信息脱敏
PayeeDesensitizeUtil.desensitize(result);
return result;
}

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.payment.trade.transfer.entity;
import cn.daxpay.open.payment.common.entity.MchBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -22,7 +23,7 @@ import java.time.OffsetDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("pay_transfer_order_alipay")
@TableName(value = "pay_transfer_order_alipay", autoResultMap = true)
public class AlipayTransferOrder extends MchBaseEntity {
// ===== 身份 =====
@@ -77,10 +78,12 @@ public class AlipayTransferOrder extends MchBaseEntity {
/// @see cn.daxpay.open.payment.trade.transfer.enums.TransferPayeeTypeEnum
private String payeeType;
/// 收款人账号
/// 收款人账号(敏感字段, AES-256-GCM 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String payeeAccount;
/// 收款人姓名
/// 收款人姓名(敏感字段, AES-256-GCM 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String payeeName;
/// 转账场景标识(支付宝=转账场景配置ID, FAIL重试时恢复场景用)

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.payment.trade.transfer.entity;
import cn.daxpay.open.payment.common.entity.MchBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -22,7 +23,7 @@ import java.time.OffsetDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("pay_transfer_order_douyin")
@TableName(value = "pay_transfer_order_douyin", autoResultMap = true)
public class DouyinTransferOrder extends MchBaseEntity {
// ===== 身份 =====
@@ -73,10 +74,12 @@ public class DouyinTransferOrder extends MchBaseEntity {
/// 收款人账号类型
private String payeeType;
/// 收款人账号
/// 收款人账号(敏感字段, AES-256-GCM 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String payeeAccount;
/// 收款人姓名
/// 收款人姓名(敏感字段, AES-256-GCM 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String payeeName;
/// 转账场景ID(发起转账时由前端选择的主数据枚举, 如1001-1007)

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.payment.trade.transfer.entity;
import cn.daxpay.open.payment.common.entity.MchBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.handler.encrypt.DataEncryptTypeHandler;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -22,7 +23,7 @@ import java.time.OffsetDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("pay_transfer_order_wechat")
@TableName(value = "pay_transfer_order_wechat", autoResultMap = true)
public class WechatTransferOrder extends MchBaseEntity {
// ===== 身份 =====
@@ -70,7 +71,8 @@ public class WechatTransferOrder extends MchBaseEntity {
// ===== 微信特有 =====
/// 收款人微信 openid
/// 收款人微信 openid(敏感字段, AES-256-GCM 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String payeeOpenid;
/// 转账场景(冗余自转账配置)
@@ -82,7 +84,8 @@ public class WechatTransferOrder extends MchBaseEntity {
/// 拉起转账确认参数(微信二次确认)
private String transferBody;
/// 收款人姓名(金额档位校验用: 小于0.3元禁填, 大于等于2000元必填)
/// 收款人姓名(金额档位校验用: 小于0.3元禁填, 大于等于2000元必填; 敏感字段, AES-256-GCM 加密存储)
@TableField(typeHandler = DataEncryptTypeHandler.class)
private String userName;
// ===== 商户出站 / 审计 =====

View File

@@ -0,0 +1,133 @@
package cn.daxpay.open.payment.trade.transfer.util;
import cn.daxpay.open.payment.trade.transfer.enums.TransferPayeeTypeEnum;
import cn.daxpay.open.payment.trade.transfer.result.AlipayTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.result.DouyinTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.result.WechatTransferOrderResult;
import cn.hutool.core.util.StrUtil;
/// # 收款人信息脱敏工具
///
/// 用于转账单查询/详情返回时对收款人姓名、收款账号进行展示脱敏(底层存储已加密)。
/// 仅作用于 Result 出口, 不影响实体上送通道(通道策略读到的仍是解密后的明文)。
///
/// 脱敏策略:
/// - **姓名**: 两字保留首字, 三字及以上保留首尾, 中间用 `*` 占位
/// - **收款账号**: 按 [TransferPayeeTypeEnum] 分级
/// - 手机号(`phone` / `login_name` 为手机号): `138****5678`(前3后4)
/// - 邮箱(`login_name` 为邮箱): `z***@example.com`(首字符 + 域名)
/// - `openid` / `user_id` / `open_id`: 伪匿名标识, 原样返回(运维排查需可见)
public final class PayeeDesensitizeUtil {
private PayeeDesensitizeUtil() {
}
/// 姓名脱敏: 两字保留首字, 三字及以上保留首尾, 中间用 * 占位
public static String maskName(String name) {
if (StrUtil.isBlank(name)) {
return name;
}
int len = name.length();
if (len == 1) {
return name;
}
if (len == 2) {
// 张三 -> 张*
return name.charAt(0) + "*";
}
// 张三丰 -> 张*丰; 诸葛亮 -> 诸**亮
return name.charAt(0) + "*".repeat(len - 2) + name.charAt(len - 1);
}
/// 收款账号脱敏: 按收款人类型分级处理
///
/// @param account 收款账号明文(实体层已由 [DataEncryptTypeHandler] 解密)
/// @param payeeType 收款人账号类型, @see TransferPayeeTypeEnum
public static String maskAccount(String account, String payeeType) {
if (StrUtil.isBlank(account)) {
return account;
}
// 仅对真个人信息(手机号/邮箱)脱敏; openid/user_id/open_id 为伪匿名标识, 原样返回
if (!isSensitiveAccount(payeeType)) {
return account;
}
// login_name 可能是手机号或邮箱, 按内容自动判断
if (isEmail(account)) {
return maskEmail(account);
}
if (isMobile(account)) {
return maskMobile(account);
}
// 兜底: 保留首尾各1字符
return maskBothEnds(account);
}
/// 判断该收款人类型的账号是否需要脱敏(手机号/邮箱为个人信息, 需脱敏)
private static boolean isSensitiveAccount(String payeeType) {
return TransferPayeeTypeEnum.LOGIN_NAME.getCode().equals(payeeType)
|| TransferPayeeTypeEnum.PHONE.getCode().equals(payeeType);
}
/// 简单邮箱判断: 包含 @ 且 @ 不在首尾
private static boolean isEmail(String value) {
int at = value.indexOf('@');
return at > 0 && at < value.length() - 1;
}
/// 简单手机号判断: 11 位纯数字(国内)
private static boolean isMobile(String value) {
return value.length() == 11 && value.chars().allMatch(Character::isDigit);
}
/// 手机号脱敏: 138****5678(前3后4)
private static String maskMobile(String mobile) {
return mobile.substring(0, 3) + "****" + mobile.substring(7);
}
/// 邮箱脱敏: z***@example.com(本地名首字符 + 3星 + 域名)
private static String maskEmail(String email) {
int at = email.indexOf('@');
String local = email.substring(0, at);
String domain = email.substring(at);
if (local.length() <= 1) {
return "*" + domain;
}
return local.charAt(0) + "***" + domain;
}
/// 兜底脱敏: 保留首尾各1字符, 中间用 * 占位
private static String maskBothEnds(String value) {
if (value.length() <= 2) {
return "*".repeat(value.length());
}
return value.charAt(0) + "*".repeat(value.length() - 2) + value.charAt(value.length() - 1);
}
// ===== Result 级脱敏入口(供 Admin/Merchant Service 出口统一调用) =====
/// 支付宝转账单收款人脱敏: 姓名 + 收款账号(按类型)
public static void desensitize(AlipayTransferOrderResult result) {
if (result == null) {
return;
}
result.setPayeeName(maskName(result.getPayeeName()));
result.setPayeeAccount(maskAccount(result.getPayeeAccount(), result.getPayeeType()));
}
/// 抖音转账单收款人脱敏: 姓名 + 收款账号(按类型)
public static void desensitize(DouyinTransferOrderResult result) {
if (result == null) {
return;
}
result.setPayeeName(maskName(result.getPayeeName()));
result.setPayeeAccount(maskAccount(result.getPayeeAccount(), result.getPayeeType()));
}
/// 微信转账单收款人脱敏: 仅姓名(openid 为伪匿名标识, 不脱敏)
public static void desensitize(WechatTransferOrderResult result) {
if (result == null) {
return;
}
result.setUserName(maskName(result.getUserName()));
}
}

View File

@@ -24,6 +24,7 @@ import cn.daxpay.open.payment.trade.transfer.result.WechatTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferCloseService;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferStartService;
import cn.daxpay.open.payment.trade.transfer.runtime.service.TransferSyncService;
import cn.daxpay.open.payment.trade.transfer.util.PayeeDesensitizeUtil;
import cn.daxpay.open.platform.common.translate.service.TransService;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.rest.param.PageParam;
@@ -53,6 +54,8 @@ public class MchTransferService {
Page<WechatTransferOrder> page = wechatTransferOrderManager.page(pageParam, query);
PageResult<WechatTransferOrderResult> pageResult = toPageResult(page, WechatTransferOrderConvert.CONVERT::toResult);
transService.translate(pageResult);
// 收款人信息脱敏
pageResult.getRecords().forEach(PayeeDesensitizeUtil::desensitize);
return pageResult;
}
@@ -62,6 +65,8 @@ public class MchTransferService {
.orElseThrow(() -> new DataNotExistException("pay.error.transfer.notFound"));
WechatTransferOrderResult result = WechatTransferOrderConvert.CONVERT.toResult(entity);
transService.translate(result);
// 收款人信息脱敏
PayeeDesensitizeUtil.desensitize(result);
return result;
}
@@ -70,6 +75,8 @@ public class MchTransferService {
Page<AlipayTransferOrder> page = alipayTransferOrderManager.page(pageParam, query);
PageResult<AlipayTransferOrderResult> pageResult = toPageResult(page, AlipayTransferOrderConvert.CONVERT::toResult);
transService.translate(pageResult);
// 收款人信息脱敏
pageResult.getRecords().forEach(PayeeDesensitizeUtil::desensitize);
return pageResult;
}
@@ -79,6 +86,8 @@ public class MchTransferService {
.orElseThrow(() -> new DataNotExistException("pay.error.transfer.notFound"));
AlipayTransferOrderResult result = AlipayTransferOrderConvert.CONVERT.toResult(entity);
transService.translate(result);
// 收款人信息脱敏
PayeeDesensitizeUtil.desensitize(result);
return result;
}
@@ -87,6 +96,8 @@ public class MchTransferService {
Page<DouyinTransferOrder> page = douyinTransferOrderManager.page(pageParam, query);
PageResult<DouyinTransferOrderResult> pageResult = toPageResult(page, DouyinTransferOrderConvert.CONVERT::toResult);
transService.translate(pageResult);
// 收款人信息脱敏
pageResult.getRecords().forEach(PayeeDesensitizeUtil::desensitize);
return pageResult;
}
@@ -96,6 +107,8 @@ public class MchTransferService {
.orElseThrow(() -> new DataNotExistException("pay.error.transfer.notFound"));
DouyinTransferOrderResult result = DouyinTransferOrderConvert.CONVERT.toResult(entity);
transService.translate(result);
// 收款人信息脱敏
PayeeDesensitizeUtil.desensitize(result);
return result;
}

View File

@@ -134,8 +134,13 @@ public class SecureAesGcmEncryptor {
}
/// 解密(根据版本前缀选择密钥)
/// @param ciphertext 密文格式v{version}:{encrypted}
/// @return 明文,如果找不到对应密钥则返回null
///
/// 容错策略: 遇到非密文格式(如历史明文)或解密失败时, 原样返回输入值而非 null,
/// 保证新增加密字段的上线零风险(历史明文自动透传, 新数据正常加密), 支持渐进式迁移。
/// 对已加密字段无影响(其 DB 值均为 v 前缀密文, 走正常解密路径)。
///
/// @param ciphertext 密文格式v{version}:{encrypted};或历史明文(原样返回)
/// @return 明文;若无法解密则原样返回输入值
public String decrypt(String ciphertext) {
if (ciphertext == null) {
return null;
@@ -143,22 +148,22 @@ public class SecureAesGcmEncryptor {
try {
// 解析版本前缀
if (!ciphertext.startsWith(VERSION_PREFIX)) {
log.warn("密文格式错误,缺少版本前缀");
return null;
// 非密文格式(历史明文或加密未启用时的明文), 原样透传
return ciphertext;
}
int separatorIndex = ciphertext.indexOf(VERSION_SEPARATOR);
if (separatorIndex == -1) {
log.warn("密文格式错误,缺少版本分隔符");
return null;
log.warn("密文格式错误,缺少版本分隔符, 原样返回");
return ciphertext;
}
int version;
try {
version = Integer.parseInt(ciphertext.substring(1, separatorIndex));
} catch (NumberFormatException e) {
log.warn("密文版本号格式错误");
return null;
log.warn("密文版本号格式错误, 原样返回");
return ciphertext;
}
String encryptedBase64 = ciphertext.substring(separatorIndex + 1);
@@ -166,8 +171,8 @@ public class SecureAesGcmEncryptor {
// 获取对应版本的密钥
SecretKey secretKey = secretKeyCache.get(version);
if (secretKey == null) {
log.warn("找不到版本 v{} 对应的密钥", version);
return null;
log.warn("找不到版本 v{} 对应的密钥, 原样返回", version);
return ciphertext;
}
// 解密
@@ -185,8 +190,8 @@ public class SecureAesGcmEncryptor {
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("解密失败", e);
return null;
log.error("解密失败, 原样返回", e);
return ciphertext;
}
}