AuditLogAspect.java 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. package com.etotem.cfc.aspect;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.etotem.cfc.entity.AuditLog;
  4. import com.etotem.cfc.entity.CfEarnRule;
  5. import com.etotem.cfc.entity.CfRateTier;
  6. import com.etotem.cfc.entity.ServiceConfigItem;
  7. import com.etotem.cfc.entity.User;
  8. import com.etotem.cfc.mapper.CfEarnRuleMapper;
  9. import com.etotem.cfc.mapper.CfRateTierMapper;
  10. import com.etotem.cfc.mapper.ServiceConfigItemMapper;
  11. import com.etotem.cfc.mapper.UserMapper;
  12. import com.etotem.cfc.service.AuditLogService;
  13. import com.fasterxml.jackson.core.type.TypeReference;
  14. import com.fasterxml.jackson.databind.ObjectMapper;
  15. import org.aspectj.lang.ProceedingJoinPoint;
  16. import org.aspectj.lang.annotation.Around;
  17. import org.aspectj.lang.annotation.Aspect;
  18. import org.aspectj.lang.annotation.Pointcut;
  19. import org.slf4j.Logger;
  20. import org.slf4j.LoggerFactory;
  21. import org.springframework.stereotype.Component;
  22. import org.springframework.web.context.request.RequestContextHolder;
  23. import org.springframework.web.context.request.ServletRequestAttributes;
  24. import javax.annotation.Resource;
  25. import javax.servlet.http.HttpServletRequest;
  26. import java.util.Arrays;
  27. import java.util.Date;
  28. import java.util.HashSet;
  29. import java.util.LinkedHashMap;
  30. import java.util.Map;
  31. import java.util.Set;
  32. /**
  33. * 管理端写操作统一审计切面(W0.3 审计日志统一)。
  34. *
  35. * 拦截范围:/api/admin/** 下 CF 相关管理端控制器({@code AdminCf*} + {@code AdminServiceConfigController})
  36. * 的写语义方法(save / update / toggle / delete / adjust 等),自动落 {@code audit_log} 并记录改前/改后值;
  37. * 审计落库成功后由 {@code AuditLogService} 触发 {@code CfConfigCache.invalidate()}。
  38. *
  39. * 约定:
  40. * - 读语义方法(list / history / balance / logs / invalidate 等)不拦截,不产生审计行;
  41. * - 业务方法抛异常时不落审计(避免为失败的写操作留痕),审计自身异常仅 warn,绝不阻塞业务请求;
  42. * - 新增 CF 管理端控制器时,类名以 {@code AdminCf} 开头即自动纳入拦截,无需修改本切面。
  43. *
  44. * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
  45. */
  46. @Aspect
  47. @Component
  48. public class AuditLogAspect {
  49. private static final Logger log = LoggerFactory.getLogger(AuditLogAspect.class);
  50. static final String MODULE_CONFIG = "config";
  51. static final String MODULE_EARN_RULE = "earn_rule";
  52. static final String MODULE_TIER = "tier";
  53. static final String MODULE_BALANCE = "balance";
  54. private static final int MAX_SNAPSHOT_CHARS = 16000;
  55. private static final TypeReference<Map<String, Object>> MAP_TYPE =
  56. new TypeReference<Map<String, Object>>() {
  57. };
  58. /** 写语义动作白名单(读语义一律不审计) */
  59. private static final Set<String> WRITE_ACTIONS = new HashSet<>(Arrays.asList(
  60. "save", "update", "toggle", "delete", "adjust",
  61. "upsert", "enable", "disable", "create", "edit", "remove"));
  62. @Resource
  63. private AuditLogService auditLogService;
  64. @Resource
  65. private UserMapper userMapper;
  66. @Resource
  67. private ServiceConfigItemMapper serviceConfigItemMapper;
  68. @Resource
  69. private CfEarnRuleMapper cfEarnRuleMapper;
  70. @Resource
  71. private CfRateTierMapper cfRateTierMapper;
  72. @Resource
  73. private ObjectMapper objectMapper;
  74. /** CF 相关管理端控制器(AdminCf* + 服务配置中心) */
  75. @Pointcut("within(com.etotem.cfc.controller.admin.AdminCf*) "
  76. + "|| within(com.etotem.cfc.controller.admin.AdminServiceConfigController)")
  77. public void cfAdminController() {
  78. }
  79. @Around("cfAdminController()")
  80. public Object aroundAdminWrite(ProceedingJoinPoint pjp) throws Throwable {
  81. String module = null;
  82. String action = null;
  83. String targetId = null;
  84. String beforeJson = null;
  85. try {
  86. HttpServletRequest request = currentRequest();
  87. if (request != null) {
  88. module = resolveModule(request.getRequestURI());
  89. if (module != null) {
  90. action = resolveAction(pjp.getSignature().getName());
  91. if (action != null) {
  92. targetId = resolveTargetId(module, pjp.getArgs());
  93. beforeJson = snapshot(module, targetId);
  94. }
  95. }
  96. }
  97. } catch (Exception e) {
  98. log.warn("审计切面解析失败,跳过审计:{}", e.getMessage());
  99. }
  100. // 业务方法:审计不改变其返回值/异常语义
  101. Object result = pjp.proceed();
  102. if (module != null && action != null) {
  103. try {
  104. HttpServletRequest request = currentRequest();
  105. String afterJson = snapshot(module, targetId);
  106. if (afterJson == null) {
  107. afterJson = toJson(payload(pjp.getArgs()));
  108. }
  109. persist(request, module, action, targetId, beforeJson, afterJson);
  110. } catch (Exception e) {
  111. log.warn("审计落库异常(不影响业务):{}", e.getMessage());
  112. }
  113. }
  114. return result;
  115. }
  116. /** 按请求 URI 判定模块:config / earn_rule / tier / balance */
  117. private String resolveModule(String uri) {
  118. if (uri == null) {
  119. return null;
  120. }
  121. String path = uri.toLowerCase();
  122. if (path.contains("/service-config")) {
  123. return MODULE_CONFIG;
  124. }
  125. if (path.contains("/cf-earn-rule")) {
  126. return MODULE_EARN_RULE;
  127. }
  128. if (path.contains("/tier")) {
  129. return MODULE_TIER;
  130. }
  131. if (path.contains("/cf-balance") || path.contains("/cf-value")) {
  132. return MODULE_BALANCE;
  133. }
  134. return null;
  135. }
  136. private String resolveAction(String methodName) {
  137. if (methodName == null || methodName.isEmpty()) {
  138. return null;
  139. }
  140. return WRITE_ACTIONS.contains(methodName) ? methodName : null;
  141. }
  142. private String resolveTargetId(String module, Object[] args) {
  143. switch (module) {
  144. case MODULE_CONFIG:
  145. return firstValue(args, "configKey", "key");
  146. case MODULE_EARN_RULE:
  147. return firstValue(args, "code", "ruleCode");
  148. case MODULE_TIER:
  149. return firstValue(args, "id", "tierId");
  150. case MODULE_BALANCE:
  151. return firstValue(args, "memberId", "userId", "id");
  152. default:
  153. return null;
  154. }
  155. }
  156. private String targetTypeOf(String module) {
  157. switch (module) {
  158. case MODULE_CONFIG:
  159. return "service_config";
  160. case MODULE_EARN_RULE:
  161. return "cf_earn_rule";
  162. case MODULE_TIER:
  163. return "cf_rate_tier";
  164. case MODULE_BALANCE:
  165. return "cf_balance";
  166. default:
  167. return null;
  168. }
  169. }
  170. /**
  171. * 目标对象快照(DB 实时读取,新建为 null,删除后为 null)。
  172. * balance 模块暂无实体快照入口(F-2 接入 CfBalanceService 后补),回退为请求参数快照。
  173. */
  174. private String snapshot(String module, String targetId) {
  175. try {
  176. switch (module) {
  177. case MODULE_CONFIG:
  178. if (targetId == null) {
  179. return null;
  180. }
  181. ServiceConfigItem item = serviceConfigItemMapper.selectOne(
  182. new LambdaQueryWrapper<ServiceConfigItem>()
  183. .eq(ServiceConfigItem::getConfigKey, targetId)
  184. .last("LIMIT 1"));
  185. return toJson(item);
  186. case MODULE_EARN_RULE:
  187. if (targetId == null) {
  188. return null;
  189. }
  190. CfEarnRule rule = cfEarnRuleMapper.selectOne(
  191. new LambdaQueryWrapper<CfEarnRule>()
  192. .eq(CfEarnRule::getCode, targetId)
  193. .last("LIMIT 1"));
  194. return toJson(rule);
  195. case MODULE_TIER:
  196. Long tierId = toLong(targetId);
  197. if (tierId == null) {
  198. return null;
  199. }
  200. return toJson(cfRateTierMapper.selectById(tierId));
  201. default:
  202. return null;
  203. }
  204. } catch (Exception e) {
  205. log.warn("审计快照读取失败 module={} targetId={}:{}", module, targetId, e.getMessage());
  206. return null;
  207. }
  208. }
  209. private void persist(HttpServletRequest request, String module, String action, String targetId,
  210. String beforeJson, String afterJson) {
  211. Long operatorId = null;
  212. String operatorName = null;
  213. if (request != null) {
  214. Object uid = request.getAttribute("userId");
  215. if (uid instanceof Number && ((Number) uid).longValue() > 0) {
  216. operatorId = ((Number) uid).longValue();
  217. operatorName = resolveOperatorName(operatorId);
  218. }
  219. }
  220. if (operatorName == null && operatorId != null) {
  221. operatorName = "uid_" + operatorId;
  222. }
  223. AuditLog audit = new AuditLog();
  224. audit.setOperatorId(operatorId);
  225. audit.setOperatorName(operatorName);
  226. audit.setModule(module);
  227. audit.setAction(action);
  228. audit.setTargetType(targetTypeOf(module));
  229. audit.setTargetId(truncate(targetId, 128));
  230. audit.setBeforeValue(truncate(beforeJson));
  231. audit.setAfterValue(truncate(afterJson));
  232. audit.setIp(clientIp(request));
  233. audit.setCreatedAt(new Date());
  234. if (auditLogService.record(audit)) {
  235. log.info("审计已落库 module={} action={} targetId={} operator={}",
  236. module, action, audit.getTargetId(), operatorName);
  237. }
  238. }
  239. private String resolveOperatorName(Long userId) {
  240. try {
  241. User user = userMapper.selectById(userId);
  242. if (user != null) {
  243. if (user.getNickname() != null && !user.getNickname().trim().isEmpty()) {
  244. return user.getNickname();
  245. }
  246. if (user.getRealName() != null && !user.getRealName().trim().isEmpty()) {
  247. return user.getRealName();
  248. }
  249. }
  250. } catch (Exception e) {
  251. log.warn("查询审计操作人失败 userId={}:{}", userId, e.getMessage());
  252. }
  253. return null;
  254. }
  255. private String clientIp(HttpServletRequest request) {
  256. if (request == null) {
  257. return null;
  258. }
  259. String ip = request.getHeader("X-Forwarded-For");
  260. if (ip != null && ip.indexOf(',') >= 0) {
  261. ip = ip.substring(0, ip.indexOf(',')).trim();
  262. }
  263. if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip.trim())) {
  264. ip = request.getHeader("X-Real-IP");
  265. }
  266. if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip.trim())) {
  267. ip = request.getRemoteAddr();
  268. }
  269. return truncate(ip, 64);
  270. }
  271. private String firstValue(Object[] args, String... keys) {
  272. if (args == null) {
  273. return null;
  274. }
  275. for (Object arg : args) {
  276. Map<String, Object> map = toMap(arg);
  277. if (map == null) {
  278. continue;
  279. }
  280. for (String key : keys) {
  281. Object v = map.get(key);
  282. if (v != null && !String.valueOf(v).trim().isEmpty()) {
  283. return String.valueOf(v);
  284. }
  285. }
  286. }
  287. return null;
  288. }
  289. private Object payload(Object[] args) {
  290. if (args == null || args.length == 0) {
  291. return null;
  292. }
  293. for (Object arg : args) {
  294. Map<String, Object> map = toMap(arg);
  295. if (map != null && !map.isEmpty()) {
  296. return map;
  297. }
  298. }
  299. return null;
  300. }
  301. private Map<String, Object> toMap(Object arg) {
  302. if (arg == null) {
  303. return null;
  304. }
  305. try {
  306. if (arg instanceof Map) {
  307. Map<String, Object> out = new LinkedHashMap<>();
  308. for (Map.Entry<?, ?> entry : ((Map<?, ?>) arg).entrySet()) {
  309. if (entry.getKey() != null) {
  310. out.put(String.valueOf(entry.getKey()), entry.getValue());
  311. }
  312. }
  313. return out;
  314. }
  315. return objectMapper.convertValue(arg, MAP_TYPE);
  316. } catch (Exception e) {
  317. return null;
  318. }
  319. }
  320. private HttpServletRequest currentRequest() {
  321. ServletRequestAttributes attrs =
  322. (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  323. return attrs == null ? null : attrs.getRequest();
  324. }
  325. private String toJson(Object obj) {
  326. if (obj == null) {
  327. return null;
  328. }
  329. try {
  330. return truncate(objectMapper.writeValueAsString(obj));
  331. } catch (Exception e) {
  332. log.warn("审计快照序列化失败:{}", e.getMessage());
  333. return null;
  334. }
  335. }
  336. private Long toLong(String value) {
  337. try {
  338. return value == null ? null : Long.valueOf(value.trim());
  339. } catch (NumberFormatException e) {
  340. return null;
  341. }
  342. }
  343. private String truncate(String value) {
  344. return truncate(value, MAX_SNAPSHOT_CHARS);
  345. }
  346. private String truncate(String value, int max) {
  347. if (value == null) {
  348. return null;
  349. }
  350. return value.length() > max ? value.substring(0, max) : value;
  351. }
  352. }