|
|
@@ -0,0 +1,386 @@
|
|
|
+package com.etotem.cfc.aspect;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.AuditLog;
|
|
|
+import com.etotem.cfc.entity.CfEarnRule;
|
|
|
+import com.etotem.cfc.entity.CfRateTier;
|
|
|
+import com.etotem.cfc.entity.ServiceConfigItem;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.CfEarnRuleMapper;
|
|
|
+import com.etotem.cfc.mapper.CfRateTierMapper;
|
|
|
+import com.etotem.cfc.mapper.ServiceConfigItemMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.AuditLogService;
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.aspectj.lang.ProceedingJoinPoint;
|
|
|
+import org.aspectj.lang.annotation.Around;
|
|
|
+import org.aspectj.lang.annotation.Aspect;
|
|
|
+import org.aspectj.lang.annotation.Pointcut;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import org.springframework.web.context.request.RequestContextHolder;
|
|
|
+import org.springframework.web.context.request.ServletRequestAttributes;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 管理端写操作统一审计切面(W0.3 审计日志统一)。
|
|
|
+ *
|
|
|
+ * 拦截范围:/api/admin/** 下 CF 相关管理端控制器({@code AdminCf*} + {@code AdminServiceConfigController})
|
|
|
+ * 的写语义方法(save / update / toggle / delete / adjust 等),自动落 {@code audit_log} 并记录改前/改后值;
|
|
|
+ * 审计落库成功后由 {@code AuditLogService} 触发 {@code CfConfigCache.invalidate()}。
|
|
|
+ *
|
|
|
+ * 约定:
|
|
|
+ * - 读语义方法(list / history / balance / logs / invalidate 等)不拦截,不产生审计行;
|
|
|
+ * - 业务方法抛异常时不落审计(避免为失败的写操作留痕),审计自身异常仅 warn,绝不阻塞业务请求;
|
|
|
+ * - 新增 CF 管理端控制器时,类名以 {@code AdminCf} 开头即自动纳入拦截,无需修改本切面。
|
|
|
+ *
|
|
|
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
|
|
|
+ */
|
|
|
+@Aspect
|
|
|
+@Component
|
|
|
+public class AuditLogAspect {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(AuditLogAspect.class);
|
|
|
+
|
|
|
+ static final String MODULE_CONFIG = "config";
|
|
|
+ static final String MODULE_EARN_RULE = "earn_rule";
|
|
|
+ static final String MODULE_TIER = "tier";
|
|
|
+ static final String MODULE_BALANCE = "balance";
|
|
|
+
|
|
|
+ private static final int MAX_SNAPSHOT_CHARS = 16000;
|
|
|
+
|
|
|
+ private static final TypeReference<Map<String, Object>> MAP_TYPE =
|
|
|
+ new TypeReference<Map<String, Object>>() {
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 写语义动作白名单(读语义一律不审计) */
|
|
|
+ private static final Set<String> WRITE_ACTIONS = new HashSet<>(Arrays.asList(
|
|
|
+ "save", "update", "toggle", "delete", "adjust",
|
|
|
+ "upsert", "enable", "disable", "create", "edit", "remove"));
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AuditLogService auditLogService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ServiceConfigItemMapper serviceConfigItemMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private CfEarnRuleMapper cfEarnRuleMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private CfRateTierMapper cfRateTierMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ /** CF 相关管理端控制器(AdminCf* + 服务配置中心) */
|
|
|
+ @Pointcut("within(com.etotem.cfc.controller.admin.AdminCf*) "
|
|
|
+ + "|| within(com.etotem.cfc.controller.admin.AdminServiceConfigController)")
|
|
|
+ public void cfAdminController() {
|
|
|
+ }
|
|
|
+
|
|
|
+ @Around("cfAdminController()")
|
|
|
+ public Object aroundAdminWrite(ProceedingJoinPoint pjp) throws Throwable {
|
|
|
+ String module = null;
|
|
|
+ String action = null;
|
|
|
+ String targetId = null;
|
|
|
+ String beforeJson = null;
|
|
|
+ try {
|
|
|
+ HttpServletRequest request = currentRequest();
|
|
|
+ if (request != null) {
|
|
|
+ module = resolveModule(request.getRequestURI());
|
|
|
+ if (module != null) {
|
|
|
+ action = resolveAction(pjp.getSignature().getName());
|
|
|
+ if (action != null) {
|
|
|
+ targetId = resolveTargetId(module, pjp.getArgs());
|
|
|
+ beforeJson = snapshot(module, targetId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("审计切面解析失败,跳过审计:{}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 业务方法:审计不改变其返回值/异常语义
|
|
|
+ Object result = pjp.proceed();
|
|
|
+
|
|
|
+ if (module != null && action != null) {
|
|
|
+ try {
|
|
|
+ HttpServletRequest request = currentRequest();
|
|
|
+ String afterJson = snapshot(module, targetId);
|
|
|
+ if (afterJson == null) {
|
|
|
+ afterJson = toJson(payload(pjp.getArgs()));
|
|
|
+ }
|
|
|
+ persist(request, module, action, targetId, beforeJson, afterJson);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("审计落库异常(不影响业务):{}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按请求 URI 判定模块:config / earn_rule / tier / balance */
|
|
|
+ private String resolveModule(String uri) {
|
|
|
+ if (uri == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String path = uri.toLowerCase();
|
|
|
+ if (path.contains("/service-config")) {
|
|
|
+ return MODULE_CONFIG;
|
|
|
+ }
|
|
|
+ if (path.contains("/cf-earn-rule")) {
|
|
|
+ return MODULE_EARN_RULE;
|
|
|
+ }
|
|
|
+ if (path.contains("/tier")) {
|
|
|
+ return MODULE_TIER;
|
|
|
+ }
|
|
|
+ if (path.contains("/cf-balance") || path.contains("/cf-value")) {
|
|
|
+ return MODULE_BALANCE;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String resolveAction(String methodName) {
|
|
|
+ if (methodName == null || methodName.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return WRITE_ACTIONS.contains(methodName) ? methodName : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String resolveTargetId(String module, Object[] args) {
|
|
|
+ switch (module) {
|
|
|
+ case MODULE_CONFIG:
|
|
|
+ return firstValue(args, "configKey", "key");
|
|
|
+ case MODULE_EARN_RULE:
|
|
|
+ return firstValue(args, "code", "ruleCode");
|
|
|
+ case MODULE_TIER:
|
|
|
+ return firstValue(args, "id", "tierId");
|
|
|
+ case MODULE_BALANCE:
|
|
|
+ return firstValue(args, "memberId", "userId", "id");
|
|
|
+ default:
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String targetTypeOf(String module) {
|
|
|
+ switch (module) {
|
|
|
+ case MODULE_CONFIG:
|
|
|
+ return "service_config";
|
|
|
+ case MODULE_EARN_RULE:
|
|
|
+ return "cf_earn_rule";
|
|
|
+ case MODULE_TIER:
|
|
|
+ return "cf_rate_tier";
|
|
|
+ case MODULE_BALANCE:
|
|
|
+ return "cf_balance";
|
|
|
+ default:
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 目标对象快照(DB 实时读取,新建为 null,删除后为 null)。
|
|
|
+ * balance 模块暂无实体快照入口(F-2 接入 CfBalanceService 后补),回退为请求参数快照。
|
|
|
+ */
|
|
|
+ private String snapshot(String module, String targetId) {
|
|
|
+ try {
|
|
|
+ switch (module) {
|
|
|
+ case MODULE_CONFIG:
|
|
|
+ if (targetId == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ ServiceConfigItem item = serviceConfigItemMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ServiceConfigItem>()
|
|
|
+ .eq(ServiceConfigItem::getConfigKey, targetId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ return toJson(item);
|
|
|
+ case MODULE_EARN_RULE:
|
|
|
+ if (targetId == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ CfEarnRule rule = cfEarnRuleMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<CfEarnRule>()
|
|
|
+ .eq(CfEarnRule::getCode, targetId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ return toJson(rule);
|
|
|
+ case MODULE_TIER:
|
|
|
+ Long tierId = toLong(targetId);
|
|
|
+ if (tierId == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return toJson(cfRateTierMapper.selectById(tierId));
|
|
|
+ default:
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("审计快照读取失败 module={} targetId={}:{}", module, targetId, e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void persist(HttpServletRequest request, String module, String action, String targetId,
|
|
|
+ String beforeJson, String afterJson) {
|
|
|
+ Long operatorId = null;
|
|
|
+ String operatorName = null;
|
|
|
+ if (request != null) {
|
|
|
+ Object uid = request.getAttribute("userId");
|
|
|
+ if (uid instanceof Number && ((Number) uid).longValue() > 0) {
|
|
|
+ operatorId = ((Number) uid).longValue();
|
|
|
+ operatorName = resolveOperatorName(operatorId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (operatorName == null && operatorId != null) {
|
|
|
+ operatorName = "uid_" + operatorId;
|
|
|
+ }
|
|
|
+ AuditLog audit = new AuditLog();
|
|
|
+ audit.setOperatorId(operatorId);
|
|
|
+ audit.setOperatorName(operatorName);
|
|
|
+ audit.setModule(module);
|
|
|
+ audit.setAction(action);
|
|
|
+ audit.setTargetType(targetTypeOf(module));
|
|
|
+ audit.setTargetId(truncate(targetId, 128));
|
|
|
+ audit.setBeforeValue(truncate(beforeJson));
|
|
|
+ audit.setAfterValue(truncate(afterJson));
|
|
|
+ audit.setIp(clientIp(request));
|
|
|
+ audit.setCreatedAt(new Date());
|
|
|
+ if (auditLogService.record(audit)) {
|
|
|
+ log.info("审计已落库 module={} action={} targetId={} operator={}",
|
|
|
+ module, action, audit.getTargetId(), operatorName);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String resolveOperatorName(Long userId) {
|
|
|
+ try {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ if (user.getNickname() != null && !user.getNickname().trim().isEmpty()) {
|
|
|
+ return user.getNickname();
|
|
|
+ }
|
|
|
+ if (user.getRealName() != null && !user.getRealName().trim().isEmpty()) {
|
|
|
+ return user.getRealName();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("查询审计操作人失败 userId={}:{}", userId, e.getMessage());
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String clientIp(HttpServletRequest request) {
|
|
|
+ if (request == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String ip = request.getHeader("X-Forwarded-For");
|
|
|
+ if (ip != null && ip.indexOf(',') >= 0) {
|
|
|
+ ip = ip.substring(0, ip.indexOf(',')).trim();
|
|
|
+ }
|
|
|
+ if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip.trim())) {
|
|
|
+ ip = request.getHeader("X-Real-IP");
|
|
|
+ }
|
|
|
+ if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip.trim())) {
|
|
|
+ ip = request.getRemoteAddr();
|
|
|
+ }
|
|
|
+ return truncate(ip, 64);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String firstValue(Object[] args, String... keys) {
|
|
|
+ if (args == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ for (Object arg : args) {
|
|
|
+ Map<String, Object> map = toMap(arg);
|
|
|
+ if (map == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (String key : keys) {
|
|
|
+ Object v = map.get(key);
|
|
|
+ if (v != null && !String.valueOf(v).trim().isEmpty()) {
|
|
|
+ return String.valueOf(v);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Object payload(Object[] args) {
|
|
|
+ if (args == null || args.length == 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ for (Object arg : args) {
|
|
|
+ Map<String, Object> map = toMap(arg);
|
|
|
+ if (map != null && !map.isEmpty()) {
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> toMap(Object arg) {
|
|
|
+ if (arg == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ if (arg instanceof Map) {
|
|
|
+ Map<String, Object> out = new LinkedHashMap<>();
|
|
|
+ for (Map.Entry<?, ?> entry : ((Map<?, ?>) arg).entrySet()) {
|
|
|
+ if (entry.getKey() != null) {
|
|
|
+ out.put(String.valueOf(entry.getKey()), entry.getValue());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ return objectMapper.convertValue(arg, MAP_TYPE);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private HttpServletRequest currentRequest() {
|
|
|
+ ServletRequestAttributes attrs =
|
|
|
+ (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
|
+ return attrs == null ? null : attrs.getRequest();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String toJson(Object obj) {
|
|
|
+ if (obj == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return truncate(objectMapper.writeValueAsString(obj));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("审计快照序列化失败:{}", e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Long toLong(String value) {
|
|
|
+ try {
|
|
|
+ return value == null ? null : Long.valueOf(value.trim());
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String truncate(String value) {
|
|
|
+ return truncate(value, MAX_SNAPSHOT_CHARS);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String truncate(String value, int max) {
|
|
|
+ if (value == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return value.length() > max ? value.substring(0, max) : value;
|
|
|
+ }
|
|
|
+}
|