feat: initialize project

This commit is contained in:
ikmkj
2026-03-15 18:25:58 +08:00
commit 2200009c8c
151 changed files with 8685 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.test.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

View File

@@ -0,0 +1,165 @@
package com.test.demo.aspect;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.demo.bo.LoginBo;
import com.test.demo.common.annotation.Log;
import com.test.demo.entity.OperLogEntity;
import com.test.demo.entity.UserEntity;
import com.test.demo.log.event.OperLogEvent;
import com.test.demo.security.SecurityUtil;
import com.test.demo.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.LocalDateTime;
/**
* 操作日志切面
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class LogAspect {
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
private final UserService userService;
@Around("@annotation(logAnnotation)")
public Object around(ProceedingJoinPoint point, Log logAnnotation) throws Throwable {
long startTime = System.currentTimeMillis();
OperLogEntity operLog = new OperLogEntity();
operLog.setTitle(logAnnotation.value());
operLog.setLogType(logAnnotation.type().name());
// 请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
operLog.setRequestUrl(request.getRequestURI());
operLog.setRequestMethod(request.getMethod());
operLog.setIp(getClientIp(request));
}
// 方法信息
MethodSignature signature = (MethodSignature) point.getSignature();
operLog.setMethod(signature.getDeclaringTypeName() + "." + signature.getName());
// 请求参数(脱敏)
try {
String params = objectMapper.writeValueAsString(point.getArgs());
operLog.setRequestParam(truncate(params, 2000));
} catch (Exception e) {
operLog.setRequestParam("参数序列化失败");
}
// 用户信息
try {
Long userId = SecurityUtil.getUserIdOrNull();
operLog.setUserId(userId);
if (userId != null) {
UserEntity user = userService.getById(userId);
if (user != null) {
operLog.setUserName(user.getUsername());
}
}
} catch (Exception ignored) {}
Object result = null;
try {
result = point.proceed();
operLog.setStatus(1);
// 返回结果(脱敏)
try {
String resultStr = objectMapper.writeValueAsString(result);
operLog.setResponseResult(truncate(resultStr, 2000));
} catch (Exception ignored) {}
} catch (Throwable e) {
operLog.setStatus(0);
operLog.setErrorMsg(truncate(e.getMessage(), 2000));
throw e;
} finally {
if (operLog.getUserId() == null) {
Long userId = SecurityUtil.getUserIdOrNull();
if (userId != null) {
operLog.setUserId(userId);
UserEntity user = userService.getById(userId);
if (user != null) {
operLog.setUserName(user.getUsername());
}
}
}
if (operLog.getUserName() == null) {
operLog.setUserName(extractUsername(point.getArgs()));
}
operLog.setCostTime(System.currentTimeMillis() - startTime);
operLog.setCreateTime(LocalDateTime.now());
// 日志输出
switch (logAnnotation.level()) {
case DEBUG -> log.debug("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
case WARN -> log.warn("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
case ERROR -> log.error("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
default -> log.info("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
}
// 异步写入数据库
if (logAnnotation.saveToDB()) {
eventPublisher.publishEvent(new OperLogEvent(this, operLog));
}
}
return result;
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
private String truncate(String str, int maxLen) {
if (str == null) return null;
return str.length() > maxLen ? str.substring(0, maxLen) : str;
}
private String extractUsername(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg instanceof LoginBo loginBo) {
return loginBo.getUsername();
}
if (arg == null) {
continue;
}
try {
var method = arg.getClass().getMethod("getUsername");
Object username = method.invoke(arg);
if (username != null) {
return String.valueOf(username);
}
} catch (Exception ignored) {}
}
return null;
}
}

View File

@@ -0,0 +1,77 @@
package com.test.demo.aspect;
import com.test.demo.common.annotation.RateLimit;
import com.test.demo.common.constant.RedisKeyConstant;
import com.test.demo.common.exception.BizException;
import com.test.demo.common.result.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Collections;
import java.util.List;
/**
* 限流切面Redis 滑动窗口)
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final RedisTemplate<String, Object> redisTemplate;
private static final String LUA_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('EXPIRE', key, window)
return 1
end
return 0
""";
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable {
String key = buildKey(point, rateLimit);
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
List<String> keys = Collections.singletonList(key);
Long result = redisTemplate.execute(script, keys,
(long) rateLimit.count(), (long) rateLimit.time(), System.currentTimeMillis());
if (result == null || result == 0L) {
log.warn("限流触发: key={}", key);
throw new BizException(ResultCode.RATE_LIMIT);
}
return point.proceed();
}
private String buildKey(ProceedingJoinPoint point, RateLimit rateLimit) {
if (!rateLimit.key().isEmpty()) {
return RedisKeyConstant.RATE_LIMIT + rateLimit.key();
}
MethodSignature signature = (MethodSignature) point.getSignature();
String methodName = signature.getDeclaringTypeName() + "." + signature.getName();
String ip = "";
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
ip = attributes.getRequest().getRemoteAddr();
}
return RedisKeyConstant.RATE_LIMIT + ip + ":" + methodName;
}
}

View File

@@ -0,0 +1,49 @@
package com.test.demo.bo;
import com.test.demo.common.validate.ValidGroup;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 枚举/字典项请求参数
*/
@Data
@Schema(name = "EnumItemBo", description = "枚举/字典项请求参数")
public class EnumItemBo {
@NotNull(groups = ValidGroup.Update.class, message = "字典项ID不能为空")
@Schema(description = "字典项ID", example = "2033103240752513025")
private Long id;
@NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "枚举编码不能为空")
@Schema(description = "枚举编码", example = "LogType")
private String enumCode;
@NotBlank(groups = ValidGroup.Add.class, message = "字典值不能为空")
@Schema(description = "字典值", example = "LOGIN")
private String itemValue;
@NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "显示文本不能为空")
@Schema(description = "显示文本", example = "登录日志")
private String itemLabel;
@Schema(description = "系统枚举常量名", example = "LOGIN")
private String itemName;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "标签颜色", example = "#409EFF")
private String color;
@Schema(description = "标签类型", example = "success")
private String tagType;
@Schema(description = "备注", example = "系统默认日志类型")
private String remark;
}

View File

@@ -0,0 +1,41 @@
package com.test.demo.bo;
import com.test.demo.common.validate.ValidGroup;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 枚举/字典类型请求参数
*/
@Data
@Schema(name = "EnumTypeBo", description = "枚举/字典类型请求参数")
public class EnumTypeBo {
@NotNull(groups = ValidGroup.Update.class, message = "类型ID不能为空")
@Schema(description = "类型ID", example = "2033103240660238338")
private Long id;
@NotBlank(groups = ValidGroup.Add.class, message = "枚举编码不能为空")
@Schema(description = "枚举编码", example = "LogType")
private String enumCode;
@Schema(description = "枚举名称", example = "日志类型")
private String enumName;
@Schema(description = "枚举描述", example = "日志类型枚举")
private String enumDesc;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "是否允许系统同步更新", example = "1")
private Integer syncEnabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "备注", example = "系统内置日志类型")
private String remark;
}

View File

@@ -0,0 +1,27 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 枚举/字典类型查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "EnumTypeQueryBo", description = "枚举/字典类型查询参数")
public class EnumTypeQueryBo extends BaseBo {
@Schema(description = "枚举编码", example = "LogType")
private String enumCode;
@Schema(description = "枚举名称/描述关键字", example = "日志")
private String keyword;
@Schema(description = "来源类型", example = "SYSTEM", allowableValues = {"SYSTEM", "CUSTOM"})
private String sourceType;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
}

View File

@@ -0,0 +1,21 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 文件查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "FileQueryBo", description = "文件查询参数")
public class FileQueryBo extends BaseBo {
@Schema(description = "相对目录", example = "2026/03/15")
private String relativeDir;
@Schema(description = "是否递归查询子目录", example = "false", defaultValue = "false")
private Boolean recursive = false;
}

View File

@@ -0,0 +1,29 @@
package com.test.demo.bo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录请求参数
*/
@Data
@Schema(name = "LoginBo", description = "登录请求参数")
public class LoginBo {
@NotBlank(message = "用户名不能为空")
@Schema(description = "用户名", example = "admin")
private String username;
@NotBlank(message = "密码不能为空")
@Schema(description = "密码", example = "admin123")
private String password;
@NotBlank(message = "验证码Key不能为空")
@Schema(description = "验证码Key", example = "8f3b8fd4d2cf4d1ca6a4b8cf7ab33f9d")
private String captchaKey;
@NotBlank(message = "验证码不能为空")
@Schema(description = "验证码", example = "a8k3")
private String captchaCode;
}

View File

@@ -0,0 +1,24 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 登录日志查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "LoginLogQueryBo", description = "登录日志查询参数")
public class LoginLogQueryBo extends BaseBo {
@Schema(description = "用户名", example = "admin")
private String username;
@Schema(description = "日志类型", example = "LOGIN", allowableValues = {"LOGIN", "LOGOUT"})
private String logType;
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
}

View File

@@ -0,0 +1,54 @@
package com.test.demo.bo;
import com.test.demo.common.validate.ValidGroup;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 菜单请求参数
*/
@Data
@Schema(name = "MenuBo", description = "菜单请求参数")
public class MenuBo {
@NotNull(groups = ValidGroup.Update.class, message = "菜单ID不能为空")
@Schema(description = "菜单ID", example = "1")
private Long id;
@Schema(description = "父级ID", example = "0")
private Long parentId;
@NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "菜单类型不能为空")
@Schema(description = "菜单类型", example = "MENU", allowableValues = {"MENU", "BUTTON"})
private String menuType;
@NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "菜单名称不能为空")
@Schema(description = "菜单名称", example = "用户管理")
private String menuName;
@Schema(description = "路由路径", example = "/user")
private String path;
@Schema(description = "组件路径", example = "system/user/index")
private String component;
@Schema(description = "权限标识", example = "system:user:page")
private String permission;
@Schema(description = "图标", example = "User")
private String icon;
@Schema(description = "是否显示", example = "1")
private Integer visible;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,27 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 操作日志查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "OperLogQueryBo", description = "操作日志查询参数")
public class OperLogQueryBo extends BaseBo {
@Schema(description = "操作说明", example = "新增用户")
private String title;
@Schema(description = "日志类型", example = "OPER")
private String logType;
@Schema(description = "操作用户名", example = "admin")
private String userName;
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
}

View File

@@ -0,0 +1,43 @@
package com.test.demo.bo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 注册请求参数
*/
@Data
@Schema(name = "RegisterBo", description = "注册请求参数")
public class RegisterBo {
@NotBlank(message = "用户名不能为空")
@Schema(description = "用户名", example = "zhangsan")
private String username;
@NotBlank(message = "密码不能为空")
@Schema(description = "密码", example = "123456")
private String password;
@Schema(description = "昵称", example = "张三")
private String nickname;
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱", example = "zhangsan@example.com")
private String email;
@Schema(description = "手机号", example = "13800138000")
private String phone;
@Schema(description = "头像路径", example = "/avatar/default.png")
private String avatar;
@NotBlank(message = "验证码Key不能为空")
@Schema(description = "验证码Key", example = "8f3b8fd4d2cf4d1ca6a4b8cf7ab33f9d")
private String captchaKey;
@NotBlank(message = "验证码不能为空")
@Schema(description = "验证码", example = "a8k3")
private String captchaCode;
}

View File

@@ -0,0 +1,36 @@
package com.test.demo.bo;
import com.test.demo.common.validate.ValidGroup;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 角色请求参数
*/
@Data
@Schema(name = "RoleBo", description = "角色请求参数")
public class RoleBo {
@NotNull(groups = ValidGroup.Update.class, message = "角色ID不能为空")
@Schema(description = "角色ID", example = "1")
private Long id;
@NotBlank(groups = ValidGroup.Add.class, message = "角色编码不能为空")
@Schema(description = "角色编码", example = "ADMIN")
private String roleCode;
@NotBlank(groups = {ValidGroup.Add.class, ValidGroup.Update.class}, message = "角色名称不能为空")
@Schema(description = "角色名称", example = "系统管理员")
private String roleName;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,22 @@
package com.test.demo.bo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 角色菜单分配参数
*/
@Data
@Schema(name = "RoleMenuAssignBo", description = "角色菜单分配参数")
public class RoleMenuAssignBo {
@NotNull(message = "角色ID不能为空")
@Schema(description = "角色ID", example = "1")
private Long roleId;
@Schema(description = "菜单ID列表")
private List<Long> menuIds;
}

View File

@@ -0,0 +1,21 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "RoleQueryBo", description = "角色查询参数")
public class RoleQueryBo extends BaseBo {
@Schema(description = "角色编码/名称关键字", example = "管理员")
private String keyword;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
}

View File

@@ -0,0 +1,24 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 定时任务查询参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "TaskQueryBo", description = "定时任务查询参数")
public class TaskQueryBo extends BaseBo {
@Schema(description = "任务名称", example = "清理")
private String taskName;
@Schema(description = "Bean名称", example = "fileService")
private String beanName;
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
}

View File

@@ -0,0 +1,44 @@
package com.test.demo.bo;
import com.test.demo.common.validate.ValidGroup;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 用户新增/编辑 Bo
*/
@Data
@Schema(name = "UserBo", description = "用户请求参数")
public class UserBo {
@NotNull(groups = ValidGroup.Update.class, message = "用户ID不能为空")
@Schema(description = "用户ID编辑时必填", example = "1")
private Long id;
@NotBlank(groups = ValidGroup.Add.class, message = "用户名不能为空")
@Schema(description = "用户名", example = "admin")
private String username;
@NotBlank(groups = ValidGroup.Add.class, message = "密码不能为空")
@Schema(description = "密码", example = "admin123")
private String password;
@Schema(description = "昵称", example = "管理员")
private String nickname;
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱", example = "admin@example.com")
private String email;
@Schema(description = "手机号", example = "13800138000")
private String phone;
@Schema(description = "头像路径", example = "/avatar/admin.png")
private String avatar;
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
}

View File

@@ -0,0 +1,24 @@
package com.test.demo.bo;
import com.test.demo.common.base.BaseBo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户查询 Bo
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(name = "UserQueryBo", description = "用户查询参数")
public class UserQueryBo extends BaseBo {
@Schema(description = "用户名(模糊查询)", example = "admin")
private String username;
@Schema(description = "手机号", example = "13800138000")
private String phone;
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
}

View File

@@ -0,0 +1,22 @@
package com.test.demo.bo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 用户角色分配参数
*/
@Data
@Schema(name = "UserRoleAssignBo", description = "用户角色分配参数")
public class UserRoleAssignBo {
@NotNull(message = "用户ID不能为空")
@Schema(description = "用户ID", example = "1")
private Long userId;
@Schema(description = "角色ID列表")
private List<Long> roleIds;
}

View File

@@ -0,0 +1,23 @@
package com.test.demo.common.annotation;
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.test.demo.serializer.DesensitizeSerializer;
import java.lang.annotation.*;
/**
* 数据脱敏注解(标注在 VO 字段上)
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@JacksonAnnotationsInside
@JsonSerialize(using = DesensitizeSerializer.class)
public @interface Desensitize {
/**
* 脱敏类型
*/
DesensitizeType type();
}

View File

@@ -0,0 +1,19 @@
package com.test.demo.common.annotation;
/**
* 脱敏类型枚举
*/
public enum DesensitizeType {
/** 手机号 */
PHONE,
/** 身份证 */
ID_CARD,
/** 邮箱 */
EMAIL,
/** 银行卡 */
BANK_CARD,
/** 姓名 */
NAME,
/** 地址 */
ADDRESS
}

View File

@@ -0,0 +1,18 @@
package com.test.demo.common.annotation;
import java.lang.annotation.*;
/**
* 接口幂等性注解(用于插入类接口)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/** 幂等有效期(秒) */
int expireTime() default 5;
/** 提示信息 */
String message() default "请勿重复提交";
}

View File

@@ -0,0 +1,30 @@
package com.test.demo.common.annotation;
import com.test.demo.enums.LogType;
import java.lang.annotation.*;
/**
* 操作日志注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/** 操作说明(与 Knife4j @Operation summary 保持一致) */
String value() default "";
/** 日志类型 */
LogType type() default LogType.OPER;
/** 是否写入数据库 */
boolean saveToDB() default false;
/** 日志等级 */
LogLevel level() default LogLevel.INFO;
enum LogLevel {
DEBUG, INFO, WARN, ERROR
}
}

View File

@@ -0,0 +1,21 @@
package com.test.demo.common.annotation;
import java.lang.annotation.*;
/**
* 限流注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/** 限流次数 */
int count() default 10;
/** 时间窗口(秒) */
int time() default 60;
/** 限流 key默认 IP+方法名) */
String key() default "";
}

View File

@@ -0,0 +1,29 @@
package com.test.demo.common.base;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* Bo 基类(分页参数)
*/
@Data
@Schema(description = "分页请求基类")
public class BaseBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Min(value = 1, message = "页码最小为1")
@Schema(description = "页码", example = "1", defaultValue = "1")
private Integer pageNum = 1;
@Min(value = 1, message = "每页条数最小为1")
@Max(value = 500, message = "每页条数最大为500")
@Schema(description = "每页条数", example = "10", defaultValue = "10")
private Integer pageSize = 10;
}

View File

@@ -0,0 +1,36 @@
package com.test.demo.common.base;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* Entity 基类
*/
@Data
@Schema(description = "实体基类")
public abstract class BaseEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID", example = "2033103240660238338")
private Long id;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "更新时间")
private LocalDateTime updateTime;
@TableLogic
@Schema(description = "逻辑删除标记", example = "0")
private Integer deleted;
}

View File

@@ -0,0 +1,49 @@
package com.test.demo.common.base;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* 分页返回封装
*/
@Data
@Schema(description = "分页响应结果")
public class PageResult<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "当前页数据列表")
private List<T> list;
@Schema(description = "总记录数", example = "100")
private Long total;
@Schema(description = "当前页码", example = "1")
private Integer pageNum;
@Schema(description = "每页条数", example = "10")
private Integer pageSize;
@Schema(description = "总页数", example = "10")
private Integer totalPages;
public PageResult() {}
public PageResult(List<T> list, Long total, Integer pageNum, Integer pageSize) {
this.list = list;
this.total = total;
this.pageNum = pageNum;
this.pageSize = pageSize;
this.totalPages = (int) Math.ceil((double) total / pageSize);
}
public static <T> PageResult<T> of(List<T> list, Long total, Integer pageNum, Integer pageSize) {
return new PageResult<>(list, total, pageNum, pageSize);
}
public static <T> PageResult<T> empty(Integer pageNum, Integer pageSize) {
return new PageResult<>(Collections.emptyList(), 0L, pageNum, pageSize);
}
}

View File

@@ -0,0 +1,35 @@
package com.test.demo.common.constant;
/**
* 通用常量
*/
public final class CommonConstant {
private CommonConstant() {}
/** 成功标记 */
public static final int SUCCESS = 200;
/** 失败标记 */
public static final int FAIL = 500;
/** UTF-8 编码 */
public static final String UTF8 = "UTF-8";
/** 默认日期格式 */
public static final String DATE_FORMAT = "yyyy-MM-dd";
/** 默认日期时间格式 */
public static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 未知用户 */
public static final String UNKNOWN_USER = "unknown";
/** 签名请求头 */
public static final String HEADER_TIMESTAMP = "X-Timestamp";
public static final String HEADER_NONCE = "X-Nonce";
public static final String HEADER_SIGN = "X-Sign";
/** 幂等性请求头 */
public static final String HEADER_IDEMPOTENT_TOKEN = "X-Idempotent-Token";
}

View File

@@ -0,0 +1,13 @@
package com.test.demo.common.constant;
/**
* 枚举/字典来源类型
*/
public final class EnumSourceType {
private EnumSourceType() {}
public static final String SYSTEM = "SYSTEM";
public static final String CUSTOM = "CUSTOM";
}

View File

@@ -0,0 +1,61 @@
package com.test.demo.common.constant;
/**
* 权限标识常量
*/
public final class PermissionConstant {
private PermissionConstant() {}
public static final String USER_PAGE = "system:user:page";
public static final String USER_DETAIL = "system:user:detail";
public static final String USER_ADD = "system:user:add";
public static final String USER_EDIT = "system:user:edit";
public static final String USER_REMOVE = "system:user:remove";
public static final String USER_RESET_PASSWORD = "system:user:reset-password";
public static final String USER_IMPORT = "system:user:import";
public static final String USER_EXPORT = "system:user:export";
public static final String ROLE_PAGE = "system:role:page";
public static final String ROLE_LIST = "system:role:list";
public static final String ROLE_ADD = "system:role:add";
public static final String ROLE_EDIT = "system:role:edit";
public static final String ROLE_REMOVE = "system:role:remove";
public static final String ROLE_ASSIGN_MENU = "system:role:assign-menu";
public static final String ROLE_ASSIGN_USER = "system:role:assign-user";
public static final String MENU_LIST = "system:menu:list";
public static final String MENU_ADD = "system:menu:add";
public static final String MENU_EDIT = "system:menu:edit";
public static final String MENU_REMOVE = "system:menu:remove";
public static final String MENU_CURRENT = "system:menu:current";
public static final String ENUM_TYPE_PAGE = "system:enum:type:page";
public static final String ENUM_TYPE_ADD = "system:enum:type:add";
public static final String ENUM_TYPE_EDIT = "system:enum:type:edit";
public static final String ENUM_TYPE_ENABLE = "system:enum:type:enable";
public static final String ENUM_TYPE_REMOVE = "system:enum:type:remove";
public static final String ENUM_ITEM_LIST = "system:enum:item:list";
public static final String ENUM_ITEM_ADD = "system:enum:item:add";
public static final String ENUM_ITEM_EDIT = "system:enum:item:edit";
public static final String ENUM_ITEM_ENABLE = "system:enum:item:enable";
public static final String ENUM_ITEM_REMOVE = "system:enum:item:remove";
public static final String TASK_PAGE = "system:task:page";
public static final String TASK_DETAIL = "system:task:detail";
public static final String TASK_ADD = "system:task:add";
public static final String TASK_EDIT = "system:task:edit";
public static final String TASK_PAUSE = "system:task:pause";
public static final String TASK_RESUME = "system:task:resume";
public static final String TASK_REMOVE = "system:task:remove";
public static final String FILE_LIST = "system:file:list";
public static final String FILE_UPLOAD = "system:file:upload";
public static final String FILE_DOWNLOAD = "system:file:download";
public static final String FILE_DELETE = "system:file:delete";
public static final String LOGIN_LOG_PAGE = "system:login-log:page";
public static final String LOGIN_LOG_EXPORT = "system:login-log:export";
public static final String OPER_LOG_PAGE = "system:oper-log:page";
public static final String OPER_LOG_EXPORT = "system:oper-log:export";
}

View File

@@ -0,0 +1,36 @@
package com.test.demo.common.constant;
/**
* Redis Key 常量
*/
public final class RedisKeyConstant {
private RedisKeyConstant() {}
/** 前缀 */
private static final String PREFIX = "demo:";
/** 验证码 */
public static final String CAPTCHA = PREFIX + "captcha:";
/** 限流 */
public static final String RATE_LIMIT = PREFIX + "rate_limit:";
/** 幂等性 */
public static final String IDEMPOTENT = PREFIX + "idempotent:";
/** 防重放 nonce */
public static final String NONCE = PREFIX + "nonce:";
/** 分布式锁 */
public static final String LOCK = PREFIX + "lock:";
/** 用户权限缓存 */
public static final String USER_PERMISSION = PREFIX + "user:permission:";
/** 用户角色缓存 */
public static final String USER_ROLE = PREFIX + "user:role:";
/** 登录失败次数 */
public static final String LOGIN_FAIL = PREFIX + "login:fail:";
}

View File

@@ -0,0 +1,15 @@
package com.test.demo.common.enums;
import java.lang.annotation.*;
/**
* 枚举描述注解 - 标记在枚举类上,提供枚举说明信息
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnumDesc {
/** 枚举说明 */
String value() default "";
}

View File

@@ -0,0 +1,52 @@
package com.test.demo.common.enums;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
import java.io.IOException;
/**
* 枚举 Jackson 反序列化器 - 按 value 匹配枚举
*/
public class EnumDeserializer extends JsonDeserializer<IBaseEnum<?>> implements ContextualDeserializer {
private Class<?> enumClass;
public EnumDeserializer() {}
public EnumDeserializer(Class<?> enumClass) {
this.enumClass = enumClass;
}
@Override
@SuppressWarnings({"unchecked", "rawtypes"})
public IBaseEnum<?> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
if (enumClass == null) {
return null;
}
String text = p.getText();
for (Object constant : enumClass.getEnumConstants()) {
IBaseEnum baseEnum = (IBaseEnum) constant;
if (String.valueOf(baseEnum.getValue()).equals(text)) {
return baseEnum;
}
}
return null;
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) {
JavaType type = ctxt.getContextualType();
if (type == null && property != null) {
type = property.getType();
}
if (type != null) {
Class<?> rawClass = type.getRawClass();
if (IBaseEnum.class.isAssignableFrom(rawClass)) {
return new EnumDeserializer(rawClass);
}
}
return this;
}
}

View File

@@ -0,0 +1,134 @@
package com.test.demo.common.enums;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.test.demo.common.constant.EnumSourceType;
import com.test.demo.entity.EnumItemEntity;
import com.test.demo.entity.EnumTypeEntity;
import com.test.demo.service.EnumItemService;
import com.test.demo.service.EnumTypeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 系统枚举注册中心 - 启动时扫描并同步到数据库
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EnumRegistry implements ApplicationRunner {
private final EnumTypeService enumTypeService;
private final EnumItemService enumItemService;
@Override
public void run(ApplicationArguments args) throws Exception {
syncEnums("com.test.demo");
}
private void syncEnums(String basePackage) throws Exception {
String pattern = "classpath*:" + basePackage.replace('.', '/') + "/**/enums/**/*.class";
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
CachingMetadataReaderFactory factory = new CachingMetadataReaderFactory(resolver);
Resource[] resources = resolver.getResources(pattern);
int count = 0;
for (Resource resource : resources) {
MetadataReader reader = factory.getMetadataReader(resource);
String className = reader.getClassMetadata().getClassName();
try {
Class<?> clazz = Class.forName(className);
if (clazz.isEnum() && IBaseEnum.class.isAssignableFrom(clazz)) {
EnumDesc desc = clazz.getAnnotation(EnumDesc.class);
if (desc != null) {
syncEnum(clazz, desc);
count++;
}
}
} catch (ClassNotFoundException e) {
log.warn("枚举类加载失败: {}", className);
}
}
log.info("系统枚举同步完成, 共处理 {} 个带注解枚举", count);
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Transactional(rollbackFor = Exception.class)
protected void syncEnum(Class<?> clazz, EnumDesc desc) {
String enumCode = clazz.getSimpleName();
EnumTypeEntity existingType = enumTypeService.getByEnumCode(enumCode);
LocalDateTime now = LocalDateTime.now();
if (existingType != null && EnumSourceType.CUSTOM.equals(existingType.getSourceType())) {
log.warn("检测到自定义字典与系统枚举编码冲突, 已跳过同步: {}", enumCode);
return;
}
if (existingType == null) {
existingType = new EnumTypeEntity();
existingType.setEnumCode(enumCode);
existingType.setSourceType(EnumSourceType.SYSTEM);
existingType.setBuiltin(1);
existingType.setEnabled(1);
existingType.setSyncEnabled(1);
existingType.setSort(0);
} else if (existingType.getSyncEnabled() != null && existingType.getSyncEnabled() == 0) {
log.info("系统枚举已关闭同步更新, 跳过: {}", enumCode);
return;
}
existingType.setEnumName(existingType.getEnumName() == null ? enumCode : existingType.getEnumName());
existingType.setEnumDesc(desc.value());
existingType.setClassName(clazz.getName());
existingType.setPackageName(clazz.getPackageName());
Object[] constants = clazz.getEnumConstants();
Object firstValue = constants.length == 0 ? null : ((IBaseEnum) constants[0]).getValue();
existingType.setValueType(firstValue == null ? "java.lang.Object" : firstValue.getClass().getName());
existingType.setLastSyncTime(now);
if (existingType.getId() == null) {
enumTypeService.save(existingType);
} else {
enumTypeService.updateById(existingType);
}
List<EnumItemEntity> systemItems = new ArrayList<>();
int sort = 0;
for (Object constant : clazz.getEnumConstants()) {
IBaseEnum baseEnum = (IBaseEnum) constant;
EnumItemEntity item = new EnumItemEntity();
item.setEnumCode(enumCode);
item.setItemValue(String.valueOf(baseEnum.getValue()));
item.setItemLabel(baseEnum.getDesc());
item.setItemName(((Enum<?>) constant).name());
item.setSourceType(EnumSourceType.SYSTEM);
item.setBuiltin(1);
item.setEnabled(1);
item.setEditable(1);
item.setDeletable(0);
item.setSort(sort++);
item.setLastSyncTime(now);
systemItems.add(item);
}
enumItemService.syncSystemItems(enumCode, systemItems, now);
long staleCount = enumItemService.count(new LambdaQueryWrapper<EnumItemEntity>()
.eq(EnumItemEntity::getEnumCode, enumCode)
.eq(EnumItemEntity::getSourceType, EnumSourceType.SYSTEM)
.eq(EnumItemEntity::getEnabled, 0));
if (staleCount > 0) {
log.info("系统枚举 {} 存在 {} 个失效项,已自动置为停用", enumCode, staleCount);
}
}
}

View File

@@ -0,0 +1,22 @@
package com.test.demo.common.enums;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
/**
* 枚举 Jackson 序列化器 - 输出 {value, desc}
*/
public class EnumSerializer extends JsonSerializer<IBaseEnum<?>> {
@Override
public void serialize(IBaseEnum<?> value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStartObject();
gen.writeFieldName("value");
gen.writeObject(value.getValue());
gen.writeStringField("desc", value.getDesc());
gen.writeEndObject();
}
}

View File

@@ -0,0 +1,13 @@
package com.test.demo.common.enums;
/**
* 枚举基础接口 - 所有业务枚举需实现此接口
*/
public interface IBaseEnum<T> {
/** 获取枚举值(存入数据库) */
T getValue();
/** 获取枚举描述 */
String getDesc();
}

View File

@@ -0,0 +1,53 @@
package com.test.demo.common.exception;
import com.test.demo.common.result.ResultCode;
import lombok.Getter;
import java.io.Serial;
/**
* 自定义业务异常
*/
@Getter
public class BizException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
private final int code;
public BizException(String msg) {
super(msg);
this.code = ResultCode.INTERNAL_ERROR.getCode();
}
public BizException(int code, String msg) {
super(msg);
this.code = code;
}
public BizException(ResultCode resultCode) {
super(resultCode.getMsg());
this.code = resultCode.getCode();
}
public BizException(ResultCode resultCode, String msg) {
super(msg);
this.code = resultCode.getCode();
}
/**
* 快捷抛出异常
*/
public static void throwEx(String msg) {
throw new BizException(msg);
}
public static void throwEx(ResultCode resultCode) {
throw new BizException(resultCode);
}
public static void throwEx(int code, String msg) {
throw new BizException(code, msg);
}
}

View File

@@ -0,0 +1,127 @@
package com.test.demo.common.exception;
import com.test.demo.common.result.R;
import com.test.demo.common.result.ResultCode;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<R<Void>> handleBizException(BizException e) {
log.warn("业务异常: {}", e.getMessage());
return ResponseEntity.status(resolveStatus(e.getCode()))
.body(R.fail(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<R<Void>> handleValidException(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", msg);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(R.fail(ResultCode.BAD_REQUEST, msg));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<R<Void>> handleConstraintViolation(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
log.warn("约束校验失败: {}", msg);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(R.fail(ResultCode.BAD_REQUEST, msg));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<R<Void>> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("缺少请求参数: {}", e.getParameterName());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(R.fail(ResultCode.BAD_REQUEST, "缺少参数: " + e.getParameterName()));
}
@ExceptionHandler(NotLoginException.class)
public ResponseEntity<R<Void>> handleNotLogin(NotLoginException e) {
log.warn("未登录访问: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(R.fail(ResultCode.UNAUTHORIZED));
}
@ExceptionHandler(NotPermissionException.class)
public ResponseEntity<R<Void>> handleNotPermission(NotPermissionException e) {
log.warn("无权限: {}", e.getPermission());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(R.fail(ResultCode.FORBIDDEN));
}
@ExceptionHandler(NotRoleException.class)
public ResponseEntity<R<Void>> handleNotRole(NotRoleException e) {
log.warn("无角色: {}", e.getRole());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(R.fail(ResultCode.FORBIDDEN));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<R<Void>> handleMaxUploadSize(MaxUploadSizeExceededException e) {
log.warn("上传文件过大: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(R.fail(ResultCode.BAD_REQUEST, "上传文件大小超出限制"));
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<R<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(R.fail(ResultCode.METHOD_NOT_ALLOWED));
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<R<Void>> handleNoResource(NoResourceFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(R.fail(ResultCode.NOT_FOUND));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<R<Void>> handleException(Exception e) {
log.error("系统异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(R.fail(ResultCode.INTERNAL_ERROR));
}
private HttpStatus resolveStatus(int code) {
return switch (code) {
case 400 -> HttpStatus.BAD_REQUEST;
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
case 404 -> HttpStatus.NOT_FOUND;
case 405 -> HttpStatus.METHOD_NOT_ALLOWED;
case 409 -> HttpStatus.CONFLICT;
case 415 -> HttpStatus.UNSUPPORTED_MEDIA_TYPE;
case 423 -> HttpStatus.LOCKED;
case 429 -> HttpStatus.TOO_MANY_REQUESTS;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
}
}

View File

@@ -0,0 +1,63 @@
package com.test.demo.common.result;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 统一返回结果
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class R<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private int code;
private boolean success;
private String msg;
private T data;
private LocalDateTime timestamp;
private R() {}
private R(int code, String msg, T data) {
this.code = code;
this.success = code == ResultCode.SUCCESS.getCode();
this.msg = msg;
this.data = data;
this.timestamp = LocalDateTime.now();
}
public static <T> R<T> ok() {
return new R<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), null);
}
public static <T> R<T> ok(T data) {
return new R<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), data);
}
public static <T> R<T> ok(T data, String msg) {
return new R<>(ResultCode.SUCCESS.getCode(), msg, data);
}
public static <T> R<T> fail(String msg) {
return new R<>(ResultCode.INTERNAL_ERROR.getCode(), msg, null);
}
public static <T> R<T> fail(ResultCode resultCode) {
return new R<>(resultCode.getCode(), resultCode.getMsg(), null);
}
public static <T> R<T> fail(ResultCode resultCode, String msg) {
return new R<>(resultCode.getCode(), msg, null);
}
public static <T> R<T> fail(int code, String msg) {
return new R<>(code, msg, null);
}
}

View File

@@ -0,0 +1,30 @@
package com.test.demo.common.result;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 返回码枚举
*/
@Getter
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或登录已过期"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "资源不存在"),
CONFLICT(409, "资源冲突"),
METHOD_NOT_ALLOWED(405, "请求方法不允许"),
UNSUPPORTED_MEDIA_TYPE(415, "不支持的资源类型"),
ACCOUNT_LOCKED(423, "账号已锁定"),
RATE_LIMIT(429, "请求过于频繁"),
INTERNAL_ERROR(500, "系统内部错误"),
IDEMPOTENT_REJECT(601, "请勿重复提交"),
SIGNATURE_INVALID(602, "签名校验失败"),
REPLAY_ATTACK(603, "请求已过期或重复");
private final int code;
private final String msg;
}

View File

@@ -0,0 +1,19 @@
package com.test.demo.common.validate;
/**
* 参数校验分组
*/
public interface ValidGroup {
/** 新增 */
interface Add {}
/** 修改 */
interface Update {}
/** 查询 */
interface Query {}
/** 删除 */
interface Delete {}
}

View File

@@ -0,0 +1,204 @@
package com.test.demo.config;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.entity.MenuEntity;
import com.test.demo.entity.RoleEntity;
import com.test.demo.entity.RoleMenuEntity;
import com.test.demo.entity.UserEntity;
import com.test.demo.entity.UserRoleEntity;
import com.test.demo.mapper.MenuMapper;
import com.test.demo.mapper.RoleMapper;
import com.test.demo.mapper.RoleMenuMapper;
import com.test.demo.mapper.UserRoleMapper;
import com.test.demo.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
* 权限基础数据初始化
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthDataInitializer implements ApplicationRunner {
private final RoleMapper roleMapper;
private final MenuMapper menuMapper;
private final UserRoleMapper userRoleMapper;
private final RoleMenuMapper roleMenuMapper;
private final UserMapper userMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void run(ApplicationArguments args) {
RoleEntity adminRole = ensureAdminRole();
List<Long> menuIds = ensureMenus();
bindRoleMenus(adminRole.getId(), menuIds);
bindAdminUser(adminRole.getId());
}
private RoleEntity ensureAdminRole() {
RoleEntity role = roleMapper.selectOne(new LambdaQueryWrapper<RoleEntity>()
.eq(RoleEntity::getRoleCode, "ADMIN")
.last("limit 1"));
if (role != null) {
return role;
}
role = new RoleEntity();
role.setRoleCode("ADMIN");
role.setRoleName("系统管理员");
role.setEnabled(1);
role.setSort(0);
role.setRemark("系统初始化管理员角色");
roleMapper.insert(role);
return role;
}
private List<Long> ensureMenus() {
List<Long> ids = new ArrayList<>();
Long userRoot = ensureMenu(0L, "MENU", "用户管理", "/user", "system/user/index", null, "User", 1, 1, 10, "用户管理菜单");
ids.add(userRoot);
ids.add(ensureMenu(userRoot, "BUTTON", "用户分页", null, null, PermissionConstant.USER_PAGE, null, 1, 1, 1, null));
ids.add(ensureMenu(userRoot, "BUTTON", "用户详情", null, null, PermissionConstant.USER_DETAIL, null, 1, 1, 2, null));
ids.add(ensureMenu(userRoot, "BUTTON", "新增用户", null, null, PermissionConstant.USER_ADD, null, 1, 1, 3, null));
ids.add(ensureMenu(userRoot, "BUTTON", "编辑用户", null, null, PermissionConstant.USER_EDIT, null, 1, 1, 4, null));
ids.add(ensureMenu(userRoot, "BUTTON", "删除用户", null, null, PermissionConstant.USER_REMOVE, null, 1, 1, 5, null));
ids.add(ensureMenu(userRoot, "BUTTON", "重置密码", null, null, PermissionConstant.USER_RESET_PASSWORD, null, 1, 1, 6, null));
ids.add(ensureMenu(userRoot, "BUTTON", "导入用户", null, null, PermissionConstant.USER_IMPORT, null, 1, 1, 7, null));
ids.add(ensureMenu(userRoot, "BUTTON", "导出用户", null, null, PermissionConstant.USER_EXPORT, null, 1, 1, 8, null));
Long roleRoot = ensureMenu(0L, "MENU", "角色管理", "/role", "system/role/index", null, "Lock", 1, 1, 20, "角色管理菜单");
ids.add(roleRoot);
ids.add(ensureMenu(roleRoot, "BUTTON", "角色分页", null, null, PermissionConstant.ROLE_PAGE, null, 1, 1, 1, null));
ids.add(ensureMenu(roleRoot, "BUTTON", "角色列表", null, null, PermissionConstant.ROLE_LIST, null, 1, 1, 2, null));
ids.add(ensureMenu(roleRoot, "BUTTON", "新增角色", null, null, PermissionConstant.ROLE_ADD, null, 1, 1, 3, null));
ids.add(ensureMenu(roleRoot, "BUTTON", "编辑角色", null, null, PermissionConstant.ROLE_EDIT, null, 1, 1, 4, null));
ids.add(ensureMenu(roleRoot, "BUTTON", "删除角色", null, null, PermissionConstant.ROLE_REMOVE, null, 1, 1, 5, null));
ids.add(ensureMenu(roleRoot, "BUTTON", "分配菜单", null, null, PermissionConstant.ROLE_ASSIGN_MENU, null, 1, 1, 6, null));
ids.add(ensureMenu(roleRoot, "BUTTON", "分配用户", null, null, PermissionConstant.ROLE_ASSIGN_USER, null, 1, 1, 7, null));
Long menuRoot = ensureMenu(0L, "MENU", "菜单管理", "/menu", "system/menu/index", null, "Menu", 1, 1, 30, "菜单权限管理");
ids.add(menuRoot);
ids.add(ensureMenu(menuRoot, "BUTTON", "菜单列表", null, null, PermissionConstant.MENU_LIST, null, 1, 1, 1, null));
ids.add(ensureMenu(menuRoot, "BUTTON", "新增菜单", null, null, PermissionConstant.MENU_ADD, null, 1, 1, 2, null));
ids.add(ensureMenu(menuRoot, "BUTTON", "编辑菜单", null, null, PermissionConstant.MENU_EDIT, null, 1, 1, 3, null));
ids.add(ensureMenu(menuRoot, "BUTTON", "删除菜单", null, null, PermissionConstant.MENU_REMOVE, null, 1, 1, 4, null));
ids.add(ensureMenu(menuRoot, "BUTTON", "当前菜单", null, null, PermissionConstant.MENU_CURRENT, null, 1, 1, 5, null));
Long enumRoot = ensureMenu(0L, "MENU", "字典管理", "/enum-manage", "system/enum/index", null, "List", 1, 1, 40, "字典管理");
ids.add(enumRoot);
ids.add(ensureMenu(enumRoot, "BUTTON", "类型分页", null, null, PermissionConstant.ENUM_TYPE_PAGE, null, 1, 1, 1, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "新增类型", null, null, PermissionConstant.ENUM_TYPE_ADD, null, 1, 1, 2, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "编辑类型", null, null, PermissionConstant.ENUM_TYPE_EDIT, null, 1, 1, 3, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "启停类型", null, null, PermissionConstant.ENUM_TYPE_ENABLE, null, 1, 1, 4, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "删除类型", null, null, PermissionConstant.ENUM_TYPE_REMOVE, null, 1, 1, 5, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "枚举项列表", null, null, PermissionConstant.ENUM_ITEM_LIST, null, 1, 1, 6, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "新增枚举项", null, null, PermissionConstant.ENUM_ITEM_ADD, null, 1, 1, 7, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "编辑枚举项", null, null, PermissionConstant.ENUM_ITEM_EDIT, null, 1, 1, 8, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "启停枚举项", null, null, PermissionConstant.ENUM_ITEM_ENABLE, null, 1, 1, 9, null));
ids.add(ensureMenu(enumRoot, "BUTTON", "删除枚举项", null, null, PermissionConstant.ENUM_ITEM_REMOVE, null, 1, 1, 10, null));
Long taskRoot = ensureMenu(0L, "MENU", "定时任务", "/task", "system/task/index", null, "Clock", 1, 1, 50, "定时任务管理");
ids.add(taskRoot);
ids.add(ensureMenu(taskRoot, "BUTTON", "任务分页", null, null, PermissionConstant.TASK_PAGE, null, 1, 1, 1, null));
ids.add(ensureMenu(taskRoot, "BUTTON", "任务详情", null, null, PermissionConstant.TASK_DETAIL, null, 1, 1, 2, null));
ids.add(ensureMenu(taskRoot, "BUTTON", "新增任务", null, null, PermissionConstant.TASK_ADD, null, 1, 1, 3, null));
ids.add(ensureMenu(taskRoot, "BUTTON", "编辑任务", null, null, PermissionConstant.TASK_EDIT, null, 1, 1, 4, null));
ids.add(ensureMenu(taskRoot, "BUTTON", "暂停任务", null, null, PermissionConstant.TASK_PAUSE, null, 1, 1, 5, null));
ids.add(ensureMenu(taskRoot, "BUTTON", "恢复任务", null, null, PermissionConstant.TASK_RESUME, null, 1, 1, 6, null));
ids.add(ensureMenu(taskRoot, "BUTTON", "删除任务", null, null, PermissionConstant.TASK_REMOVE, null, 1, 1, 7, null));
Long fileRoot = ensureMenu(0L, "MENU", "文件管理", "/file", "system/file/index", null, "Folder", 1, 1, 60, "文件管理");
ids.add(fileRoot);
ids.add(ensureMenu(fileRoot, "BUTTON", "文件列表", null, null, PermissionConstant.FILE_LIST, null, 1, 1, 1, null));
ids.add(ensureMenu(fileRoot, "BUTTON", "上传文件", null, null, PermissionConstant.FILE_UPLOAD, null, 1, 1, 2, null));
ids.add(ensureMenu(fileRoot, "BUTTON", "下载文件", null, null, PermissionConstant.FILE_DOWNLOAD, null, 1, 1, 3, null));
ids.add(ensureMenu(fileRoot, "BUTTON", "删除文件", null, null, PermissionConstant.FILE_DELETE, null, 1, 1, 4, null));
Long logRoot = ensureMenu(0L, "MENU", "日志管理", "/log", "system/log/index", null, "Document", 1, 1, 70, "日志管理");
ids.add(logRoot);
ids.add(ensureMenu(logRoot, "BUTTON", "登录日志分页", null, null, PermissionConstant.LOGIN_LOG_PAGE, null, 1, 1, 1, null));
ids.add(ensureMenu(logRoot, "BUTTON", "登录日志导出", null, null, PermissionConstant.LOGIN_LOG_EXPORT, null, 1, 1, 2, null));
ids.add(ensureMenu(logRoot, "BUTTON", "操作日志分页", null, null, PermissionConstant.OPER_LOG_PAGE, null, 1, 1, 3, null));
ids.add(ensureMenu(logRoot, "BUTTON", "操作日志导出", null, null, PermissionConstant.OPER_LOG_EXPORT, null, 1, 1, 4, null));
return ids;
}
private Long ensureMenu(Long parentId, String menuType, String menuName, String path, String component,
String permission, String icon, Integer visible, Integer enabled, Integer sort, String remark) {
MenuEntity existing = menuMapper.selectOne(new LambdaQueryWrapper<MenuEntity>()
.eq(MenuEntity::getParentId, parentId)
.eq(MenuEntity::getMenuName, menuName)
.last("limit 1"));
if (existing != null) {
existing.setMenuType(menuType);
existing.setPath(path);
existing.setComponent(component);
existing.setPermission(permission);
existing.setIcon(icon);
existing.setVisible(visible);
existing.setEnabled(enabled);
existing.setSort(sort);
existing.setRemark(remark);
menuMapper.updateById(existing);
return existing.getId();
}
MenuEntity entity = new MenuEntity();
entity.setParentId(parentId);
entity.setMenuType(menuType);
entity.setMenuName(menuName);
entity.setPath(path);
entity.setComponent(component);
entity.setPermission(permission);
entity.setIcon(icon);
entity.setVisible(visible);
entity.setEnabled(enabled);
entity.setSort(sort);
entity.setRemark(remark);
menuMapper.insert(entity);
return entity.getId();
}
private void bindRoleMenus(Long roleId, List<Long> menuIds) {
for (Long menuId : menuIds) {
Long count = roleMenuMapper.selectCount(new LambdaQueryWrapper<RoleMenuEntity>()
.eq(RoleMenuEntity::getRoleId, roleId)
.eq(RoleMenuEntity::getMenuId, menuId));
if (count == 0) {
RoleMenuEntity entity = new RoleMenuEntity();
entity.setRoleId(roleId);
entity.setMenuId(menuId);
roleMenuMapper.insert(entity);
}
}
}
private void bindAdminUser(Long roleId) {
UserEntity admin = userMapper.selectOne(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getUsername, "admin")
.last("limit 1"));
if (admin == null) {
log.warn("未找到 admin 用户,已跳过管理员角色绑定");
return;
}
Long count = userRoleMapper.selectCount(new LambdaQueryWrapper<UserRoleEntity>()
.eq(UserRoleEntity::getUserId, admin.getId())
.eq(UserRoleEntity::getRoleId, roleId));
if (count == 0) {
UserRoleEntity entity = new UserRoleEntity();
entity.setUserId(admin.getId());
entity.setRoleId(roleId);
userRoleMapper.insert(entity);
}
}
}

View File

@@ -0,0 +1,29 @@
package com.test.demo.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
/**
* Caffeine 本地缓存配置
*/
@EnableCaching
@Configuration
public class CaffeineCacheConfig {
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(128)
.maximumSize(1024)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats());
return cacheManager;
}
}

View File

@@ -0,0 +1,27 @@
package com.test.demo.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 文件上传配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "app.file")
public class FileConfig {
/** 上传根路径 */
private String uploadPath = "/data/upload";
/** 允许的文件扩展名 */
private List<String> allowedExtensions = List.of(
"jpg", "jpeg", "png", "gif", "bmp", "webp",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"txt", "csv", "zip", "rar", "7z",
"mp4", "avi", "mov", "mp3", "wav"
);
}

View File

@@ -0,0 +1,46 @@
package com.test.demo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* Jackson 全局配置
*/
@Configuration
public class JacksonConfig {
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
// Long → String 防前端精度丢失
builder.serializerByType(Long.class, ToStringSerializer.instance);
builder.serializerByType(Long.TYPE, ToStringSerializer.instance);
// LocalDate 序列化/反序列化
builder.serializerByType(LocalDate.class,
new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
builder.deserializerByType(LocalDate.class,
new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
// LocalDateTime 序列化/反序列化
builder.serializerByType(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT)));
builder.deserializerByType(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT)));
};
}
}

View File

@@ -0,0 +1,24 @@
package com.test.demo.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Knife4j / OpenAPI 配置
*/
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Demo 后台管理系统 API")
.description("Spring Boot 4 脚手架接口文档")
.version("1.0.0")
.contact(new Contact().name("admin").email("admin@example.com")));
}
}

View File

@@ -0,0 +1,27 @@
package com.test.demo.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(500L);
interceptor.addInnerInterceptor(paginationInterceptor);
// 防全表更新与删除
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
}

View File

@@ -0,0 +1,31 @@
package com.test.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 序列化配置
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -0,0 +1,13 @@
package com.test.demo.config;
import org.springframework.context.annotation.Configuration;
/**
* Sa-Token 配置
*/
@Configuration
public class SaTokenConfig {
// 如需 JWT 模式,需引入 sa-token-jwt 依赖后启用以下配置
// 默认使用 Redis Session 模式
}

View File

@@ -0,0 +1,25 @@
package com.test.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
/**
* 定时任务调度配置
*/
@EnableScheduling
@Configuration
public class ScheduleConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("schedule-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}

View File

@@ -0,0 +1,32 @@
package com.test.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 线程池配置
*/
@EnableAsync
@Configuration
public class ThreadPoolConfig {
@Bean("asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2);
executor.setQueueCapacity(500);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,65 @@
package com.test.demo.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.stp.StpUtil;
import com.test.demo.interceptor.IdempotentInterceptor;
import com.test.demo.interceptor.SignatureInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置(跨域 + 拦截器)
*/
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final SignatureInterceptor signatureInterceptor;
private final IdempotentInterceptor idempotentInterceptor;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(signatureInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/doc.html",
"/webjars/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/actuator/**"
);
registry.addInterceptor(idempotentInterceptor)
.addPathPatterns("/**");
// Sa-Token 路由拦截
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login",
"/auth/register",
"/captcha/**",
"/file/preview/**",
"/enums/**",
"/doc.html",
"/webjars/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/actuator/**"
);
}
}

View File

@@ -0,0 +1,30 @@
package com.test.demo.config;
import com.test.demo.filter.XssFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* XSS 过滤器注册
*/
@Configuration
public class XssFilterConfig {
@Bean
public FilterRegistrationBean<XssFilter> xssFilterRegistration() {
FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/*");
registration.setName("xssFilter");
registration.setOrder(1);
// 排除路径(如富文本接口)
Map<String, String> initParams = new HashMap<>();
initParams.put("excludes", "/file/upload");
registration.setInitParameters(initParams);
return registration;
}
}

View File

@@ -0,0 +1,51 @@
package com.test.demo.controller;
import com.test.demo.bo.LoginBo;
import com.test.demo.bo.RegisterBo;
import com.test.demo.common.result.R;
import com.test.demo.service.AuthService;
import com.test.demo.vo.LoginVo;
import com.test.demo.vo.UserVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 认证接口
*/
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Tag(name = "认证管理")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
@Operation(summary = "用户登录")
public R<LoginVo> login(@RequestBody @Valid LoginBo bo) {
return R.ok(authService.login(bo));
}
@PostMapping("/register")
@Operation(summary = "用户注册")
public R<Void> register(@RequestBody @Valid RegisterBo bo) {
authService.register(bo);
return R.ok();
}
@PostMapping("/logout")
@Operation(summary = "退出登录")
public R<Void> logout() {
authService.logout();
return R.ok();
}
@PostMapping("/me")
@Operation(summary = "获取当前登录用户")
public R<UserVo> me() {
return R.ok(authService.getCurrentUser());
}
}

View File

@@ -0,0 +1,30 @@
package com.test.demo.controller;
import com.test.demo.common.result.R;
import com.test.demo.service.CaptchaService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 验证码接口
*/
@RestController
@RequestMapping("/captcha")
@RequiredArgsConstructor
@Tag(name = "验证码")
public class CaptchaController {
private final CaptchaService captchaService;
@PostMapping("/generate")
@Operation(summary = "获取图形验证码")
public R<Map<String, String>> getCaptcha() {
return R.ok(captchaService.generate());
}
}

View File

@@ -0,0 +1,43 @@
package com.test.demo.controller;
import com.test.demo.common.result.R;
import com.test.demo.service.EnumItemService;
import com.test.demo.service.EnumTypeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 枚举查询接口(供前端下拉框使用)
*/
@RestController
@RequestMapping("/enums")
@RequiredArgsConstructor
@Tag(name = "枚举查询")
public class EnumController {
private final EnumTypeService enumTypeService;
private final EnumItemService enumItemService;
@PostMapping("/list")
@Operation(summary = "获取所有带描述注解的枚举元信息")
public R<List<Map<String, Object>>> listAll() {
return R.ok(enumTypeService.listEnabledForApi());
}
@PostMapping("/items/{enumName}")
@Operation(summary = "获取指定枚举的所有值")
public R<List<Map<String, Object>>> getByName(
@Parameter(description = "枚举编码", example = "LogType")
@PathVariable String enumName) {
return R.ok(enumItemService.listEnabledForApi(enumName));
}
}

View File

@@ -0,0 +1,149 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.bo.EnumItemBo;
import com.test.demo.bo.EnumTypeBo;
import com.test.demo.bo.EnumTypeQueryBo;
import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.Idempotent;
import com.test.demo.common.base.PageResult;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.common.validate.ValidGroup;
import com.test.demo.entity.EnumItemEntity;
import com.test.demo.entity.EnumTypeEntity;
import com.test.demo.enums.LogType;
import com.test.demo.service.EnumItemService;
import com.test.demo.service.EnumTypeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 枚举/字典管理接口
*/
@RestController
@RequestMapping("/enum-manage")
@RequiredArgsConstructor
@Tag(name = "枚举字典管理")
public class EnumManageController {
private final EnumTypeService enumTypeService;
private final EnumItemService enumItemService;
@PostMapping("/type/page")
@Operation(summary = "分页查询枚举类型")
@SaCheckPermission(PermissionConstant.ENUM_TYPE_PAGE)
public R<PageResult<EnumTypeEntity>> pageTypes(@RequestBody(required = false) EnumTypeQueryBo query) {
if (query == null) {
query = new EnumTypeQueryBo();
}
return R.ok(enumTypeService.page(query));
}
@PostMapping("/type/add")
@Operation(summary = "新增自定义枚举类型")
@SaCheckPermission(PermissionConstant.ENUM_TYPE_ADD)
@Idempotent(message = "请勿重复新增枚举类型")
@Log(value = "新增自定义枚举类型", type = LogType.OPER, saveToDB = true)
public R<Void> addType(@RequestBody @Validated(ValidGroup.Add.class) EnumTypeBo bo) {
enumTypeService.add(bo);
return R.ok();
}
@PostMapping("/type/edit")
@Operation(summary = "编辑枚举类型")
@SaCheckPermission(PermissionConstant.ENUM_TYPE_EDIT)
@Idempotent(message = "请勿重复编辑枚举类型")
@Log(value = "编辑枚举类型", type = LogType.OPER, saveToDB = true)
public R<Void> editType(@RequestBody @Validated(ValidGroup.Update.class) EnumTypeBo bo) {
enumTypeService.edit(bo);
return R.ok();
}
@PostMapping("/type/enable/{id}")
@Operation(summary = "启用或停用枚举类型")
@SaCheckPermission(PermissionConstant.ENUM_TYPE_ENABLE)
@Idempotent(message = "请勿重复变更枚举类型状态")
@Log(value = "变更枚举类型状态", type = LogType.OPER, saveToDB = true)
public R<Void> changeTypeEnabled(
@Parameter(description = "类型ID", example = "2033103240660238338")
@PathVariable Long id,
@Parameter(description = "启用状态1启用0停用", example = "1")
@RequestParam Integer enabled) {
enumTypeService.changeEnabled(id, enabled);
return R.ok();
}
@PostMapping("/type/remove/{id}")
@Operation(summary = "删除自定义枚举类型")
@SaCheckPermission(PermissionConstant.ENUM_TYPE_REMOVE)
@Idempotent(message = "请勿重复删除枚举类型")
@Log(value = "删除自定义枚举类型", type = LogType.OPER, saveToDB = true)
public R<Void> removeType(
@Parameter(description = "类型ID", example = "2033103240660238338")
@PathVariable Long id) {
enumTypeService.remove(id);
return R.ok();
}
@PostMapping("/item/list/{enumCode}")
@Operation(summary = "查询枚举项列表")
@SaCheckPermission(PermissionConstant.ENUM_ITEM_LIST)
public R<List<EnumItemEntity>> listItems(
@Parameter(description = "枚举编码", example = "LogType")
@PathVariable String enumCode) {
return R.ok(enumItemService.listManageItems(enumCode));
}
@PostMapping("/item/add")
@Operation(summary = "新增枚举项")
@SaCheckPermission(PermissionConstant.ENUM_ITEM_ADD)
@Idempotent(message = "请勿重复新增枚举项")
@Log(value = "新增枚举项", type = LogType.OPER, saveToDB = true)
public R<Void> addItem(@RequestBody @Validated(ValidGroup.Add.class) EnumItemBo bo) {
enumItemService.add(bo);
return R.ok();
}
@PostMapping("/item/edit")
@Operation(summary = "编辑枚举项")
@SaCheckPermission(PermissionConstant.ENUM_ITEM_EDIT)
@Idempotent(message = "请勿重复编辑枚举项")
@Log(value = "编辑枚举项", type = LogType.OPER, saveToDB = true)
public R<Void> editItem(@RequestBody @Validated(ValidGroup.Update.class) EnumItemBo bo) {
enumItemService.edit(bo);
return R.ok();
}
@PostMapping("/item/enable/{id}")
@Operation(summary = "启用或停用枚举项")
@SaCheckPermission(PermissionConstant.ENUM_ITEM_ENABLE)
@Idempotent(message = "请勿重复变更枚举项状态")
@Log(value = "变更枚举项状态", type = LogType.OPER, saveToDB = true)
public R<Void> changeItemEnabled(
@Parameter(description = "枚举项ID", example = "2033103240752513025")
@PathVariable Long id,
@Parameter(description = "启用状态1启用0停用", example = "1")
@RequestParam Integer enabled) {
enumItemService.changeEnabled(id, enabled);
return R.ok();
}
@PostMapping("/item/remove/{id}")
@Operation(summary = "删除枚举项")
@SaCheckPermission(PermissionConstant.ENUM_ITEM_REMOVE)
@Idempotent(message = "请勿重复删除枚举项")
@Log(value = "删除枚举项", type = LogType.OPER, saveToDB = true)
public R<Void> removeItem(
@Parameter(description = "枚举项ID", example = "2033103240752513025")
@PathVariable Long id) {
enumItemService.remove(id);
return R.ok();
}
}

View File

@@ -0,0 +1,122 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.bo.FileQueryBo;
import com.test.demo.common.annotation.Log;
import com.test.demo.common.base.PageResult;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.enums.LogType;
import com.test.demo.service.FileService;
import com.test.demo.vo.FileInfoVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 文件管理接口
*/
@Slf4j
@RestController
@RequestMapping("/file")
@RequiredArgsConstructor
@Tag(name = "文件管理")
public class FileController {
private final FileService fileService;
@PostMapping("/list")
@Operation(summary = "查询文件列表")
@SaCheckPermission(PermissionConstant.FILE_LIST)
public R<PageResult<FileInfoVo>> list(@RequestBody(required = false) FileQueryBo query) {
return R.ok(fileService.list(query));
}
@PostMapping("/upload")
@Operation(summary = "上传文件")
@SaCheckPermission(PermissionConstant.FILE_UPLOAD)
@Log(value = "上传文件", type = LogType.OPER)
public R<String> upload(
@Parameter(description = "上传文件")
@RequestParam("file") MultipartFile file) {
return R.ok(fileService.upload(file));
}
@GetMapping("/download/{*filePath}")
@Operation(summary = "下载文件")
@SaCheckPermission(PermissionConstant.FILE_DOWNLOAD)
public void download(
@Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx")
@PathVariable String filePath,
HttpServletResponse response) throws IOException {
String absPath = fileService.getFilePath(filePath);
Path path = Paths.get(absPath);
if (!Files.exists(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
String fileName = path.getFileName().toString();
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
response.setContentLengthLong(Files.size(path));
try (InputStream is = Files.newInputStream(path);
OutputStream os = response.getOutputStream()) {
is.transferTo(os);
}
}
@GetMapping("/preview/{*filePath}")
@Operation(summary = "预览文件")
public void preview(
@Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx")
@PathVariable String filePath,
HttpServletResponse response) throws IOException {
String absPath = fileService.getFilePath(filePath);
Path path = Paths.get(absPath);
if (!Files.exists(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
String contentType = Files.probeContentType(path);
if (contentType == null) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
response.setContentType(contentType);
response.setContentLengthLong(Files.size(path));
// 视频支持 Range 请求
if (contentType.startsWith("video/")) {
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
}
try (InputStream is = Files.newInputStream(path);
OutputStream os = response.getOutputStream()) {
is.transferTo(os);
}
}
@PostMapping("/delete/{*filePath}")
@Operation(summary = "删除文件")
@SaCheckPermission(PermissionConstant.FILE_DELETE)
@Log(value = "删除文件", type = LogType.OPER, saveToDB = true)
public R<Boolean> delete(
@Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx")
@PathVariable String filePath) {
return R.ok(fileService.delete(filePath));
}
}

View File

@@ -0,0 +1,79 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.bo.LoginLogQueryBo;
import com.test.demo.bo.OperLogQueryBo;
import com.test.demo.common.base.PageResult;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.entity.LoginLogEntity;
import com.test.demo.entity.OperLogEntity;
import com.test.demo.service.LoginLogService;
import com.test.demo.service.OperLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
/**
* 日志管理接口
*/
@RestController
@RequestMapping("/log")
@RequiredArgsConstructor
@Tag(name = "日志管理")
public class LogController {
private final LoginLogService loginLogService;
private final OperLogService operLogService;
@PostMapping("/login/page")
@Operation(summary = "分页查询登录日志")
@SaCheckPermission(PermissionConstant.LOGIN_LOG_PAGE)
public R<PageResult<LoginLogEntity>> loginPage(@RequestBody(required = false) LoginLogQueryBo query) {
if (query == null) {
query = new LoginLogQueryBo();
}
return R.ok(loginLogService.page(query));
}
@PostMapping("/oper/page")
@Operation(summary = "分页查询操作日志")
@SaCheckPermission(PermissionConstant.OPER_LOG_PAGE)
public R<PageResult<OperLogEntity>> operPage(@RequestBody(required = false) OperLogQueryBo query) {
if (query == null) {
query = new OperLogQueryBo();
}
return R.ok(operLogService.page(query));
}
@PostMapping("/login/export")
@Operation(summary = "导出登录日志")
@SaCheckPermission(PermissionConstant.LOGIN_LOG_EXPORT)
public void exportLogin(
@Parameter(description = "登录日志筛选条件")
@RequestBody(required = false) LoginLogQueryBo query,
HttpServletResponse response) throws IOException {
if (query == null) {
query = new LoginLogQueryBo();
}
loginLogService.export(query, response);
}
@PostMapping("/oper/export")
@Operation(summary = "导出操作日志")
@SaCheckPermission(PermissionConstant.OPER_LOG_EXPORT)
public void exportOper(
@Parameter(description = "操作日志筛选条件")
@RequestBody(required = false) OperLogQueryBo query,
HttpServletResponse response) throws IOException {
if (query == null) {
query = new OperLogQueryBo();
}
operLogService.export(query, response);
}
}

View File

@@ -0,0 +1,81 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.bo.MenuBo;
import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.Idempotent;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.common.validate.ValidGroup;
import com.test.demo.entity.MenuEntity;
import com.test.demo.enums.LogType;
import com.test.demo.security.SecurityUtil;
import com.test.demo.service.MenuService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 菜单权限管理接口
*/
@RestController
@RequestMapping("/menu")
@RequiredArgsConstructor
@Tag(name = "菜单权限管理")
public class MenuController {
private final MenuService menuService;
@PostMapping("/list")
@Operation(summary = "查询菜单权限列表")
@SaCheckPermission(PermissionConstant.MENU_LIST)
public R<List<MenuEntity>> list() {
return R.ok(menuService.listAll());
}
@PostMapping("/current")
@Operation(summary = "查询当前用户菜单")
public R<List<MenuEntity>> current() {
return R.ok(menuService.listCurrentUserMenus(SecurityUtil.getUserId()));
}
@PostMapping("/permissions/current")
@Operation(summary = "查询当前用户权限标识")
public R<List<String>> currentPermissions() {
return R.ok(menuService.listCurrentUserPermissions(SecurityUtil.getUserId()));
}
@PostMapping("/add")
@Operation(summary = "新增菜单权限")
@SaCheckPermission(PermissionConstant.MENU_ADD)
@Idempotent(message = "请勿重复新增菜单")
@Log(value = "新增菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> add(@RequestBody @Validated(ValidGroup.Add.class) MenuBo bo) {
menuService.add(bo);
return R.ok();
}
@PostMapping("/edit")
@Operation(summary = "编辑菜单权限")
@SaCheckPermission(PermissionConstant.MENU_EDIT)
@Idempotent(message = "请勿重复编辑菜单")
@Log(value = "编辑菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> edit(@RequestBody @Validated(ValidGroup.Update.class) MenuBo bo) {
menuService.edit(bo);
return R.ok();
}
@PostMapping("/remove/{id}")
@Operation(summary = "删除菜单权限")
@SaCheckPermission(PermissionConstant.MENU_REMOVE)
@Idempotent(message = "请勿重复删除菜单")
@Log(value = "删除菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> remove(@PathVariable Long id) {
menuService.remove(id);
return R.ok();
}
}

View File

@@ -0,0 +1,117 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.bo.RoleBo;
import com.test.demo.bo.RoleMenuAssignBo;
import com.test.demo.bo.RoleQueryBo;
import com.test.demo.bo.UserRoleAssignBo;
import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.Idempotent;
import com.test.demo.common.base.PageResult;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.common.validate.ValidGroup;
import com.test.demo.entity.RoleEntity;
import com.test.demo.enums.LogType;
import com.test.demo.service.RoleService;
import com.test.demo.vo.RoleDetailVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 角色管理接口
*/
@RestController
@RequestMapping("/role")
@RequiredArgsConstructor
@Tag(name = "角色管理")
public class RoleController {
private final RoleService roleService;
@PostMapping("/page")
@Operation(summary = "分页查询角色")
@SaCheckPermission(PermissionConstant.ROLE_PAGE)
public R<PageResult<RoleEntity>> page(@RequestBody(required = false) RoleQueryBo query) {
if (query == null) {
query = new RoleQueryBo();
}
return R.ok(roleService.page(query));
}
@PostMapping("/list")
@Operation(summary = "查询启用角色列表")
@SaCheckPermission(PermissionConstant.ROLE_LIST)
public R<List<RoleEntity>> list() {
return R.ok(roleService.listEnabled());
}
@PostMapping("/detail/{id}")
@Operation(summary = "查询角色详情及已分配菜单")
@SaCheckPermission(PermissionConstant.ROLE_LIST)
public R<RoleDetailVo> detail(@PathVariable Long id) {
return R.ok(roleService.getDetail(id));
}
@PostMapping("/user/{userId}/role-ids")
@Operation(summary = "查询用户已分配角色ID列表")
@SaCheckPermission(PermissionConstant.ROLE_ASSIGN_USER)
public R<List<Long>> userRoleIds(@PathVariable Long userId) {
return R.ok(roleService.listUserRoleIds(userId));
}
@PostMapping("/add")
@Operation(summary = "新增角色")
@SaCheckPermission(PermissionConstant.ROLE_ADD)
@Idempotent(message = "请勿重复新增角色")
@Log(value = "新增角色", type = LogType.OPER, saveToDB = true)
public R<Void> add(@RequestBody @Validated(ValidGroup.Add.class) RoleBo bo) {
roleService.add(bo);
return R.ok();
}
@PostMapping("/edit")
@Operation(summary = "编辑角色")
@SaCheckPermission(PermissionConstant.ROLE_EDIT)
@Idempotent(message = "请勿重复编辑角色")
@Log(value = "编辑角色", type = LogType.OPER, saveToDB = true)
public R<Void> edit(@RequestBody @Validated(ValidGroup.Update.class) RoleBo bo) {
roleService.edit(bo);
return R.ok();
}
@PostMapping("/remove/{id}")
@Operation(summary = "删除角色")
@SaCheckPermission(PermissionConstant.ROLE_REMOVE)
@Idempotent(message = "请勿重复删除角色")
@Log(value = "删除角色", type = LogType.OPER, saveToDB = true)
public R<Void> remove(@PathVariable Long id) {
roleService.remove(id);
return R.ok();
}
@PostMapping("/assign-menu")
@Operation(summary = "分配角色菜单权限")
@SaCheckPermission(PermissionConstant.ROLE_ASSIGN_MENU)
@Idempotent(message = "请勿重复分配角色菜单")
@Log(value = "分配角色菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> assignMenu(@RequestBody @Validated RoleMenuAssignBo bo) {
roleService.assignMenus(bo.getRoleId(), bo.getMenuIds());
return R.ok();
}
@PostMapping("/assign-user")
@Operation(summary = "分配用户角色")
@SaCheckPermission(PermissionConstant.ROLE_ASSIGN_USER)
@Idempotent(message = "请勿重复分配用户角色")
@Log(value = "分配用户角色", type = LogType.OPER, saveToDB = true)
public R<Void> assignUser(@RequestBody @Validated UserRoleAssignBo bo) {
roleService.assignUserRoles(bo.getUserId(), bo.getRoleIds());
return R.ok();
}
}

View File

@@ -0,0 +1,95 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.bo.TaskQueryBo;
import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.Idempotent;
import com.test.demo.common.base.PageResult;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.entity.ScheduledTaskEntity;
import com.test.demo.enums.LogType;
import com.test.demo.service.DynamicTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 定时任务管理接口
*/
@RestController
@RequestMapping("/task")
@RequiredArgsConstructor
@Tag(name = "定时任务管理")
public class TaskController {
private final DynamicTaskService dynamicTaskService;
@PostMapping("/page")
@Operation(summary = "分页查询定时任务")
@SaCheckPermission(PermissionConstant.TASK_PAGE)
public R<PageResult<ScheduledTaskEntity>> page(@RequestBody(required = false) TaskQueryBo query) {
if (query == null) {
query = new TaskQueryBo();
}
return R.ok(dynamicTaskService.page(query));
}
@PostMapping("/detail/{id}")
@Operation(summary = "获取定时任务详情")
@SaCheckPermission(PermissionConstant.TASK_DETAIL)
public R<ScheduledTaskEntity> detail(@PathVariable Long id) {
return R.ok(dynamicTaskService.detail(id));
}
@PostMapping("/add")
@Operation(summary = "创建定时任务")
@SaCheckPermission(PermissionConstant.TASK_ADD)
@Idempotent(message = "请勿重复创建定时任务")
@Log(value = "创建定时任务", type = LogType.OPER, saveToDB = true)
public R<Void> create(@RequestBody ScheduledTaskEntity task) {
dynamicTaskService.create(task);
return R.ok();
}
@PostMapping("/edit")
@Operation(summary = "更新定时任务")
@SaCheckPermission(PermissionConstant.TASK_EDIT)
@Idempotent(message = "请勿重复更新定时任务")
@Log(value = "更新定时任务", type = LogType.OPER, saveToDB = true)
public R<Void> update(@RequestBody ScheduledTaskEntity task) {
dynamicTaskService.update(task);
return R.ok();
}
@PostMapping("/pause/{id}")
@Operation(summary = "暂停定时任务")
@SaCheckPermission(PermissionConstant.TASK_PAUSE)
@Idempotent(message = "请勿重复暂停定时任务")
@Log(value = "暂停定时任务", type = LogType.OPER, saveToDB = true)
public R<Void> pause(@PathVariable Long id) {
dynamicTaskService.pause(id);
return R.ok();
}
@PostMapping("/resume/{id}")
@Operation(summary = "恢复定时任务")
@SaCheckPermission(PermissionConstant.TASK_RESUME)
@Idempotent(message = "请勿重复恢复定时任务")
@Log(value = "恢复定时任务", type = LogType.OPER, saveToDB = true)
public R<Void> resume(@PathVariable Long id) {
dynamicTaskService.resume(id);
return R.ok();
}
@PostMapping("/remove/{id}")
@Operation(summary = "删除定时任务")
@SaCheckPermission(PermissionConstant.TASK_REMOVE)
@Idempotent(message = "请勿重复删除定时任务")
@Log(value = "删除定时任务", type = LogType.OPER, saveToDB = true)
public R<Void> delete(@PathVariable Long id) {
dynamicTaskService.delete(id);
return R.ok();
}
}

View File

@@ -0,0 +1,125 @@
package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.RateLimit;
import com.test.demo.common.annotation.Idempotent;
import com.test.demo.common.base.PageResult;
import com.test.demo.common.constant.PermissionConstant;
import com.test.demo.common.result.R;
import com.test.demo.common.validate.ValidGroup;
import com.test.demo.enums.LogType;
import com.test.demo.bo.UserBo;
import com.test.demo.bo.UserQueryBo;
import com.test.demo.service.UserService;
import jakarta.servlet.http.HttpServletResponse;
import com.test.demo.vo.UserVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 用户管理接口
*/
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
@Tag(name = "用户管理")
public class UserController {
private final UserService userService;
@PostMapping("/page")
@Operation(summary = "分页查询用户")
@SaCheckPermission(PermissionConstant.USER_PAGE)
@Log(value = "分页查询用户", type = LogType.OPER)
@RateLimit(count = 20, time = 60)
public R<PageResult<UserVo>> page(@RequestBody(required = false) UserQueryBo query) {
if (query == null) {
query = new UserQueryBo();
}
return R.ok(userService.page(query));
}
@PostMapping("/detail/{id}")
@Operation(summary = "获取用户详情")
@SaCheckPermission(PermissionConstant.USER_DETAIL)
public R<UserVo> detail(
@Parameter(description = "用户ID", example = "1")
@PathVariable Long id) {
return R.ok(userService.getDetail(id));
}
@PostMapping("/add")
@Operation(summary = "新增用户")
@SaCheckPermission(PermissionConstant.USER_ADD)
@Idempotent(message = "请勿重复新增用户")
@Log(value = "新增用户", type = LogType.OPER, saveToDB = true)
public R<Void> add(@RequestBody @Validated(ValidGroup.Add.class) UserBo bo) {
userService.add(bo);
return R.ok();
}
@PostMapping("/edit")
@Operation(summary = "编辑用户")
@SaCheckPermission(PermissionConstant.USER_EDIT)
@Idempotent(message = "请勿重复编辑用户")
@Log(value = "编辑用户", type = LogType.OPER, saveToDB = true)
public R<Void> edit(@RequestBody @Validated(ValidGroup.Update.class) UserBo bo) {
userService.edit(bo);
return R.ok();
}
@PostMapping("/remove/{id}")
@Operation(summary = "删除用户")
@SaCheckPermission(PermissionConstant.USER_REMOVE)
@Idempotent(message = "请勿重复删除用户")
@Log(value = "删除用户", type = LogType.OPER, saveToDB = true)
public R<Void> remove(
@Parameter(description = "用户ID", example = "1")
@PathVariable Long id) {
userService.remove(id);
return R.ok();
}
@PostMapping("/reset-password/{id}")
@Operation(summary = "重置密码")
@SaCheckPermission(PermissionConstant.USER_RESET_PASSWORD)
@Idempotent(message = "请勿重复重置密码")
@Log(value = "重置密码", type = LogType.OPER, saveToDB = true)
public R<Void> resetPassword(
@Parameter(description = "用户ID", example = "1")
@PathVariable Long id,
@Parameter(description = "新密码", example = "123456")
@RequestParam String newPassword) {
userService.resetPassword(id, newPassword);
return R.ok();
}
@PostMapping("/import")
@Operation(summary = "导入用户")
@SaCheckPermission(PermissionConstant.USER_IMPORT)
@Idempotent(message = "请勿重复导入用户")
@Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true)
public R<Void> importUsers(
@Parameter(description = "用户导入Excel文件")
@RequestParam("file") MultipartFile file) {
userService.importUsers(file);
return R.ok();
}
@PostMapping("/export")
@Operation(summary = "导出用户")
@SaCheckPermission(PermissionConstant.USER_EXPORT)
@Log(value = "导出用户", type = LogType.EXPORT, saveToDB = true)
public void exportUsers(@RequestBody(required = false) UserQueryBo query, HttpServletResponse response) throws Exception {
if (query == null) {
query = new UserQueryBo();
}
userService.exportUsers(query, response);
}
}

View File

@@ -0,0 +1,33 @@
package com.test.demo.convert;
import com.test.demo.bo.UserBo;
import com.test.demo.entity.UserEntity;
import com.test.demo.vo.UserVo;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.factory.Mappers;
import java.util.List;
/**
* 用户对象转换器MapStruct编译时生成实现
*/
@Mapper(componentModel = "spring")
public interface UserConvert {
UserConvert INSTANCE = Mappers.getMapper(UserConvert.class);
@Mapping(target = "statusDesc", expression = "java(entity.getStatus() != null ? entity.getStatus().getDesc() : null)")
@Mapping(target = "status", expression = "java(entity.getStatus() != null ? entity.getStatus().getValue() : null)")
UserVo toVo(UserEntity entity);
List<UserVo> toVoList(List<UserEntity> list);
@Mapping(target = "id", ignore = true)
@Mapping(target = "status", ignore = true)
UserEntity toEntity(UserBo bo);
@Mapping(target = "status", ignore = true)
void updateEntity(UserBo bo, @MappingTarget UserEntity entity);
}

View File

@@ -0,0 +1,74 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 枚举/字典项实体
*/
@Data
@TableName("sys_enum_item")
@Schema(name = "EnumItemEntity", description = "枚举/字典项实体")
public class EnumItemEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID", example = "2033103240752513025")
private Long id;
@Schema(description = "枚举编码", example = "LogType")
private String enumCode;
@Schema(description = "字典值", example = "LOGIN")
private String itemValue;
@Schema(description = "显示文本", example = "登录日志")
private String itemLabel;
@Schema(description = "系统枚举常量名", example = "LOGIN")
private String itemName;
@Schema(description = "来源类型", example = "SYSTEM", allowableValues = {"SYSTEM", "CUSTOM"})
private String sourceType;
@Schema(description = "是否系统内置", example = "1")
private Integer builtin;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "是否允许后台编辑", example = "1")
private Integer editable;
@Schema(description = "是否允许后台删除", example = "0")
private Integer deletable;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "标签颜色", example = "#409EFF")
private String color;
@Schema(description = "标签类型", example = "success")
private String tagType;
@Schema(description = "备注", example = "系统默认项")
private String remark;
@Schema(description = "最近同步时间")
private LocalDateTime lastSyncTime;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,71 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 枚举/字典类型实体
*/
@Data
@TableName("sys_enum_type")
@Schema(name = "EnumTypeEntity", description = "枚举/字典类型实体")
public class EnumTypeEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID", example = "2033103240660238338")
private Long id;
@Schema(description = "枚举编码", example = "LogType")
private String enumCode;
@Schema(description = "枚举名称", example = "日志类型")
private String enumName;
@Schema(description = "枚举描述", example = "日志类型枚举")
private String enumDesc;
@Schema(description = "系统枚举完整类名", example = "com.test.demo.enums.LogType")
private String className;
@Schema(description = "包路径", example = "com.test.demo.enums")
private String packageName;
@Schema(description = "值类型", example = "java.lang.String")
private String valueType;
@Schema(description = "来源类型", example = "SYSTEM", allowableValues = {"SYSTEM", "CUSTOM"})
private String sourceType;
@Schema(description = "是否系统内置", example = "1")
private Integer builtin;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "是否允许系统同步更新", example = "1")
private Integer syncEnabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "备注", example = "系统内置枚举")
private String remark;
@Schema(description = "最近同步时间")
private LocalDateTime lastSyncTime;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,49 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 登录日志实体
*/
@Data
@TableName("sys_login_log")
@Schema(name = "LoginLogEntity", description = "登录日志实体")
public class LoginLogEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID", example = "2033103240660238338")
private Long id;
@Schema(description = "用户名", example = "admin")
private String username;
@Schema(description = "用户ID", example = "1")
private Long userId;
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
@Schema(description = "日志类型", example = "LOGIN", allowableValues = {"LOGIN", "LOGOUT"})
private String logType;
@Schema(description = "结果信息", example = "登录成功")
private String message;
@Schema(description = "登录IP", example = "127.0.0.1")
private String ip;
@Schema(description = "User-Agent")
private String userAgent;
@Schema(description = "请求URL", example = "/api/auth/login")
private String requestUrl;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,50 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.test.demo.common.base.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 菜单权限实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_menu")
@Schema(name = "MenuEntity", description = "菜单权限实体")
public class MenuEntity extends BaseEntity {
@Schema(description = "父级ID", example = "0")
private Long parentId;
@Schema(description = "菜单类型", example = "MENU", allowableValues = {"MENU", "BUTTON"})
private String menuType;
@Schema(description = "菜单名称", example = "用户管理")
private String menuName;
@Schema(description = "路由路径", example = "/user")
private String path;
@Schema(description = "组件路径", example = "system/user/index")
private String component;
@Schema(description = "权限标识", example = "system:user:page")
private String permission;
@Schema(description = "图标", example = "User")
private String icon;
@Schema(description = "是否显示", example = "1")
private Integer visible;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,77 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 操作日志实体
*/
@Data
@TableName("sys_oper_log")
@Schema(name = "OperLogEntity", description = "操作日志实体")
public class OperLogEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID", example = "2033103240660238338")
private Long id;
/** 操作说明 */
@Schema(description = "操作说明", example = "新增用户")
private String title;
/** 日志类型 */
@Schema(description = "日志类型", example = "OPER")
private String logType;
/** 请求方法(类名.方法名) */
@Schema(description = "请求方法", example = "com.test.demo.controller.UserController.add")
private String method;
/** HTTP方法 */
@Schema(description = "HTTP方法", example = "POST")
private String requestMethod;
/** 请求URL */
@Schema(description = "请求URL", example = "/api/user")
private String requestUrl;
/** 请求参数(脱敏后) */
@Schema(description = "请求参数(脱敏后)")
private String requestParam;
/** 返回结果(脱敏后) */
@Schema(description = "返回结果(脱敏后)")
private String responseResult;
/** 操作用户ID */
@Schema(description = "操作用户ID", example = "1")
private Long userId;
/** 操作用户名 */
@Schema(description = "操作用户名", example = "admin")
private String userName;
/** 操作IP */
@Schema(description = "操作IP", example = "127.0.0.1")
private String ip;
/** 操作状态(0失败 1成功) */
@Schema(description = "操作状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
/** 错误信息 */
@Schema(description = "错误信息")
private String errorMsg;
/** 耗时(ms) */
@Schema(description = "耗时(ms)", example = "35")
private Long costTime;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,32 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.test.demo.common.base.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_role")
@Schema(name = "RoleEntity", description = "角色实体")
public class RoleEntity extends BaseEntity {
@Schema(description = "角色编码", example = "ADMIN")
private String roleCode;
@Schema(description = "角色名称", example = "系统管理员")
private String roleName;
@Schema(description = "是否启用", example = "1")
private Integer enabled;
@Schema(description = "排序", example = "0")
private Integer sort;
@Schema(description = "备注", example = "默认管理员角色")
private String remark;
}

View File

@@ -0,0 +1,31 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 角色菜单关联实体
*/
@Data
@TableName("sys_role_menu")
@Schema(name = "RoleMenuEntity", description = "角色菜单关联实体")
public class RoleMenuEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "角色ID", example = "1")
private Long roleId;
@Schema(description = "菜单ID", example = "1")
private Long menuId;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,45 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.test.demo.common.base.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 定时任务实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_scheduled_task")
@Schema(name = "ScheduledTaskEntity", description = "定时任务实体")
public class ScheduledTaskEntity extends BaseEntity {
/** 任务名称 */
@Schema(description = "任务名称", example = "清理临时文件")
private String taskName;
/** cron表达式 */
@Schema(description = "Cron表达式", example = "0 0/5 * * * ?")
private String cronExpression;
/** Bean名称 */
@Schema(description = "Bean名称", example = "fileService")
private String beanName;
/** 方法名 */
@Schema(description = "方法名", example = "clearTempFiles")
private String methodName;
/** 方法参数 */
@Schema(description = "方法参数", example = "7")
private String methodParams;
/** 状态(0暂停 1运行) */
@Schema(description = "状态", example = "1", allowableValues = {"0", "1"})
private Integer status;
/** 备注 */
@Schema(description = "备注", example = "每5分钟执行一次")
private String remark;
}

View File

@@ -0,0 +1,50 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.test.demo.common.base.BaseEntity;
import com.test.demo.enums.UserStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_user")
@Schema(name = "UserEntity", description = "用户实体")
public class UserEntity extends BaseEntity {
/** 用户名 */
@Schema(description = "用户名", example = "admin")
private String username;
/** SM3加密密码 */
@Schema(description = "SM3加密密码")
private String password;
/** 密码盐 */
@Schema(description = "密码盐")
private String salt;
/** 昵称 */
@Schema(description = "昵称", example = "管理员")
private String nickname;
/** 邮箱 */
@Schema(description = "邮箱", example = "admin@example.com")
private String email;
/** 手机号 */
@Schema(description = "手机号", example = "13800138000")
private String phone;
/** 头像路径 */
@Schema(description = "头像路径", example = "/avatar/admin.png")
private String avatar;
/** 状态 */
@Schema(description = "状态枚举")
private UserStatusEnum status;
}

View File

@@ -0,0 +1,31 @@
package com.test.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户角色关联实体
*/
@Data
@TableName("sys_user_role")
@Schema(name = "UserRoleEntity", description = "用户角色关联实体")
public class UserRoleEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "用户ID", example = "1")
private Long userId;
@Schema(description = "角色ID", example = "1")
private Long roleId;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,37 @@
package com.test.demo.enums;
import com.test.demo.common.enums.EnumDesc;
import com.test.demo.common.enums.IBaseEnum;
/**
* 日志类型枚举
*/
@EnumDesc("日志类型枚举")
public enum LogType implements IBaseEnum<String> {
LOGIN("LOGIN", "登录日志"),
LOGOUT("LOGOUT", "登出日志"),
OPER("OPER", "操作日志"),
MAIL("MAIL", "邮件日志"),
EXPORT("EXPORT", "导出日志"),
IMPORT("IMPORT", "导入日志"),
OTHER("OTHER", "其他日志");
private final String value;
private final String desc;
LogType(String value, String desc) {
this.value = value;
this.desc = desc;
}
@Override
public String getValue() {
return this.value;
}
@Override
public String getDesc() {
return this.desc;
}
}

View File

@@ -0,0 +1,23 @@
package com.test.demo.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.test.demo.common.enums.EnumDesc;
import com.test.demo.common.enums.IBaseEnum;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 用户状态枚举
*/
@EnumDesc("用户状态枚举")
@Getter
@RequiredArgsConstructor
public enum UserStatusEnum implements IBaseEnum<Integer> {
DISABLED(0, "禁用"),
NORMAL(1, "正常");
@EnumValue
private final Integer value;
private final String desc;
}

View File

@@ -0,0 +1,25 @@
package com.test.demo.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 可重复读取请求体过滤器
*/
public class RepeatableReadFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String contentType = req.getContentType();
// 仅对 JSON 请求体做包装
if (contentType != null && contentType.contains("application/json")) {
chain.doFilter(new RepeatableReadRequestWrapper(req), response);
} else {
chain.doFilter(request, response);
}
}
}

View File

@@ -0,0 +1,64 @@
package com.test.demo.filter;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.*;
/**
* 可重复读取请求体的包装器(用于日志记录、签名校验等需要多次读取 body 的场景)
*/
public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public RepeatableReadRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
try (InputStream is = request.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
this.body = baos.toByteArray();
}
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return bais.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
// no-op
}
@Override
public int read() {
return bais.read();
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
public byte[] getBody() {
return body;
}
}

View File

@@ -0,0 +1,43 @@
package com.test.demo.filter;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* XSS 防护过滤器
*/
public class XssFilter implements Filter {
private List<String> excludes;
@Override
public void init(FilterConfig filterConfig) {
String excludeStr = filterConfig.getInitParameter("excludes");
if (StrUtil.isNotBlank(excludeStr)) {
excludes = Arrays.asList(excludeStr.split(","));
} else {
excludes = List.of();
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
if (isExclude(req)) {
chain.doFilter(request, response);
} else {
chain.doFilter(new XssRequestWrapper(req), response);
}
}
private boolean isExclude(HttpServletRequest request) {
String uri = request.getServletPath();
return excludes.stream().anyMatch(uri::startsWith);
}
}

View File

@@ -0,0 +1,40 @@
package com.test.demo.filter;
import cn.hutool.http.HtmlUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
/**
* XSS 请求包装器 - 对参数和请求头做 HTML 转义
*/
public class XssRequestWrapper extends HttpServletRequestWrapper {
public XssRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
return value != null ? HtmlUtil.escape(value) : null;
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
String[] escaped = new String[values.length];
for (int i = 0; i < values.length; i++) {
escaped[i] = HtmlUtil.escape(values[i]);
}
return escaped;
}
@Override
public String getHeader(String name) {
String value = super.getHeader(name);
return value != null ? HtmlUtil.escape(value) : null;
}
}

View File

@@ -0,0 +1,25 @@
package com.test.demo.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 自动填充处理器
*/
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
}
}

View File

@@ -0,0 +1,58 @@
package com.test.demo.interceptor;
import cn.hutool.crypto.digest.DigestUtil;
import com.test.demo.common.annotation.Idempotent;
import com.test.demo.common.constant.CommonConstant;
import com.test.demo.common.constant.RedisKeyConstant;
import com.test.demo.common.exception.BizException;
import com.test.demo.common.result.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.concurrent.TimeUnit;
/**
* 幂等性拦截器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class IdempotentInterceptor implements HandlerInterceptor {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
Idempotent idempotent = handlerMethod.getMethodAnnotation(Idempotent.class);
if (idempotent == null) {
return true;
}
// 构建幂等 key: token + url + 参数hash
String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN);
if (token == null) {
token = request.getHeader("Authorization");
}
String url = request.getRequestURI();
String queryString = request.getQueryString() != null ? request.getQueryString() : "";
String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.md5Hex(token + url + queryString);
Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", idempotent.expireTime(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(absent)) {
log.warn("幂等性拦截: key={}", key);
throw new BizException(ResultCode.IDEMPOTENT_REJECT.getCode(), idempotent.message());
}
return true;
}
}

View File

@@ -0,0 +1,75 @@
package com.test.demo.interceptor;
import cn.hutool.crypto.digest.DigestUtil;
import com.test.demo.common.constant.CommonConstant;
import com.test.demo.common.constant.RedisKeyConstant;
import com.test.demo.common.exception.BizException;
import com.test.demo.common.result.ResultCode;
import com.test.demo.filter.RepeatableReadRequestWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* 接口签名/防重放拦截器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SignatureInterceptor implements HandlerInterceptor {
private final RedisTemplate<String, Object> redisTemplate;
@Value("${security.sign.secret:change-me-sign-secret}")
private String signSecret;
@Value("${security.sign.expire-seconds:300}")
private long expireSeconds;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String timestamp = request.getHeader(CommonConstant.HEADER_TIMESTAMP);
String nonce = request.getHeader(CommonConstant.HEADER_NONCE);
String sign = request.getHeader(CommonConstant.HEADER_SIGN);
// 缺少签名头则跳过(可根据需求改为拒绝)
if (timestamp == null || nonce == null || sign == null) {
return true;
}
// 时间戳校验
long ts = Long.parseLong(timestamp);
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - ts) > expireSeconds) {
throw new BizException(ResultCode.REPLAY_ATTACK);
}
// nonce 防重放
String nonceKey = RedisKeyConstant.NONCE + nonce;
Boolean absent = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", expireSeconds, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(absent)) {
throw new BizException(ResultCode.REPLAY_ATTACK);
}
// 签名校验
String body = "";
if (request instanceof RepeatableReadRequestWrapper wrapper) {
body = new String(wrapper.getBody(), StandardCharsets.UTF_8);
}
String rawSign = timestamp + nonce + body + signSecret;
String expectedSign = DigestUtil.sha256Hex(rawSign);
if (!expectedSign.equalsIgnoreCase(sign)) {
throw new BizException(ResultCode.SIGNATURE_INVALID);
}
return true;
}
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.EnumItemEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 枚举/字典项 Mapper
*/
@Mapper
public interface EnumItemMapper extends BaseMapper<EnumItemEntity> {
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.EnumTypeEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 枚举/字典类型 Mapper
*/
@Mapper
public interface EnumTypeMapper extends BaseMapper<EnumTypeEntity> {
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.LoginLogEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 登录日志 Mapper
*/
@Mapper
public interface LoginLogMapper extends BaseMapper<LoginLogEntity> {
}

View File

@@ -0,0 +1,48 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.MenuEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 菜单 Mapper
*/
@Mapper
public interface MenuMapper extends BaseMapper<MenuEntity> {
@Select("""
SELECT DISTINCT m.id, m.parent_id, m.menu_type, m.menu_name, m.path, m.component, m.permission,
m.icon, m.visible, m.enabled, m.sort, m.remark, m.create_time, m.update_time, m.deleted
FROM sys_menu m
INNER JOIN sys_role_menu rm ON rm.menu_id = m.id
INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id
INNER JOIN sys_role r ON r.id = rm.role_id
WHERE ur.user_id = #{userId}
AND m.enabled = 1
AND m.visible = 1
AND m.deleted = 0
AND r.enabled = 1
AND r.deleted = 0
ORDER BY m.sort ASC, m.id ASC
""")
List<MenuEntity> selectMenusByUserId(Long userId);
@Select("""
SELECT DISTINCT m.permission
FROM sys_menu m
INNER JOIN sys_role_menu rm ON rm.menu_id = m.id
INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id
INNER JOIN sys_role r ON r.id = rm.role_id
WHERE ur.user_id = #{userId}
AND m.enabled = 1
AND m.deleted = 0
AND r.enabled = 1
AND r.deleted = 0
AND m.permission IS NOT NULL
AND m.permission <> ''
""")
List<String> selectPermissionsByUserId(Long userId);
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.OperLogEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 操作日志 Mapper
*/
@Mapper
public interface OperLogMapper extends BaseMapper<OperLogEntity> {
}

View File

@@ -0,0 +1,23 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.RoleEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 角色 Mapper
*/
@Mapper
public interface RoleMapper extends BaseMapper<RoleEntity> {
@Select("""
SELECT DISTINCT r.role_code
FROM sys_role r
INNER JOIN sys_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId} AND r.enabled = 1 AND r.deleted = 0
""")
List<String> selectRoleCodesByUserId(Long userId);
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.RoleMenuEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 角色菜单 Mapper
*/
@Mapper
public interface RoleMenuMapper extends BaseMapper<RoleMenuEntity> {
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.ScheduledTaskEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 定时任务 Mapper
*/
@Mapper
public interface ScheduledTaskMapper extends BaseMapper<ScheduledTaskEntity> {
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.UserEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户 Mapper
*/
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
}

View File

@@ -0,0 +1,12 @@
package com.test.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.demo.entity.UserRoleEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户角色 Mapper
*/
@Mapper
public interface UserRoleMapper extends BaseMapper<UserRoleEntity> {
}

View File

@@ -0,0 +1,57 @@
package com.test.demo.security;
import cn.dev33.satoken.stp.StpUtil;
/**
* 安全工具类
*/
public final class SecurityUtil {
private SecurityUtil() {}
/**
* 获取当前登录用户ID
*/
public static Long getUserId() {
return StpUtil.getLoginIdAsLong();
}
/**
* 获取当前登录用户ID未登录返回null
*/
public static Long getUserIdOrNull() {
try {
return StpUtil.getLoginIdAsLong();
} catch (Exception e) {
return null;
}
}
/**
* 判断是否已登录
*/
public static boolean isLogin() {
return StpUtil.isLogin();
}
/**
* 登录
*/
public static void login(Long userId) {
StpUtil.login(userId);
}
/**
* 登出
*/
public static void logout() {
StpUtil.logout();
}
/**
* 获取当前 Token
*/
public static String getToken() {
return StpUtil.getTokenValue();
}
}

View File

@@ -0,0 +1,34 @@
package com.test.demo.security;
import cn.dev33.satoken.stp.StpInterface;
import com.test.demo.mapper.MenuMapper;
import com.test.demo.mapper.RoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* Sa-Token 权限/角色数据加载实现
*/
@Component
@RequiredArgsConstructor
public class StpInterfaceImpl implements StpInterface {
private final RoleMapper roleMapper;
private final MenuMapper menuMapper;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
Long userId = Long.parseLong(String.valueOf(loginId));
return menuMapper.selectPermissionsByUserId(userId);
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
Long userId = Long.parseLong(String.valueOf(loginId));
List<String> roles = roleMapper.selectRoleCodesByUserId(userId);
return roles != null ? roles : Collections.emptyList();
}
}

View File

@@ -0,0 +1,47 @@
package com.test.demo.security.crypto;
import cn.hutool.core.util.HexUtil;
import cn.hutool.core.util.RandomUtil;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.nio.charset.StandardCharsets;
import java.security.Security;
/**
* SM3 国密哈希工具(密码加密)
*/
public final class SM3Util {
static {
Security.addProvider(new BouncyCastleProvider());
}
private SM3Util() {}
/**
* 生成随机盐
*/
public static String generateSalt() {
return RandomUtil.randomString(16);
}
/**
* SM3 哈希(加盐)
*/
public static String hash(String data, String salt) {
byte[] input = (salt + data).getBytes(StandardCharsets.UTF_8);
SM3Digest digest = new SM3Digest();
digest.update(input, 0, input.length);
byte[] result = new byte[digest.getDigestSize()];
digest.doFinal(result, 0);
return HexUtil.encodeHexStr(result);
}
/**
* 验证密码
*/
public static boolean verify(String data, String salt, String hash) {
return hash(data, salt).equals(hash);
}
}

View File

@@ -0,0 +1,61 @@
package com.test.demo.security.crypto;
import cn.hutool.core.util.HexUtil;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Security;
import java.util.Base64;
/**
* SM4 国密对称加解密工具
*/
public final class SM4Util {
private static final String ALGORITHM = "SM4";
private static final String TRANSFORMATION = "SM4/ECB/PKCS5Padding";
static {
Security.addProvider(new BouncyCastleProvider());
}
private SM4Util() {}
/**
* 加密(返回 Base64
* @param plainText 明文
* @param hexKey 16字节密钥的十六进制字符串32个hex字符
*/
public static String encrypt(String plainText, String hexKey) {
try {
byte[] keyBytes = HexUtil.decodeHex(hexKey);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("SM4加密失败", e);
}
}
/**
* 解密
* @param cipherText Base64 密文
* @param hexKey 16字节密钥的十六进制字符串
*/
public static String decrypt(String cipherText, String hexKey) {
try {
byte[] keyBytes = HexUtil.decodeHex(hexKey);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORMATION, "BC");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("SM4解密失败", e);
}
}
}

View File

@@ -0,0 +1,54 @@
package com.test.demo.serializer;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import com.test.demo.common.annotation.Desensitize;
import com.test.demo.common.annotation.DesensitizeType;
import com.test.demo.util.DesensitizeUtil;
import lombok.NoArgsConstructor;
import java.io.IOException;
/**
* 数据脱敏 Jackson 序列化器
*/
@NoArgsConstructor
public class DesensitizeSerializer extends JsonSerializer<String> implements ContextualSerializer {
private DesensitizeType type;
public DesensitizeSerializer(DesensitizeType type) {
this.type = type;
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
String result = switch (type) {
case PHONE -> DesensitizeUtil.phone(value);
case ID_CARD -> DesensitizeUtil.idCard(value);
case EMAIL -> DesensitizeUtil.email(value);
case BANK_CARD -> DesensitizeUtil.bankCard(value);
case NAME -> DesensitizeUtil.name(value);
case ADDRESS -> DesensitizeUtil.address(value);
};
gen.writeString(result);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
Desensitize annotation = property.getAnnotation(Desensitize.class);
if (annotation != null) {
return new DesensitizeSerializer(annotation.type());
}
}
return this;
}
}

View File

@@ -0,0 +1,20 @@
package com.test.demo.service;
import com.test.demo.bo.LoginBo;
import com.test.demo.bo.RegisterBo;
import com.test.demo.vo.LoginVo;
import com.test.demo.vo.UserVo;
/**
* 认证 Service
*/
public interface AuthService {
LoginVo login(LoginBo bo);
void register(RegisterBo bo);
void logout();
UserVo getCurrentUser();
}

View File

@@ -0,0 +1,57 @@
package com.test.demo.service;
import cn.hutool.core.lang.UUID;
import com.test.demo.common.constant.RedisKeyConstant;
import com.wf.captcha.SpecCaptcha;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 验证码服务
*/
@Service
@RequiredArgsConstructor
public class CaptchaService {
private final RedisTemplate<String, Object> redisTemplate;
/** 验证码有效期(秒) */
private static final long CAPTCHA_EXPIRE = 120;
/**
* 生成验证码
* @return {key: 缓存key, image: base64图片}
*/
public Map<String, String> generate() {
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
String code = captcha.text().toLowerCase();
String key = UUID.randomUUID().toString(true);
redisTemplate.opsForValue().set(
RedisKeyConstant.CAPTCHA + key, code, CAPTCHA_EXPIRE, TimeUnit.SECONDS);
Map<String, String> result = new HashMap<>(2);
result.put("key", key);
result.put("image", captcha.toBase64());
return result;
}
/**
* 校验验证码
*/
public boolean verify(String key, String code) {
if (key == null || code == null) {
return false;
}
String redisKey = RedisKeyConstant.CAPTCHA + key;
Object cached = redisTemplate.opsForValue().get(redisKey);
// 验证后立即删除,防止重复使用
redisTemplate.delete(redisKey);
return cached != null && cached.toString().equalsIgnoreCase(code);
}
}

Some files were not shown because too many files have changed in this diff Show More