Просмотр исходного кода

refactor(audit): W0.3 审计日志统一(audit_log 表 + AOP 切面 + 查询接口,迁移 414)

zxyj-web 2 недель назад
Родитель
Сommit
167787f8a8

+ 6 - 0
cfc-backend/pom.xml

@@ -44,6 +44,12 @@
             </exclusions>
         </dependency>
 
+        <!-- AOP(审计切面) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
         <!-- MyBatis-Plus -->
         <dependency>
             <groupId>com.baomidou</groupId>

+ 386 - 0
cfc-backend/src/main/java/com/etotem/cfc/aspect/AuditLogAspect.java

@@ -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;
+    }
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3286,6 +3286,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         addServiceConfigProductKeyColumn();
         migrateProductConfigToServiceConfig();
 
+        // 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)
+        createAuditLogTable();
+
         // 迁移416: cf_balance_log 补 operator_user_id + 幂等键升级 (member_id,type,ref_type,ref_id)
         //         + CF 提现开关/汇率种子(F-2)
         migrateCfBalanceLogForLedger();
@@ -13911,4 +13914,39 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             return false;
         }
     }
+
+    /**
+     * 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)。
+     * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。
+     */
+    private void createAuditLogTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS audit_log (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "operator_id BIGINT COMMENT '操作人ID(关联 users.id)'," +
+                    "operator_name VARCHAR(100) COMMENT '操作人姓名'," +
+                    "module VARCHAR(64) NOT NULL COMMENT '审计模块(config/earn_rule/tier/balance)'," +
+                    "action VARCHAR(32) NOT NULL COMMENT '动作类型(save/update/toggle/delete/adjust)'," +
+                    "target_type VARCHAR(32) NOT NULL COMMENT '目标类型(service_config/cf_earn_rule/cf_rate_tier/cf_balance)'," +
+                    "target_id VARCHAR(128) COMMENT '目标标识(配置key / 规则code / 主键ID)'," +
+                    "before_value TEXT COMMENT '变更前值(JSON)'," +
+                    "after_value TEXT COMMENT '变更后值(JSON)'," +
+                    "ip VARCHAR(45) COMMENT '操作来源IP'," +
+                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间'," +
+                    "KEY idx_module_target_created (module, target_id, created_at)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)'");
+            log.info("迁移414: audit_log 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移414: 创建 audit_log 表失败(表可能已存在): {}", e.getMessage());
+        }
+        // 索引兜底:表已存在但索引缺失时补建(幂等)
+        try {
+            if (!indexExists("audit_log", "idx_module_target_created")) {
+                jdbcTemplate.execute("ALTER TABLE audit_log ADD INDEX idx_module_target_created (module, target_id, created_at)");
+                log.info("迁移414: 已为 audit_log 补建索引 idx_module_target_created");
+            }
+        } catch (Exception e) {
+            log.warn("迁移414: audit_log 索引检查/补建失败(可能已存在): {}", e.getMessage());
+        }
+    }
 }

+ 119 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuditController.java

@@ -0,0 +1,119 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.AuditLog;
+import com.etotem.cfc.service.AuditLogService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 审计日志查询接口(W0.3 审计日志统一)。
+ *
+ * audit_log 由 {@code AuditLogAspect} 自动写入(管理端写操作),本接口只读查询,
+ * 仅 admin 角色可访问。
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
+ */
+@Tag(name = "管理端-审计日志")
+@RestController
+@RequestMapping("/api/admin/audit")
+public class AdminAuditController {
+
+    private static final String[] DATE_PATTERNS = {
+            "yyyy-MM-dd HH:mm:ss",
+            "yyyy-MM-dd HH:mm",
+            "yyyy-MM-dd'T'HH:mm:ss",
+            "yyyy-MM-dd"
+    };
+
+    @Resource
+    private AuditLogService auditLogService;
+
+    /**
+     * 分页查询审计日志。
+     *
+     * @param module    模块:config / earn_rule / tier / balance(可选)
+     * @param targetId  目标标识:配置key / 规则code / 主键(可选,兼容 target_id)
+     * @param startTime 起始时间(可选,兼容 startDate)
+     * @param endTime   结束时间(可选,兼容 endDate)
+     * @param page      页码,默认 1
+     * @param size      每页条数,默认 20,最大 200
+     */
+    @Operation(summary = "审计日志分页查询(仅 admin)")
+    @PostMapping("/list")
+    public Result<Page<AuditLog>> list(@RequestAttribute("role") String role,
+                                        @RequestBody(required = false) Map<String, Object> params) {
+        if (!"admin".equals(role)) {
+            return Result.error("仅管理员可查询审计日志");
+        }
+        Map<String, Object> query = params == null ? java.util.Collections.<String, Object>emptyMap() : params;
+
+        String module = str(query.get("module"));
+        String targetId = str(query.get("targetId"));
+        if (targetId == null) {
+            targetId = str(query.get("target_id"));
+        }
+        Date startTime = parseDate(firstNonNull(query.get("startTime"), query.get("startDate")));
+        Date endTime = parseDate(firstNonNull(query.get("endTime"), query.get("endDate")));
+
+        int pageNo = intVal(query.get("page"), 1);
+        int pageSize = intVal(query.get("size"), 20);
+
+        return Result.success(auditLogService.page(module, targetId, startTime, endTime, pageNo, pageSize));
+    }
+
+    private Object firstNonNull(Object a, Object b) {
+        return a != null ? a : b;
+    }
+
+    private String str(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String s = String.valueOf(value).trim();
+        return s.isEmpty() ? null : s;
+    }
+
+    private int intVal(Object value, int defaultValue) {
+        if (value == null) {
+            return defaultValue;
+        }
+        try {
+            return Integer.parseInt(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+
+    /** 解析多种时间格式,失败返回 null(不阻断查询) */
+    private Date parseDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        for (String pattern : DATE_PATTERNS) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat(pattern);
+                sdf.setLenient(false);
+                return sdf.parse(text);
+            } catch (ParseException ignored) {
+                // 试下一个格式
+            }
+        }
+        return null;
+    }
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AuditLog.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 管理端写操作审计日志(W0.3 统一审计层,只增不删改)。
+ *
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3:
+ * 写入链路:/admin/** 写操作 → {@code AuditLogAspect} 自动落库 → 成功后 {@code CfConfigCache.invalidate()};
+ * 查询链路:POST /api/admin/audit/list(仅 admin 角色)。
+ */
+@Data
+@TableName("audit_log")
+public class AuditLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 操作人用户ID(users.id;匿名或未识别为 null) */
+    private Long operatorId;
+
+    /** 操作人名称(users.nickname / realName 兜底) */
+    private String operatorName;
+
+    /** 模块:config / earn_rule / tier / balance */
+    private String module;
+
+    /** 动作:save / update / toggle / delete / adjust */
+    private String action;
+
+    /** 目标对象类型(service_config / cf_earn_rule / cf_rate_tier / cf_balance) */
+    private String targetType;
+
+    /** 目标对象标识(配置key / 规则code / 主键ID) */
+    private String targetId;
+
+    /** 变更前值(JSON),无记录/无法快照为 null */
+    private String beforeValue;
+
+    /** 变更后值(JSON),删除场景为请求参数快照 */
+    private String afterValue;
+
+    /** 操作来源IP */
+    private String ip;
+
+    /** 发生时间 */
+    private Date createdAt;
+}

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/AuditLogMapper.java

@@ -0,0 +1,10 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AuditLog;
+
+/**
+ * 审计日志 Mapper(W0.3,表只增不删改)。
+ */
+public interface AuditLogMapper extends BaseMapper<AuditLog> {
+}

+ 76 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AuditLogService.java

@@ -0,0 +1,76 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.config.CfConfigCache;
+import com.etotem.cfc.entity.AuditLog;
+import com.etotem.cfc.mapper.AuditLogMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 审计日志写入/查询服务(W0.3 审计日志统一)。
+ *
+ * 表只增不删改:不允许 UPDATE/DELETE,写入失败仅 warn 不阻塞业务主流程。
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
+ */
+@Service
+public class AuditLogService {
+
+    private static final Logger log = LoggerFactory.getLogger(AuditLogService.class);
+
+    @Resource
+    private AuditLogMapper auditLogMapper;
+
+    /**
+     * 落一条审计日志;成功后按 W0.3 约定同步失效 CF 配置缓存。
+     *
+     * @return true=落库成功
+     */
+    public boolean record(AuditLog audit) {
+        if (audit == null) {
+            return false;
+        }
+        if (audit.getCreatedAt() == null) {
+            audit.setCreatedAt(new Date());
+        }
+        try {
+            auditLogMapper.insert(audit);
+            CfConfigCache.invalidate();
+            return true;
+        } catch (Exception e) {
+            log.warn("写 audit_log 失败 module={} action={} targetId={}:{}",
+                    audit.getModule(), audit.getAction(), audit.getTargetId(), e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 分页查询审计日志(module / targetId / 时间范围 可选筛选,时间倒序)。
+     */
+    public Page<AuditLog> page(String module, String targetId, Date startTime, Date endTime,
+                               int pageNo, int pageSize) {
+        LambdaQueryWrapper<AuditLog> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.hasText(module)) {
+            wrapper.eq(AuditLog::getModule, module.trim());
+        }
+        if (StringUtils.hasText(targetId)) {
+            wrapper.eq(AuditLog::getTargetId, targetId.trim());
+        }
+        if (startTime != null) {
+            wrapper.ge(AuditLog::getCreatedAt, startTime);
+        }
+        if (endTime != null) {
+            wrapper.le(AuditLog::getCreatedAt, endTime);
+        }
+        wrapper.orderByDesc(AuditLog::getCreatedAt).orderByDesc(AuditLog::getId);
+        int p = Math.max(1, pageNo == 0 ? 1 : pageNo);
+        int s = pageSize <= 0 ? 20 : Math.min(pageSize, 200);
+        return auditLogMapper.selectPage(new Page<>(p, s), wrapper);
+    }
+}

+ 15 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6798,3 +6798,18 @@ CREATE TABLE IF NOT EXISTS service_config (
     KEY idx_group_key (group_key),
     KEY idx_product_key (product_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口 + product 侧配置并入,W0.2)';
+
+CREATE TABLE IF NOT EXISTS audit_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    operator_id BIGINT COMMENT '操作人ID(关联 users.id)',
+    operator_name VARCHAR(100) COMMENT '操作人姓名',
+    module VARCHAR(64) NOT NULL COMMENT '审计模块(config/earn_rule/tier/balance)',
+    action VARCHAR(32) NOT NULL COMMENT '动作类型(save/update/toggle/delete/adjust)',
+    target_type VARCHAR(32) NOT NULL COMMENT '目标类型(service_config/cf_earn_rule/cf_rate_tier/cf_balance)',
+    target_id VARCHAR(128) COMMENT '目标标识(配置key / 规则code / 主键ID)',
+    before_value TEXT COMMENT '变更前值(JSON)',
+    after_value TEXT COMMENT '变更后值(JSON)',
+    ip VARCHAR(45) COMMENT '操作来源IP',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间',
+    KEY idx_module_target_created (module, target_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)';