第 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 章:测试与部署 →