package com.dsh.privacy; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; import javax.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import java.lang.reflect.Array; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.Collection; import java.util.IdentityHashMap; import java.util.Map; /** * 手机号和身份证号等个人敏感数据的响应边界保护。 * 基于字段名进行识别,因此也可覆盖报表接口临时生成的 Map 数据。 */ @ControllerAdvice public class SensitiveDataMaskingAdvice implements ResponseBodyAdvice { private static final String INTERNAL_SENSITIVE_ACCESS = "X-Internal-Sensitive-Access"; /** 客户端接收原始数据、仅在展示时脱敏的 APP 接口。 */ private static final Set APP_UNMASKED_PATHS = new HashSet(Arrays.asList( "/base/exploreWP/storeDetail", "/base/exploreWP/customerList" )); private final HttpServletRequest request; public SensitiveDataMaskingAdvice(HttpServletRequest request) { this.request = request; } @Override public boolean supports(MethodParameter returnType, Class> converterType) { String path = request.getRequestURI(); // 内部管理调用必须携带敏感数据访问请求头。明确列出的两个 APP 接口返回原始数据, // 由客户端在展示时脱敏;其他账户服务响应均进行脱敏。 return !(APP_UNMASKED_PATHS.contains(path) || (path.startsWith("/internal/sensitive/") && "true".equalsIgnoreCase(request.getHeader(INTERNAL_SENSITIVE_ACCESS)))); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, org.springframework.http.server.ServerHttpRequest request, org.springframework.http.server.ServerHttpResponse response) { mask(body, new IdentityHashMap()); return body; } private void mask(Object value, IdentityHashMap visited) { if (value == null || value instanceof String || value.getClass().isEnum() || visited.put(value, Boolean.TRUE) != null) return; // Path 的实现类(如 sun.nio.fs.UnixPath)本身是 Iterable;遍历其子路径会不断创建新对象, // IdentityHashMap 无法识别该循环,因而会导致 StackOverflowError。 if (value instanceof java.nio.file.Path || isJdkType(value.getClass())) return; if (value instanceof Map) { for (Object entryObject : ((Map) value).entrySet()) { Map.Entry entry = (Map.Entry) entryObject; Object entryValue = entry.getValue(); if (entryValue instanceof String && sensitiveType(String.valueOf(entry.getKey())) != 0) { entry.setValue(maskText((String) entryValue, sensitiveType(String.valueOf(entry.getKey())))); } else mask(entryValue, visited); } return; } if (value instanceof Iterable) { for (Object item : (Iterable) value) mask(item, visited); return; } if (value.getClass().isArray()) { for (int i = 0; i < Array.getLength(value); i++) mask(Array.get(value, i), visited); return; } for (Class type = value.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (Field field : type.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue; try { field.setAccessible(true); Object fieldValue = field.get(value); int sensitive = sensitiveType(field.getName()); if (fieldValue instanceof String && sensitive != 0 && !field.isAnnotationPresent(UnmaskedSensitiveData.class)) { field.set(value, maskText((String) fieldValue, sensitive)); } else mask(fieldValue, visited); } catch (IllegalAccessException ignored) { } } } } private boolean isJdkType(Class type) { String name = type.getName(); return name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun."); } private int sensitiveType(String name) { String key = name == null ? "" : name.replace("_", "").replace("-", "").toLowerCase(); if (key.equals("idcard") || key.equals("identitycard") || key.equals("identitynumber") || key.equals("idnumber")) return 2; return key.equals("phone") || key.equals("mobile") || key.equals("telephone") || key.equals("tel") || key.endsWith("phone") || key.endsWith("mobile") ? 1 : 0; } private String maskText(String value, int sensitive) { if (value == null) return null; if (sensitive == 1) return value.length() <= 7 ? stars(value.length()) : value.substring(0, 3) + "****" + value.substring(7); return value.length() <= 10 ? stars(value.length()) : value.substring(0, 6) + stars(value.length() - 10) + value.substring(value.length() - 4); } private String stars(int count) { StringBuilder result = new StringBuilder(); for (int i = 0; i < count; i++) result.append('*'); return result.toString(); } }