Skip to content
第 89 / 250 章后端⏱ 10 分钟阅读

第 89 章:操作日志与代码生成

学习目标

  • 实现基于 AOP 的自动操作日志
  • 实现登录日志与安全审计
  • 实现代码生成器:一键生成 CRUD

一、操作日志

1.1 实体

java
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_operation_log")
public class OperationLog extends BaseEntity {
    private String module;          // 模块名
    private String action;          // 操作(新增/修改/删除/查询)
    private String description;     // 描述
    private String method;          // 方法签名
    private String requestMethod;   // GET/POST
    private String requestUrl;
    private String requestParams;   // 请求参数(JSON)
    private String responseData;    // 响应数据
    private Long userId;
    private String username;
    private String ip;
    private String userAgent;
    private Long costTime;          // 耗时(ms)
    private Integer status;          // 1=成功 0=失败
    private String errorMsg;
}

1.2 注解

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    String module();                         // 模块
    String action();                         // 操作
    String description() default "";         // 描述
    boolean saveParams() default true;        // 是否保存请求参数
    boolean saveResponse() default false;     // 是否保存响应
}

1.3 AOP 切面

java
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class OperationLogAspect {

    private final OperationLogMapper operationLogMapper;
    private final ObjectMapper objectMapper;

    @Around("@annotation(operationLog)")
    public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
        long start = System.currentTimeMillis();
        OperationLog log = new OperationLog();
        log.setModule(operationLog.module());
        log.setAction(operationLog.action());
        log.setDescription(operationLog.description());

        try {
            // ① 当前用户
            Long userId = SecurityUtils.getCurrentUserId();
            log.setUserId(userId);
            log.setUsername(SecurityUtils.getCurrentUsername());

            // ② 请求信息
            HttpServletRequest req = getRequest();
            log.setIp(SecurityUtils.getCurrentRequestIp());
            log.setRequestUrl(req.getRequestURI());
            log.setRequestMethod(req.getMethod());
            log.setUserAgent(req.getHeader("User-Agent"));
            log.setMethod(pjp.getSignature().toLongString());

            // ③ 请求参数
            if (operationLog.saveParams()) {
                log.setRequestParams(serializeArgs(pjp.getArgs()));
            }

            // ④ 执行
            Object result = pjp.proceed();

            // ⑤ 响应
            if (operationLog.saveResponse()) {
                log.setResponseData(objectMapper.writeValueAsString(result));
            }
            log.setStatus(1);
            return result;

        } catch (Throwable e) {
            log.setStatus(0);
            log.setErrorMsg(e.getMessage());
            throw e;

        } finally {
            // ⑥ 耗时
            log.setCostTime(System.currentTimeMillis() - start);
            // ⑦ 异步保存
            saveAsync(log);
        }
    }

    private void saveAsync(OperationLog log) {
        CompletableFuture.runAsync(() -> {
            try {
                operationLogMapper.insert(log);
            } catch (Exception e) {
                OperationLogAspect.log.error("保存操作日志失败", e);
            }
        });
    }

    private String serializeArgs(Object[] args) {
        if (args == null || args.length == 0) return null;
        try {
            return objectMapper.writeValueAsString(args);
        } catch (Exception e) {
            return Arrays.stream(args).map(String::valueOf).collect(Collectors.joining(","));
        }
    }

    private HttpServletRequest getRequest() {
        ServletRequestAttributes attrs = (ServletRequestAttributes)
                RequestContextHolder.getRequestAttributes();
        return attrs.getRequest();
    }
}

1.4 使用

java
@OperationLog(module = "用户管理", action = "新增", description = "创建新用户")
@RequiresPermission("user:create")
@PostMapping
public Result<Long> create(@RequestBody @Valid UserCreateDTO dto) {
    return Result.ok(userService.createUser(dto));
}

@OperationLog(module = "用户管理", action = "删除", description = "删除用户")
@RequiresPermission("user:delete")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
    userService.deleteUser(id);
    return Result.ok();
}

1.5 异步线程池(专用)

java
@Configuration
public class LogAsyncConfig {

    @Bean("logExecutor")
    public Executor logExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("log-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
        executor.initialize();
        return executor;
    }
}
java
// 在切面使用专用线程池
@Async("logExecutor")
public void asyncSave(OperationLog log) {
    operationLogMapper.insert(log);
}

1.6 Controller(查询日志)

java
@RestController
@RequestMapping("/api/log/operation")
@RequiredArgsConstructor
@Tag(name = "操作日志")
public class OperationLogController {

    private final OperationLogMapper operationLogMapper;

    @GetMapping("/page")
    @RequiresPermission("log:operation:view")
    public Result<PageResult<OperationLog>> page(
            @RequestParam(defaultValue = "1") Long current,
            @RequestParam(defaultValue = "20") Long size,
            @RequestParam(required = false) String module,
            @RequestParam(required = false) String username) {

        Page<OperationLog> page = new Page<>(current, size);
        LambdaQueryWrapper<OperationLog> wrapper = new LambdaQueryWrapper<OperationLog>()
                .like(StringUtils.hasText(module), OperationLog::getModule, module)
                .like(StringUtils.hasText(username), OperationLog::getUsername, username)
                .orderByDesc(OperationLog::getCreateTime);
        IPage<OperationLog> result = operationLogMapper.selectPage(page, wrapper);
        return Result.ok(PageResult.of(result));
    }

    @DeleteMapping("/{id}")
    @RequiresPermission("log:operation:delete")
    public Result<Void> delete(@PathVariable Long id) {
        operationLogMapper.deleteById(id);
        return Result.ok();
    }

    @DeleteMapping("/batch")
    @RequiresPermission("log:operation:delete")
    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
        operationLogMapper.deleteBatchIds(ids);
        return Result.ok();
    }
}

二、登录日志

2.1 实体

java
@Data
@TableName("sys_login_log")
public class LoginLog {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private String username;
    private String ip;
    private String userAgent;
    private String location;        // IP 归属地
    private Integer status;         // 1=成功 0=失败
    private String message;
    private LocalDateTime loginTime;
}

2.2 在认证流程中记录

java
@Component
@RequiredArgsConstructor
@Slf4j
public class LoginLogHelper {

    private final LoginLogMapper loginLogMapper;
    private final Ip2RegionUtil ip2Region;

    public void recordSuccess(String username, HttpServletRequest req) {
        LoginLog log = new LoginLog();
        log.setUsername(username);
        log.setIp(SecurityUtils.getIp(req));
        log.setUserAgent(req.getHeader("User-Agent"));
        log.setLocation(ip2Region.parse(log.getIp()));
        log.setStatus(1);
        log.setMessage("登录成功");
        log.setLoginTime(LocalDateTime.now());
        loginLogMapper.insert(log);
    }

    public void recordFailure(String username, HttpServletRequest req, String reason) {
        LoginLog log = new LoginLog();
        log.setUsername(username);
        log.setIp(SecurityUtils.getIp(req));
        log.setUserAgent(req.getHeader("User-Agent"));
        log.setStatus(0);
        log.setMessage(reason);
        log.setLoginTime(LocalDateTime.now());
        loginLogMapper.insert(log);
    }
}

2.3 在 AuthenticationFailureListener 中调用

java
@Component
@RequiredArgsConstructor
public class AuthenticationFailureListener
        implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {

    private final LoginLogHelper loginLogHelper;

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
        String username = (String) event.getAuthentication().getPrincipal();
        HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes()).getRequest();
        loginLogHelper.recordFailure(username, req, "密码错误");
    }
}

2.4 登录失败次数限制(Redis)

java
@Component
@RequiredArgsConstructor
public class LoginAttemptService {

    private final RedisTemplate<String, Object> redis;
    private static final int MAX_ATTEMPTS = 5;
    private static final Duration LOCK_DURATION = Duration.ofMinutes(15);

    public void loginFailed(String username) {
        String key = "login:attempt:" + username;
        Long count = redis.opsForValue().increment(key);
        if (count == 1L) {
            redis.expire(key, LOCK_DURATION);
        }
        if (count >= MAX_ATTEMPTS) {
            throw new BusinessException(ErrorCode.USER_LOCKED,
                    "登录失败次数过多,请 15 分钟后再试");
        }
    }

    public void loginSucceeded(String username) {
        redis.delete("login:attempt:" + username);
    }

    public boolean isLocked(String username) {
        Long count = (Long) redis.opsForValue().get("login:attempt:" + username);
        return count != null && count >= MAX_ATTEMPTS;
    }
}

三、代码生成器

3.1 引入依赖

xml
<dependency>
    <groupId>org.apache.velocity</groupId>
    <artifactId>velocity-engine-core</artifactId>
    <version>2.3</version>
</dependency>

3.2 数据源

java
@Data
public class GenTable {
    private String tableName;
    private String tableComment;
    private String className;       // 驼峰类名
    private String classNameLower;  // 首字母小写
    private String moduleName;      // 模块名
    private String businessName;    // 业务名
    private List<GenTableColumn> columns;
}

@Data
public class GenTableColumn {
    private String columnName;
    private String columnComment;
    private String fieldName;       // 驼峰字段名
    private String fieldType;       // Java 类型
    private String tsType;          // TypeScript 类型
    private Boolean primaryKey;
    private Boolean nullable;
    private String defaultValue;
    private Integer sort;
    private String htmlType;        // input/select/radio/checkbox
}

3.3 表结构查询

java
@Service
@RequiredArgsConstructor
public class GenTableService {

    private final DataSource dataSource;

    /**
     * 查询数据库所有表
     */
    public List<GenTable> listTables() throws SQLException {
        List<GenTable> tables = new ArrayList<>();
        try (Connection conn = dataSource.getConnection()) {
            DatabaseMetaData meta = conn.getMetaData();
            // MySQL:schema = database name
            String catalog = conn.getCatalog();
            ResultSet rs = meta.getTables(catalog, null, "%",
                    new String[]{"TABLE"});

            while (rs.next()) {
                GenTable table = new GenTable();
                table.setTableName(rs.getString("TABLE_NAME"));
                table.setTableComment(rs.getString("REMARKS"));
                table.setClassName(toCamel(rs.getString("TABLE_NAME"), true));
                table.setClassNameLower(toCamel(rs.getString("TABLE_NAME"), false));
                table.setColumns(listColumns(rs.getString("TABLE_NAME")));
                tables.add(table);
            }
        }
        return tables;
    }

    /**
     * 查询表的字段
     */
    public List<GenTableColumn> listColumns(String tableName) throws SQLException {
        List<GenTableColumn> columns = new ArrayList<>();
        try (Connection conn = dataSource.getConnection()) {
            DatabaseMetaData meta = conn.getMetaData();
            String catalog = conn.getCatalog();
            ResultSet rs = meta.getColumns(catalog, null, tableName, null);
            while (rs.next()) {
                GenTableColumn col = new GenTableColumn();
                col.setColumnName(rs.getString("COLUMN_NAME"));
                col.setColumnComment(rs.getString("REMARKS"));
                col.setFieldName(toCamel(rs.getString("COLUMN_NAME"), false));
                col.setFieldType(toJavaType(rs.getString("TYPE_NAME")));
                col.setTsType(toTsType(col.getFieldType()));
                col.setNullable(rs.getInt("NULLABLE") == 1);
                col.setDefaultValue(rs.getString("COLUMN_DEF"));
                columns.add(col);
            }

            // 主键
            ResultSet pkRs = meta.getPrimaryKeys(catalog, null, tableName);
            while (pkRs.next()) {
                String pk = pkRs.getString("COLUMN_NAME");
                columns.stream()
                        .filter(c -> c.getColumnName().equals(pk))
                        .findFirst()
                        .ifPresent(c -> c.setPrimaryKey(true));
            }
        }
        return columns;
    }

    private String toJavaType(String sqlType) {
        return switch (sqlType.toUpperCase()) {
            case "INT", "INTEGER", "SMALLINT", "TINYINT" -> "Integer";
            case "BIGINT" -> "Long";
            case "DECIMAL", "NUMERIC" -> "BigDecimal";
            case "FLOAT", "DOUBLE", "REAL" -> "Double";
            case "DATE", "TIME", "DATETIME", "TIMESTAMP" -> "LocalDateTime";
            case "VARCHAR", "CHAR", "TEXT", "MEDIUMTEXT", "LONGTEXT" -> "String";
            case "BIT", "BOOLEAN" -> "Boolean";
            case "BLOB", "MEDIUMBLOB", "LONGBLOB" -> "byte[]";
            default -> "String";
        };
    }

    private String toTsType(String javaType) {
        return switch (javaType) {
            case "Integer", "Long", "Double", "BigDecimal" -> "number";
            case "Boolean" -> "boolean";
            case "LocalDateTime", "LocalDate", "LocalTime" -> "string";
            default -> "string";
        };
    }

    private String toCamel(String name, boolean upperFirst) {
        String[] parts = name.split("_");
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < parts.length; i++) {
            String p = parts[i];
            if (i == 0 && !upperFirst) {
                sb.append(p.toLowerCase());
            } else {
                sb.append(p.substring(0, 1).toUpperCase()).append(p.substring(1).toLowerCase());
            }
        }
        return sb.toString();
    }
}

3.4 Velocity 模板

velocity
## Entity.java.vm
package ${packageName}.entity;

import com.taskflow.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import com.baomidou.mybatisplus.annotation.TableName;

@Data
@EqualsAndHashCode(callSuper = true)
@TableName("${tableName}")
public class ${className} extends BaseEntity {

#foreach($column in $columns)
#if(!$column.primaryKey)
    /** ${column.columnComment} */
    private ${column.fieldType} ${column.fieldName};

#end
#end
}
velocity
## Mapper.java.vm
package ${packageName}.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import ${packageName}.entity.${className};
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface ${className}Mapper extends BaseMapper<${className}> {
}
velocity
## Service.java.vm
package ${packageName}.service;

import com.baomidou.mybatisplus.extension.service.IService;
import ${packageName}.entity.${className};

public interface ${className}Service extends IService<${className}> {
}
velocity
## ServiceImpl.java.vm
package ${packageName}.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import ${packageName}.entity.${className};
import ${packageName}.mapper.${className}Mapper;
import ${packageName}.service.${className}Service;
import org.springframework.stereotype.Service;

@Service
public class ${className}ServiceImpl
        extends ServiceImpl<${className}Mapper, ${className}>
        implements ${className}Service {
}
velocity
## Controller.java.vm
package ${packageName}.controller;

import com.taskflow.common.api.Result;
import com.taskflow.common.api.PageResult;
import ${packageName}.entity.${className};
import ${packageName}.service.${className}Service;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/${classNameLower}")
@RequiredArgsConstructor
@Tag(name = "${tableComment}管理")
public class ${className}Controller {

    private final ${className}Service service;

    @GetMapping("/page")
    @Operation(summary = "分页")
    public Result<PageResult<${className}>> page(
            @RequestParam(defaultValue = "1") Long current,
            @RequestParam(defaultValue = "10") Long size) {
        return Result.ok(PageResult.of(service.page()));
    }

    @GetMapping("/{id}")
    public Result<${className}> getById(@PathVariable Long id) {
        return Result.ok(service.getById(id));
    }

    @PostMapping
    public Result<${className}> create(@RequestBody ${className} entity) {
        service.save(entity);
        return Result.ok(entity);
    }

    @PutMapping("/{id}")
    public Result<Void> update(@PathVariable Long id, @RequestBody ${className} entity) {
        entity.setId(id);
        service.updateById(entity);
        return Result.ok();
    }

    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        service.removeById(id);
        return Result.ok();
    }
}

3.5 代码生成执行

java
@Service
@RequiredArgsConstructor
public class CodeGenerator {

    private final GenTableService genTableService;

    private static final String BASE_PATH = System.getProperty("user.dir") + "/src/main/java/";

    public void generate(String tableName, String moduleName, String author)
            throws Exception {
        GenTable table = genTableService.listTables().stream()
                .filter(t -> t.getTableName().equals(tableName))
                .findFirst().orElseThrow();
        table.setModuleName(moduleName);
        table.setBusinessName(tableName.replace("sys_", ""));

        Map<String, Object> params = new HashMap<>();
        params.put("packageName", "com.taskflow.modules." + moduleName);
        params.put("tableName", table.getTableName());
        params.put("tableComment", table.getTableComment());
        params.put("className", table.getClassName());
        params.put("classNameLower", table.getClassNameLower());
        params.put("columns", table.getColumns());
        params.put("author", author);
        params.put("datetime", LocalDateTime.now().format(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));

        // 加载模板
        Properties props = new Properties();
        props.put("resource.loaders", "class");
        props.put("resource.loader.class.class",
                "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
        Velocity.init(props);

        // 渲染模板
        generateFile("templates/Entity.java.vm",
                BASE_PATH + params.get("packageName") + "/entity/" + table.getClassName() + ".java",
                params);
        generateFile("templates/Mapper.java.vm",
                BASE_PATH + params.get("packageName") + "/mapper/" + table.getClassName() + "Mapper.java",
                params);
        generateFile("templates/Service.java.vm",
                BASE_PATH + params.get("packageName") + "/service/" + table.getClassName() + "Service.java",
                params);
        generateFile("templates/ServiceImpl.java.vm",
                BASE_PATH + params.get("packageName") + "/service/impl/" + table.getClassName() + "ServiceImpl.java",
                params);
        generateFile("templates/Controller.java.vm",
                BASE_PATH + params.get("packageName") + "/controller/" + table.getClassName() + "Controller.java",
                params);
    }

    private void generateFile(String template, String outputPath, Map<String, Object> params)
            throws Exception {
        File file = new File(outputPath);
        file.getParentFile().mkdirs();

        Template tpl = Velocity.getTemplate(template, "UTF-8");
        StringWriter writer = new StringWriter();
        tpl.merge(new VelocityContext(params), writer);

        try (FileWriter fw = new FileWriter(file)) {
            fw.write(writer.toString());
        }
        System.out.println("生成文件:" + outputPath);
    }
}

3.6 Controller

java
@RestController
@RequestMapping("/api/gen")
@RequiredArgsConstructor
@Tag(name = "代码生成")
public class GenController {

    private final GenTableService genTableService;
    private final CodeGenerator codeGenerator;

    @GetMapping("/tables")
    @RequiresPermission("gen:view")
    public Result<List<GenTable>> tables() throws SQLException {
        return Result.ok(genTableService.listTables());
    }

    @PostMapping("/generate")
    @RequiresPermission("gen:execute")
    public Result<Void> generate(@RequestBody GenConfig config) {
        try {
            codeGenerator.generate(config.getTableName(),
                    config.getModuleName(), config.getAuthor());
            return Result.ok();
        } catch (Exception e) {
            throw new BusinessException("代码生成失败:" + e.getMessage());
        }
    }

    @PostMapping("/download")
    @RequiresPermission("gen:execute")
    public void download(@RequestBody GenConfig config, HttpServletResponse resp)
            throws IOException {
        // 打包 zip 下载
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (ZipOutputStream zos = new ZipOutputStream(baos)) {
            // 略:将生成的文件打包
        }
        resp.setContentType("application/zip");
        resp.setHeader("Content-Disposition",
                "attachment; filename=" + config.getTableName() + ".zip");
        resp.getOutputStream().write(baos.toByteArray());
    }
}

3.7 前端生成页面

vue
<!-- GenTable.vue -->
<template>
    <el-card>
        <template #header>
            <span>代码生成</span>
        </template>
        <el-form :model="query" inline>
            <el-form-item label="表名">
                <el-input v-model="query.tableName" placeholder="模糊搜索" clearable />
            </el-form-item>
            <el-button @click="loadTables">刷新</el-button>
        </el-form>

        <el-table :data="tables" @selection-change="onSelect">
            <el-table-column type="selection" width="50" />
            <el-table-column prop="tableName" label="表名" />
            <el-table-column prop="tableComment" label="说明" />
            <el-table-column prop="className" label="类名" />
            <el-table-column label="操作">
                <template #default="{ row }">
                    <el-button @click="preview(row)">预览</el-button>
                    <el-button type="primary" @click="generate(row)">生成</el-button>
                </template>
            </el-table-column>
        </el-table>
    </el-card>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'

const tables = ref([])
const selected = ref([])

const loadTables = async () => {
    const { data } = await api.listGenTables()
    tables.value = data
}

const generate = async (row) => {
    await ElMessageBox.confirm(`确定生成 ${row.tableName} 的代码?`, '提示')
    await api.generateCode({
        tableName: row.tableName,
        moduleName: 'business',
        author: 'TaskFlow'
    })
    ElMessage.success('生成成功')
}

onMounted(loadTables)
</script>

四、本章小结

要点关键
操作日志@OperationLog + AOP 切面 + 异步保存
登录日志登录成功/失败审计
登录保护Redis 计数 + 失败锁定
代码生成DatabaseMetaData 读取表结构
模板引擎Velocity 渲染 Java 代码
工作流选择表 → 生成 5 个文件 → 一键 CRUD

动手练习

练习 1:基础题

实现操作日志:在 @OperationLog 注解 + 切面,将用户操作保存到数据库。

练习 2:进阶题

实现登录失败锁定:Redis 计数 + 15 分钟自动解锁。

练习 3:思考题

代码生成器如何支持多数据库(MySQL / PostgreSQL / Oracle)?如何支持前后端分离(生成 Vue 页面)?


下一章第 90 章:测试与部署

本站基于 VitePress 构建 · 由 Codebook 团队维护