Compare commits
4 Commits
2200009c8c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65f21dae2 | ||
|
|
60f4fce90b | ||
|
|
6ec0c3d77b | ||
|
|
7af4abea43 |
@@ -1,26 +1,39 @@
|
||||
package com.test.demo.aspect;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
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.OperLogService;
|
||||
import com.test.demo.service.UserService;
|
||||
import com.test.demo.util.DesensitizeUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
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.core.io.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 操作日志切面
|
||||
@@ -31,9 +44,17 @@ import java.time.LocalDateTime;
|
||||
@RequiredArgsConstructor
|
||||
public class LogAspect {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private static final Set<String> SENSITIVE_FIELDS = Set.of(
|
||||
"password", "newpassword", "oldpassword", "token", "authorization",
|
||||
"secret", "captcha", "captchacode", "captchakey", "accesstoken", "refreshtoken"
|
||||
);
|
||||
|
||||
private static final Set<String> EMAIL_FIELDS = Set.of("email");
|
||||
private static final Set<String> PHONE_FIELDS = Set.of("phone", "mobile", "phonenumber");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserService userService;
|
||||
private final OperLogService operLogService;
|
||||
|
||||
@Around("@annotation(logAnnotation)")
|
||||
public Object around(ProceedingJoinPoint point, Log logAnnotation) throws Throwable {
|
||||
@@ -42,7 +63,6 @@ public class LogAspect {
|
||||
operLog.setTitle(logAnnotation.value());
|
||||
operLog.setLogType(logAnnotation.type().name());
|
||||
|
||||
// 请求信息
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes != null) {
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
@@ -51,19 +71,20 @@ public class LogAspect {
|
||||
operLog.setIp(getClientIp(request));
|
||||
}
|
||||
|
||||
// 方法信息
|
||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||
operLog.setMethod(signature.getDeclaringTypeName() + "." + signature.getName());
|
||||
|
||||
// 请求参数(脱敏)
|
||||
if (logAnnotation.recordRequestParam()) {
|
||||
try {
|
||||
String params = objectMapper.writeValueAsString(point.getArgs());
|
||||
String params = objectMapper.writeValueAsString(maskMethodArguments(signature, point.getArgs()));
|
||||
operLog.setRequestParam(truncate(params, 2000));
|
||||
} catch (Exception e) {
|
||||
operLog.setRequestParam("参数序列化失败");
|
||||
}
|
||||
} else {
|
||||
operLog.setRequestParam("<omitted>");
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
try {
|
||||
Long userId = SecurityUtil.getUserIdOrNull();
|
||||
operLog.setUserId(userId);
|
||||
@@ -73,17 +94,18 @@ public class LogAspect {
|
||||
operLog.setUserName(user.getUsername());
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
operLog.setStatus(1);
|
||||
// 返回结果(脱敏)
|
||||
try {
|
||||
String resultStr = objectMapper.writeValueAsString(result);
|
||||
String resultStr = objectMapper.writeValueAsString(maskSensitiveValue(result));
|
||||
operLog.setResponseResult(truncate(resultStr, 2000));
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
operLog.setStatus(0);
|
||||
operLog.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
@@ -105,7 +127,6 @@ public class LogAspect {
|
||||
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());
|
||||
@@ -113,14 +134,139 @@ public class LogAspect {
|
||||
default -> log.info("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
|
||||
}
|
||||
|
||||
// 异步写入数据库
|
||||
if (logAnnotation.saveToDB()) {
|
||||
eventPublisher.publishEvent(new OperLogEvent(this, operLog));
|
||||
operLogService.record(operLog);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object maskSensitiveValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof String stringValue) {
|
||||
return sanitizeSimpleValue(null, stringValue);
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean || value instanceof Enum<?>) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof HttpServletRequest || value instanceof HttpServletResponse
|
||||
|| value instanceof MultipartFile || value instanceof MultipartFile[]
|
||||
|| value instanceof BindingResult || value instanceof Resource) {
|
||||
return "<" + value.getClass().getSimpleName() + ">";
|
||||
}
|
||||
if (value.getClass().isArray()) {
|
||||
ArrayNode arrayNode = objectMapper.createArrayNode();
|
||||
int length = Array.getLength(value);
|
||||
for (int i = 0; i < length; i++) {
|
||||
arrayNode.addPOJO(maskSensitiveValue(Array.get(value, i)));
|
||||
}
|
||||
return arrayNode;
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
ArrayNode arrayNode = objectMapper.createArrayNode();
|
||||
for (Object item : collection) {
|
||||
arrayNode.addPOJO(maskSensitiveValue(item));
|
||||
}
|
||||
return arrayNode;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
ObjectNode objectNode = objectMapper.createObjectNode();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
String key = String.valueOf(entry.getKey());
|
||||
objectNode.set(key, objectMapper.valueToTree(maskValueByField(key, entry.getValue())));
|
||||
}
|
||||
return objectNode;
|
||||
}
|
||||
|
||||
JsonNode node = objectMapper.valueToTree(value);
|
||||
return maskJsonNode(node, null);
|
||||
}
|
||||
|
||||
private ObjectNode maskMethodArguments(MethodSignature signature, Object[] args) {
|
||||
ObjectNode objectNode = objectMapper.createObjectNode();
|
||||
if (args == null || args.length == 0) {
|
||||
return objectNode;
|
||||
}
|
||||
String[] parameterNames = signature.getParameterNames();
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String parameterName = parameterNames != null && i < parameterNames.length && parameterNames[i] != null
|
||||
? parameterNames[i]
|
||||
: "arg" + i;
|
||||
objectNode.set(parameterName, objectMapper.valueToTree(maskValueByField(parameterName, args[i])));
|
||||
}
|
||||
return objectNode;
|
||||
}
|
||||
|
||||
private JsonNode maskJsonNode(JsonNode node, String fieldName) {
|
||||
if (node == null || node.isNull()) {
|
||||
return node;
|
||||
}
|
||||
if (isSensitiveKey(fieldName)) {
|
||||
return objectMapper.getNodeFactory().textNode("******");
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return objectMapper.getNodeFactory().textNode(sanitizeSimpleValue(fieldName, node.asText()));
|
||||
}
|
||||
if (node.isArray()) {
|
||||
ArrayNode arrayNode = objectMapper.createArrayNode();
|
||||
for (JsonNode item : node) {
|
||||
arrayNode.add(maskJsonNode(item, null));
|
||||
}
|
||||
return arrayNode;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
ObjectNode objectNode = objectMapper.createObjectNode();
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
objectNode.set(entry.getKey(), maskJsonNode(entry.getValue(), entry.getKey()));
|
||||
}
|
||||
return objectNode;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private Object maskValueByField(String fieldName, Object value) {
|
||||
if (value instanceof String stringValue) {
|
||||
return sanitizeSimpleValue(fieldName, stringValue);
|
||||
}
|
||||
if (isSensitiveKey(fieldName)) {
|
||||
return "******";
|
||||
}
|
||||
return maskSensitiveValue(value);
|
||||
}
|
||||
|
||||
private String sanitizeSimpleValue(String fieldName, String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (isSensitiveKey(fieldName)) {
|
||||
return "******";
|
||||
}
|
||||
String normalized = normalizeKey(fieldName);
|
||||
if (EMAIL_FIELDS.contains(normalized)) {
|
||||
return DesensitizeUtil.email(value);
|
||||
}
|
||||
if (PHONE_FIELDS.contains(normalized)) {
|
||||
return DesensitizeUtil.phone(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean isSensitiveKey(String key) {
|
||||
String normalized = normalizeKey(key);
|
||||
return !normalized.isEmpty() && SENSITIVE_FIELDS.contains(normalized);
|
||||
}
|
||||
|
||||
private String normalizeKey(String key) {
|
||||
if (key == null) {
|
||||
return "";
|
||||
}
|
||||
return key.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
@@ -129,7 +275,6 @@ public class LogAspect {
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 多级代理取第一个
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
@@ -137,7 +282,9 @@ public class LogAspect {
|
||||
}
|
||||
|
||||
private String truncate(String str, int maxLen) {
|
||||
if (str == null) return null;
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
return str.length() > maxLen ? str.substring(0, maxLen) : str;
|
||||
}
|
||||
|
||||
@@ -158,7 +305,8 @@ public class LogAspect {
|
||||
if (username != null) {
|
||||
return String.valueOf(username);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ public @interface Log {
|
||||
/** 是否写入数据库 */
|
||||
boolean saveToDB() default false;
|
||||
|
||||
/** 是否记录请求参数 */
|
||||
boolean recordRequestParam() default true;
|
||||
|
||||
/** 日志等级 */
|
||||
LogLevel level() default LogLevel.INFO;
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.test.demo.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -24,4 +26,20 @@ public class FileConfig {
|
||||
"txt", "csv", "zip", "rar", "7z",
|
||||
"mp4", "avi", "mov", "mp3", "wav"
|
||||
);
|
||||
|
||||
/** 预览路径前缀 */
|
||||
private String previewUrl = "/api/file/preview";
|
||||
|
||||
@PostConstruct
|
||||
public void validate() {
|
||||
if (!StringUtils.hasText(uploadPath)) {
|
||||
throw new IllegalStateException("app.file.upload-path 未配置");
|
||||
}
|
||||
if (allowedExtensions == null || allowedExtensions.isEmpty()) {
|
||||
throw new IllegalStateException("app.file.allowed-extensions 未配置");
|
||||
}
|
||||
if (!StringUtils.hasText(previewUrl) || !previewUrl.startsWith("/")) {
|
||||
throw new IllegalStateException("app.file.preview-url 配置非法");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.test.demo.interceptor.IdempotentInterceptor;
|
||||
import com.test.demo.interceptor.SignatureInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Arrays;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@@ -17,14 +20,27 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final SignatureInterceptor signatureInterceptor;
|
||||
private static final String[] LOCAL_DEV_ORIGINS = {
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173"
|
||||
};
|
||||
|
||||
private final SignatureInterceptor signatureInterceptor;
|
||||
private final IdempotentInterceptor idempotentInterceptor;
|
||||
|
||||
@Value("${app.cors.allowed-origins:}")
|
||||
private String[] allowedOrigins;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
String[] corsOrigins = (allowedOrigins == null || allowedOrigins.length == 0
|
||||
|| Arrays.stream(allowedOrigins).allMatch(String::isBlank))
|
||||
? LOCAL_DEV_ORIGINS
|
||||
: Arrays.stream(allowedOrigins).map(String::trim).filter(origin -> !origin.isBlank()).toArray(String[]::new);
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedOrigins(corsOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
@@ -39,14 +55,19 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/doc.html",
|
||||
"/webjars/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-resources/**",
|
||||
"/actuator/**"
|
||||
"/swagger-resources/**"
|
||||
);
|
||||
|
||||
registry.addInterceptor(idempotentInterceptor)
|
||||
.addPathPatterns("/**");
|
||||
|
||||
// Sa-Token 路由拦截
|
||||
registry.addInterceptor(new SaInterceptor(handle -> {
|
||||
StpUtil.checkLogin();
|
||||
StpUtil.checkRole("ADMIN");
|
||||
}))
|
||||
.addPathPatterns("/actuator/**")
|
||||
.excludePathPatterns("/actuator/health", "/actuator/health/**");
|
||||
|
||||
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(
|
||||
@@ -55,11 +76,12 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/captcha/**",
|
||||
"/file/preview/**",
|
||||
"/enums/**",
|
||||
"/actuator/health",
|
||||
"/actuator/health/**",
|
||||
"/doc.html",
|
||||
"/webjars/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-resources/**",
|
||||
"/actuator/**"
|
||||
"/swagger-resources/**"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.bo.EnumItemBo;
|
||||
import com.test.demo.bo.EnumTypeBo;
|
||||
import com.test.demo.bo.EnumTypeQueryBo;
|
||||
@@ -30,6 +31,7 @@ import java.util.List;
|
||||
@RestController
|
||||
@RequestMapping("/enum-manage")
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckRole("ADMIN")
|
||||
@Tag(name = "枚举字典管理")
|
||||
public class EnumManageController {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.bo.FileQueryBo;
|
||||
import com.test.demo.common.annotation.Log;
|
||||
import com.test.demo.common.base.PageResult;
|
||||
@@ -49,7 +50,8 @@ public class FileController {
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传文件")
|
||||
@SaCheckPermission(PermissionConstant.FILE_UPLOAD)
|
||||
@Log(value = "上传文件", type = LogType.OPER)
|
||||
@SaCheckRole("ADMIN")
|
||||
@Log(value = "上传文件", type = LogType.OPER, recordRequestParam = false)
|
||||
public R<String> upload(
|
||||
@Parameter(description = "上传文件")
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
@@ -113,6 +115,7 @@ public class FileController {
|
||||
@PostMapping("/delete/{*filePath}")
|
||||
@Operation(summary = "删除文件")
|
||||
@SaCheckPermission(PermissionConstant.FILE_DELETE)
|
||||
@SaCheckRole("ADMIN")
|
||||
@Log(value = "删除文件", type = LogType.OPER, saveToDB = true)
|
||||
public R<Boolean> delete(
|
||||
@Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.bo.LoginLogQueryBo;
|
||||
import com.test.demo.bo.OperLogQueryBo;
|
||||
import com.test.demo.common.base.PageResult;
|
||||
@@ -25,6 +26,7 @@ import java.io.IOException;
|
||||
@RestController
|
||||
@RequestMapping("/log")
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckRole("ADMIN")
|
||||
@Tag(name = "日志管理")
|
||||
public class LogController {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.bo.MenuBo;
|
||||
import com.test.demo.common.annotation.Log;
|
||||
import com.test.demo.common.annotation.Idempotent;
|
||||
@@ -33,6 +34,7 @@ public class MenuController {
|
||||
@PostMapping("/list")
|
||||
@Operation(summary = "查询菜单权限列表")
|
||||
@SaCheckPermission(PermissionConstant.MENU_LIST)
|
||||
@SaCheckRole("ADMIN")
|
||||
public R<List<MenuEntity>> list() {
|
||||
return R.ok(menuService.listAll());
|
||||
}
|
||||
@@ -52,6 +54,7 @@ public class MenuController {
|
||||
@PostMapping("/add")
|
||||
@Operation(summary = "新增菜单权限")
|
||||
@SaCheckPermission(PermissionConstant.MENU_ADD)
|
||||
@SaCheckRole("ADMIN")
|
||||
@Idempotent(message = "请勿重复新增菜单")
|
||||
@Log(value = "新增菜单权限", type = LogType.OPER, saveToDB = true)
|
||||
public R<Void> add(@RequestBody @Validated(ValidGroup.Add.class) MenuBo bo) {
|
||||
@@ -62,6 +65,7 @@ public class MenuController {
|
||||
@PostMapping("/edit")
|
||||
@Operation(summary = "编辑菜单权限")
|
||||
@SaCheckPermission(PermissionConstant.MENU_EDIT)
|
||||
@SaCheckRole("ADMIN")
|
||||
@Idempotent(message = "请勿重复编辑菜单")
|
||||
@Log(value = "编辑菜单权限", type = LogType.OPER, saveToDB = true)
|
||||
public R<Void> edit(@RequestBody @Validated(ValidGroup.Update.class) MenuBo bo) {
|
||||
@@ -72,6 +76,7 @@ public class MenuController {
|
||||
@PostMapping("/remove/{id}")
|
||||
@Operation(summary = "删除菜单权限")
|
||||
@SaCheckPermission(PermissionConstant.MENU_REMOVE)
|
||||
@SaCheckRole("ADMIN")
|
||||
@Idempotent(message = "请勿重复删除菜单")
|
||||
@Log(value = "删除菜单权限", type = LogType.OPER, saveToDB = true)
|
||||
public R<Void> remove(@PathVariable Long id) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.bo.RoleBo;
|
||||
import com.test.demo.bo.RoleMenuAssignBo;
|
||||
import com.test.demo.bo.RoleQueryBo;
|
||||
@@ -29,6 +30,7 @@ import java.util.List;
|
||||
@RestController
|
||||
@RequestMapping("/role")
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckRole("ADMIN")
|
||||
@Tag(name = "角色管理")
|
||||
public class RoleController {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.bo.TaskQueryBo;
|
||||
import com.test.demo.common.annotation.Log;
|
||||
import com.test.demo.common.annotation.Idempotent;
|
||||
@@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RestController
|
||||
@RequestMapping("/task")
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckRole("ADMIN")
|
||||
@Tag(name = "定时任务管理")
|
||||
public class TaskController {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.demo.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import com.test.demo.common.annotation.Log;
|
||||
import com.test.demo.common.annotation.RateLimit;
|
||||
import com.test.demo.common.annotation.Idempotent;
|
||||
@@ -12,6 +13,7 @@ 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 com.test.demo.util.PasswordPolicyUtil;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import com.test.demo.vo.UserVo;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -28,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckRole("ADMIN")
|
||||
@Tag(name = "用户管理")
|
||||
public class UserController {
|
||||
|
||||
@@ -90,12 +93,13 @@ public class UserController {
|
||||
@Operation(summary = "重置密码")
|
||||
@SaCheckPermission(PermissionConstant.USER_RESET_PASSWORD)
|
||||
@Idempotent(message = "请勿重复重置密码")
|
||||
@Log(value = "重置密码", type = LogType.OPER, saveToDB = true)
|
||||
@Log(value = "重置密码", type = LogType.OPER, saveToDB = true, recordRequestParam = false)
|
||||
public R<Void> resetPassword(
|
||||
@Parameter(description = "用户ID", example = "1")
|
||||
@PathVariable Long id,
|
||||
@Parameter(description = "新密码", example = "123456")
|
||||
@RequestParam String newPassword) {
|
||||
PasswordPolicyUtil.validate(newPassword);
|
||||
userService.resetPassword(id, newPassword);
|
||||
return R.ok();
|
||||
}
|
||||
@@ -104,7 +108,7 @@ public class UserController {
|
||||
@Operation(summary = "导入用户")
|
||||
@SaCheckPermission(PermissionConstant.USER_IMPORT)
|
||||
@Idempotent(message = "请勿重复导入用户")
|
||||
@Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true)
|
||||
@Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true, recordRequestParam = false)
|
||||
public R<Void> importUsers(
|
||||
@Parameter(description = "用户导入Excel文件")
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 com.test.demo.security.SecurityUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -15,6 +17,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@@ -38,14 +41,16 @@ public class IdempotentInterceptor implements HandlerInterceptor {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 构建幂等 key: token + url + 参数hash
|
||||
String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN);
|
||||
if (token == null) {
|
||||
token = request.getHeader("Authorization");
|
||||
}
|
||||
String token = resolveRequestIdentity(request);
|
||||
String url = request.getRequestURI();
|
||||
String queryString = request.getQueryString() != null ? request.getQueryString() : "";
|
||||
String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.md5Hex(token + url + queryString);
|
||||
String body = "";
|
||||
if (request instanceof RepeatableReadRequestWrapper wrapper) {
|
||||
body = new String(wrapper.getBody(), StandardCharsets.UTF_8);
|
||||
}
|
||||
String bodyHash = DigestUtil.sha256Hex(body);
|
||||
String fingerprint = request.getMethod() + ":" + url + "?" + queryString + ":" + bodyHash;
|
||||
String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.sha256Hex(token + ":" + fingerprint);
|
||||
|
||||
Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", idempotent.expireTime(), TimeUnit.SECONDS);
|
||||
if (Boolean.FALSE.equals(absent)) {
|
||||
@@ -55,4 +60,30 @@ public class IdempotentInterceptor implements HandlerInterceptor {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private String resolveRequestIdentity(HttpServletRequest request) {
|
||||
Long userId = SecurityUtil.getUserIdOrNull();
|
||||
if (userId != null) {
|
||||
return "login-user:" + userId;
|
||||
}
|
||||
String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN);
|
||||
if (token != null && !token.isBlank()) {
|
||||
return "idem-token:" + token.trim();
|
||||
}
|
||||
token = request.getHeader("Authorization");
|
||||
if (token != null && !token.isBlank()) {
|
||||
return "auth-token:" + token.trim();
|
||||
}
|
||||
String ip = firstNonBlank(request.getHeader("X-Forwarded-For"), request.getRemoteAddr());
|
||||
String userAgent = firstNonBlank(request.getHeader("User-Agent"), "unknown");
|
||||
String contentType = firstNonBlank(request.getContentType(), "unknown");
|
||||
return "anonymous:" + ip + ":" + userAgent + ":" + contentType;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
if (first != null && !first.isBlank()) {
|
||||
return first.split(",")[0].trim();
|
||||
}
|
||||
return second == null ? "" : second;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ 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.util.AntPathMatcher;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@@ -26,7 +29,22 @@ import java.util.concurrent.TimeUnit;
|
||||
@RequiredArgsConstructor
|
||||
public class SignatureInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final List<String> PUBLIC_PATHS = List.of(
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/captcha/**",
|
||||
"/file/preview/**",
|
||||
"/actuator/health",
|
||||
"/actuator/health/**",
|
||||
"/enums/**",
|
||||
"/doc.html",
|
||||
"/webjars/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-resources/**"
|
||||
);
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
|
||||
|
||||
@Value("${security.sign.secret:change-me-sign-secret}")
|
||||
private String signSecret;
|
||||
@@ -39,27 +57,32 @@ public class SignatureInterceptor implements HandlerInterceptor {
|
||||
String timestamp = request.getHeader(CommonConstant.HEADER_TIMESTAMP);
|
||||
String nonce = request.getHeader(CommonConstant.HEADER_NONCE);
|
||||
String sign = request.getHeader(CommonConstant.HEADER_SIGN);
|
||||
boolean protectedEndpoint = requiresSignature(request);
|
||||
|
||||
// 缺少签名头则跳过(可根据需求改为拒绝)
|
||||
if (timestamp == null || nonce == null || sign == null) {
|
||||
if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) {
|
||||
if (protectedEndpoint) {
|
||||
throw new BizException(ResultCode.SIGNATURE_INVALID.getCode(), "受保护接口缺少签名请求头");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 时间戳校验
|
||||
long ts = Long.parseLong(timestamp);
|
||||
long ts;
|
||||
try {
|
||||
ts = Long.parseLong(timestamp);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "签名时间戳格式错误");
|
||||
}
|
||||
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);
|
||||
@@ -72,4 +95,17 @@ public class SignatureInterceptor implements HandlerInterceptor {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean requiresSignature(HttpServletRequest request) {
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
String path = request.getRequestURI();
|
||||
for (String publicPath : PUBLIC_PATHS) {
|
||||
if (antPathMatcher.match(publicPath, path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.test.demo.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.test.demo.bo.TaskQueryBo;
|
||||
@@ -12,15 +13,16 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.support.CronExpression;
|
||||
import org.springframework.scheduling.support.CronTrigger;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 动态定时任务服务
|
||||
@@ -30,103 +32,154 @@ import java.util.concurrent.ScheduledFuture;
|
||||
@RequiredArgsConstructor
|
||||
public class DynamicTaskService implements ApplicationRunner {
|
||||
|
||||
private static final int STATUS_PAUSED = 0;
|
||||
private static final int STATUS_RUNNING = 1;
|
||||
|
||||
private static final String TEMPLATE_BEAN_NAME = "dynamicTaskService";
|
||||
private static final String TEMPLATE_METHOD_HEARTBEAT = "logHeartbeat";
|
||||
private static final String TEMPLATE_METHOD_LOG_MESSAGE = "logMessage";
|
||||
|
||||
private final ScheduledTaskMapper taskMapper;
|
||||
private final TaskScheduler taskScheduler;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
private final Map<Long, ScheduledFuture<?>> runningTasks = new ConcurrentHashMap<>();
|
||||
private final Map<Long, ReentrantLock> taskLocks = new ConcurrentHashMap<>();
|
||||
private final Map<String, Consumer<String>> taskTemplates = Map.of(
|
||||
templateKey(TEMPLATE_BEAN_NAME, TEMPLATE_METHOD_HEARTBEAT), params -> log.info("执行白名单任务模板: heartbeat"),
|
||||
templateKey(TEMPLATE_BEAN_NAME, TEMPLATE_METHOD_LOG_MESSAGE),
|
||||
params -> log.info("执行白名单任务模板: logMessage, message={}", StrUtil.blankToDefault(params, "<empty>"))
|
||||
);
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
// 启动时加载所有运行中的任务
|
||||
var tasks = taskMapper.selectList(
|
||||
new LambdaQueryWrapper<ScheduledTaskEntity>().eq(ScheduledTaskEntity::getStatus, 1));
|
||||
new LambdaQueryWrapper<ScheduledTaskEntity>().eq(ScheduledTaskEntity::getStatus, STATUS_RUNNING));
|
||||
for (ScheduledTaskEntity task : tasks) {
|
||||
validateTaskTemplate(task);
|
||||
addTask(task);
|
||||
}
|
||||
log.info("动态定时任务加载完成, 共{}个", tasks.size());
|
||||
}
|
||||
|
||||
/** 添加并启动任务 */
|
||||
public void addTask(ScheduledTaskEntity task) {
|
||||
validateTaskTemplate(task);
|
||||
withTaskLock(task.getId(), () -> {
|
||||
if (!isRunningStatus(task.getStatus())) {
|
||||
log.info("定时任务当前为暂停状态,不进行调度: taskId={}", task.getId());
|
||||
doRemoveTask(task.getId());
|
||||
return;
|
||||
}
|
||||
if (isFutureActive(runningTasks.get(task.getId()))) {
|
||||
log.warn("定时任务已在运行,跳过重复启动: taskId={}", task.getId());
|
||||
return;
|
||||
}
|
||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||
() -> executeTask(task), new CronTrigger(task.getCronExpression()));
|
||||
if (future == null) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "定时任务启动失败");
|
||||
}
|
||||
runningTasks.put(task.getId(), future);
|
||||
log.info("定时任务已启动: {} [{}]", task.getTaskName(), task.getCronExpression());
|
||||
});
|
||||
}
|
||||
|
||||
/** 停止任务 */
|
||||
public void removeTask(Long taskId) {
|
||||
ScheduledFuture<?> future = runningTasks.remove(taskId);
|
||||
if (future != null) {
|
||||
future.cancel(false);
|
||||
log.info("定时任务已停止: taskId={}", taskId);
|
||||
}
|
||||
withTaskLock(taskId, () -> doRemoveTask(taskId));
|
||||
}
|
||||
|
||||
/** 重启任务 */
|
||||
public void restartTask(ScheduledTaskEntity task) {
|
||||
removeTask(task.getId());
|
||||
addTask(task);
|
||||
validateTaskTemplate(task);
|
||||
withTaskLock(task.getId(), () -> {
|
||||
if (!isRunningStatus(task.getStatus())) {
|
||||
doRemoveTask(task.getId());
|
||||
return;
|
||||
}
|
||||
doRemoveTask(task.getId());
|
||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||
() -> executeTask(task), new CronTrigger(task.getCronExpression()));
|
||||
if (future == null) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "定时任务重启失败");
|
||||
}
|
||||
runningTasks.put(task.getId(), future);
|
||||
log.info("定时任务已重启: {} [{}]", task.getTaskName(), task.getCronExpression());
|
||||
});
|
||||
}
|
||||
|
||||
private void executeTask(ScheduledTaskEntity task) {
|
||||
try {
|
||||
Object bean = applicationContext.getBean(task.getBeanName());
|
||||
Method method;
|
||||
if (task.getMethodParams() != null && !task.getMethodParams().isEmpty()) {
|
||||
method = bean.getClass().getMethod(task.getMethodName(), String.class);
|
||||
method.invoke(bean, task.getMethodParams());
|
||||
} else {
|
||||
method = bean.getClass().getMethod(task.getMethodName());
|
||||
method.invoke(bean);
|
||||
Consumer<String> taskExecutor = taskTemplates.get(templateKey(task.getBeanName(), task.getMethodName()));
|
||||
if (taskExecutor == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "任务模板未授权");
|
||||
}
|
||||
taskExecutor.accept(task.getMethodParams());
|
||||
} catch (Exception e) {
|
||||
log.error("定时任务执行失败: {}", task.getTaskName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建任务 */
|
||||
public void create(ScheduledTaskEntity task) {
|
||||
task.setStatus(1);
|
||||
normalizeAndValidateTask(task);
|
||||
task.setStatus(normalizeStatus(task.getStatus()));
|
||||
taskMapper.insert(task);
|
||||
if (isRunningStatus(task.getStatus())) {
|
||||
addTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新任务 */
|
||||
public void update(ScheduledTaskEntity task) {
|
||||
withTaskLock(task.getId(), () -> {
|
||||
ScheduledTaskEntity existing = requireTask(task.getId());
|
||||
normalizeAndValidateTask(task);
|
||||
task.setStatus(task.getStatus() == null ? existing.getStatus() : normalizeStatus(task.getStatus()));
|
||||
taskMapper.updateById(task);
|
||||
if (task.getStatus() != null && task.getStatus() == 1) {
|
||||
restartTask(task);
|
||||
} else {
|
||||
removeTask(task.getId());
|
||||
ScheduledTaskEntity latest = requireTask(task.getId());
|
||||
if (isRunningStatus(latest.getStatus())) {
|
||||
restartTask(latest);
|
||||
return;
|
||||
}
|
||||
doRemoveTask(task.getId());
|
||||
});
|
||||
}
|
||||
|
||||
/** 暂停任务 */
|
||||
public void pause(Long taskId) {
|
||||
withTaskLock(taskId, () -> {
|
||||
ScheduledTaskEntity task = requireTask(taskId);
|
||||
if (Integer.valueOf(STATUS_PAUSED).equals(task.getStatus())) {
|
||||
doRemoveTask(taskId);
|
||||
return;
|
||||
}
|
||||
ScheduledTaskEntity entity = new ScheduledTaskEntity();
|
||||
entity.setId(taskId);
|
||||
entity.setStatus(0);
|
||||
entity.setStatus(STATUS_PAUSED);
|
||||
taskMapper.updateById(entity);
|
||||
removeTask(taskId);
|
||||
doRemoveTask(taskId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 恢复任务 */
|
||||
public void resume(Long taskId) {
|
||||
ScheduledTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "任务不存在");
|
||||
withTaskLock(taskId, () -> {
|
||||
ScheduledTaskEntity task = requireTask(taskId);
|
||||
validateTaskTemplate(task);
|
||||
if (isRunningStatus(task.getStatus()) && isFutureActive(runningTasks.get(taskId))) {
|
||||
log.warn("定时任务已恢复,跳过重复调度: taskId={}", taskId);
|
||||
return;
|
||||
}
|
||||
if (!isRunningStatus(task.getStatus())) {
|
||||
ScheduledTaskEntity entity = new ScheduledTaskEntity();
|
||||
entity.setId(taskId);
|
||||
entity.setStatus(STATUS_RUNNING);
|
||||
taskMapper.updateById(entity);
|
||||
task.setStatus(STATUS_RUNNING);
|
||||
}
|
||||
task.setStatus(1);
|
||||
taskMapper.updateById(task);
|
||||
addTask(task);
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除任务 */
|
||||
public void delete(Long taskId) {
|
||||
requireTask(taskId);
|
||||
removeTask(taskId);
|
||||
taskMapper.deleteById(taskId);
|
||||
taskLocks.remove(taskId);
|
||||
}
|
||||
|
||||
public PageResult<ScheduledTaskEntity> page(TaskQueryBo query) {
|
||||
@@ -140,10 +193,86 @@ public class DynamicTaskService implements ApplicationRunner {
|
||||
}
|
||||
|
||||
public ScheduledTaskEntity detail(Long id) {
|
||||
ScheduledTaskEntity entity = taskMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
return requireTask(id);
|
||||
}
|
||||
|
||||
private void normalizeAndValidateTask(ScheduledTaskEntity task) {
|
||||
if (task == null) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "任务不能为空");
|
||||
}
|
||||
if (!CronExpression.isValidExpression(task.getCronExpression())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Cron 表达式非法");
|
||||
}
|
||||
task.setBeanName(StrUtil.trim(task.getBeanName()));
|
||||
task.setMethodName(StrUtil.trim(task.getMethodName()));
|
||||
task.setMethodParams(StrUtil.trimToNull(task.getMethodParams()));
|
||||
if (task.getMethodParams() != null && task.getMethodParams().length() > 256) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "任务参数长度不能超过256");
|
||||
}
|
||||
if (task.getStatus() != null && !isAllowedStatus(task.getStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "任务状态非法");
|
||||
}
|
||||
validateTaskTemplate(task);
|
||||
}
|
||||
|
||||
private void validateTaskTemplate(ScheduledTaskEntity task) {
|
||||
if (task == null || StrUtil.isBlank(task.getBeanName()) || StrUtil.isBlank(task.getMethodName())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "任务模板不能为空");
|
||||
}
|
||||
String key = templateKey(task.getBeanName(), task.getMethodName());
|
||||
if (!taskTemplates.containsKey(key)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN,
|
||||
"仅允许执行白名单任务模板,当前支持: dynamicTaskService.logHeartbeat / dynamicTaskService.logMessage");
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduledTaskEntity requireTask(Long taskId) {
|
||||
ScheduledTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "任务不存在");
|
||||
}
|
||||
return entity;
|
||||
return task;
|
||||
}
|
||||
|
||||
private void doRemoveTask(Long taskId) {
|
||||
ScheduledFuture<?> future = runningTasks.remove(taskId);
|
||||
if (future != null) {
|
||||
future.cancel(false);
|
||||
log.info("定时任务已停止: taskId={}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFutureActive(ScheduledFuture<?> future) {
|
||||
return future != null && !future.isCancelled() && !future.isDone();
|
||||
}
|
||||
|
||||
private boolean isRunningStatus(Integer status) {
|
||||
return Integer.valueOf(STATUS_RUNNING).equals(status);
|
||||
}
|
||||
|
||||
private boolean isAllowedStatus(Integer status) {
|
||||
return Integer.valueOf(STATUS_RUNNING).equals(status) || Integer.valueOf(STATUS_PAUSED).equals(status);
|
||||
}
|
||||
|
||||
private int normalizeStatus(Integer status) {
|
||||
return status == null ? STATUS_RUNNING : status;
|
||||
}
|
||||
|
||||
private void withTaskLock(Long taskId, Runnable action) {
|
||||
if (taskId == null) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
ReentrantLock lock = taskLocks.computeIfAbsent(taskId, key -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try {
|
||||
action.run();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private static String templateKey(String beanName, String methodName) {
|
||||
return beanName + "#" + methodName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import java.io.IOException;
|
||||
*/
|
||||
public interface OperLogService extends IService<OperLogEntity> {
|
||||
|
||||
void record(OperLogEntity operLog);
|
||||
|
||||
PageResult<OperLogEntity> page(OperLogQueryBo query);
|
||||
|
||||
void export(OperLogQueryBo query, HttpServletResponse response) throws IOException;
|
||||
|
||||
@@ -18,9 +18,11 @@ import com.test.demo.service.LoginLogService;
|
||||
import com.test.demo.service.MenuService;
|
||||
import com.test.demo.service.RoleService;
|
||||
import com.test.demo.service.UserService;
|
||||
import com.test.demo.util.PasswordPolicyUtil;
|
||||
import com.test.demo.vo.LoginVo;
|
||||
import com.test.demo.vo.UserVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -34,7 +36,6 @@ import java.util.concurrent.TimeUnit;
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
|
||||
private static final long LOGIN_FAIL_EXPIRE_MINUTES = 15;
|
||||
|
||||
private static final long LOGIN_FAIL_MAX_TIMES = 5;
|
||||
|
||||
private final UserService userService;
|
||||
@@ -94,11 +95,7 @@ public class AuthServiceImpl implements AuthService {
|
||||
if (!captchaService.verify(bo.getCaptchaKey(), bo.getCaptchaCode())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "验证码错误或已过期");
|
||||
}
|
||||
long count = userService.count(new LambdaQueryWrapper<UserEntity>()
|
||||
.eq(UserEntity::getUsername, bo.getUsername()));
|
||||
if (count > 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
PasswordPolicyUtil.validate(bo.getPassword());
|
||||
UserEntity user = new UserEntity();
|
||||
user.setUsername(bo.getUsername());
|
||||
user.setNickname(bo.getNickname());
|
||||
@@ -109,7 +106,11 @@ public class AuthServiceImpl implements AuthService {
|
||||
user.setSalt(salt);
|
||||
user.setPassword(SM3Util.hash(bo.getPassword(), salt));
|
||||
user.setStatus(UserStatusEnum.NORMAL);
|
||||
try {
|
||||
userService.save(user);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -139,14 +140,17 @@ public class AuthServiceImpl implements AuthService {
|
||||
private long getLoginFailTimes(String key) {
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
if (value == null) {
|
||||
return 0L;
|
||||
return 0;
|
||||
}
|
||||
return Long.parseLong(value.toString());
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void increaseLoginFailTimes(String key) {
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
if (count != null && count == 1L) {
|
||||
Long failTimes = redisTemplate.opsForValue().increment(key);
|
||||
if (failTimes != null && failTimes == 1L) {
|
||||
redisTemplate.expire(key, LOGIN_FAIL_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ import java.util.List;
|
||||
@Service
|
||||
public class OperLogServiceImpl extends ServiceImpl<OperLogMapper, OperLogEntity> implements OperLogService {
|
||||
|
||||
@Override
|
||||
public void record(OperLogEntity operLog) {
|
||||
if (operLog == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
save(operLog);
|
||||
} catch (Exception e) {
|
||||
log.error("写入操作日志失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<OperLogEntity> page(OperLogQueryBo query) {
|
||||
LambdaQueryWrapper<OperLogEntity> wrapper = new LambdaQueryWrapper<OperLogEntity>()
|
||||
|
||||
@@ -14,12 +14,14 @@ import com.test.demo.entity.UserEntity;
|
||||
import com.test.demo.enums.UserStatusEnum;
|
||||
import com.test.demo.mapper.UserMapper;
|
||||
import com.test.demo.security.crypto.SM3Util;
|
||||
import com.test.demo.service.UserService;
|
||||
import com.test.demo.util.ExcelUtil;
|
||||
import com.test.demo.util.PasswordPolicyUtil;
|
||||
import com.test.demo.vo.UserImportVo;
|
||||
import com.test.demo.vo.UserVo;
|
||||
import com.test.demo.service.UserService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -62,20 +64,17 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
|
||||
|
||||
@Override
|
||||
public void add(UserBo bo) {
|
||||
// 检查用户名唯一
|
||||
long count = count(new LambdaQueryWrapper<UserEntity>()
|
||||
.eq(UserEntity::getUsername, bo.getUsername()));
|
||||
if (count > 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
|
||||
PasswordPolicyUtil.validate(bo.getPassword());
|
||||
UserEntity entity = userConvert.toEntity(bo);
|
||||
// SM3加密密码
|
||||
String salt = SM3Util.generateSalt();
|
||||
entity.setSalt(salt);
|
||||
entity.setPassword(SM3Util.hash(bo.getPassword(), salt));
|
||||
entity.setStatus(UserStatusEnum.NORMAL);
|
||||
try {
|
||||
save(entity);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,6 +97,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
|
||||
|
||||
@Override
|
||||
public void resetPassword(Long id, String newPassword) {
|
||||
PasswordPolicyUtil.validate(newPassword);
|
||||
UserEntity entity = getById(id);
|
||||
if (entity == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "用户不存在");
|
||||
@@ -140,23 +140,23 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
|
||||
if (StrUtil.isBlank(row.getUsername())) {
|
||||
continue;
|
||||
}
|
||||
long count = count(new LambdaQueryWrapper<UserEntity>()
|
||||
.eq(UserEntity::getUsername, row.getUsername()));
|
||||
if (count > 0) {
|
||||
continue;
|
||||
}
|
||||
UserEntity entity = new UserEntity();
|
||||
entity.setUsername(row.getUsername());
|
||||
entity.setNickname(row.getNickname());
|
||||
entity.setEmail(row.getEmail());
|
||||
entity.setPhone(row.getPhone());
|
||||
entity.setAvatar(row.getAvatar());
|
||||
String rawPassword = StrUtil.blankToDefault(row.getPassword(), "123456");
|
||||
String rawPassword = StrUtil.blankToDefault(row.getPassword(), "ChangeMe@123");
|
||||
PasswordPolicyUtil.validate(rawPassword);
|
||||
String salt = SM3Util.generateSalt();
|
||||
entity.setSalt(salt);
|
||||
entity.setPassword(SM3Util.hash(rawPassword, salt));
|
||||
entity.setStatus(UserStatusEnum.NORMAL);
|
||||
try {
|
||||
save(entity);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "导入失败,用户名已存在: " + row.getUsername());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "导入文件读取失败");
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.test.demo.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 文件路径安全校验工具
|
||||
*/
|
||||
@@ -16,22 +20,20 @@ public final class FilePathUtil {
|
||||
* @return 是否安全
|
||||
*/
|
||||
public static boolean isSafePath(String basePath, String filePath) {
|
||||
if (StrUtil.isBlank(filePath)) {
|
||||
if (StrUtil.isBlank(basePath) || StrUtil.isBlank(filePath)) {
|
||||
return false;
|
||||
}
|
||||
// 禁止路径穿越
|
||||
if (filePath.contains("..") || filePath.contains("./")) {
|
||||
try {
|
||||
Path normalizedBasePath = Paths.get(basePath).toAbsolutePath().normalize();
|
||||
Path requestedPath = Paths.get(filePath).normalize();
|
||||
if (requestedPath.isAbsolute()) {
|
||||
return false;
|
||||
}
|
||||
// 禁止绝对路径
|
||||
if (filePath.startsWith("/") || filePath.contains(":")) {
|
||||
Path normalizedResolvedPath = normalizedBasePath.resolve(requestedPath).normalize();
|
||||
return normalizedResolvedPath.startsWith(normalizedBasePath);
|
||||
} catch (InvalidPathException ex) {
|
||||
return false;
|
||||
}
|
||||
// 禁止特殊字符
|
||||
if (filePath.matches(".*[\\\\<>|\"?*].*")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
34
src/main/java/com/test/demo/util/PasswordPolicyUtil.java
Normal file
34
src/main/java/com/test/demo/util/PasswordPolicyUtil.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.test.demo.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.test.demo.common.exception.BizException;
|
||||
import com.test.demo.common.result.ResultCode;
|
||||
|
||||
/**
|
||||
* 密码强度校验工具
|
||||
*/
|
||||
public final class PasswordPolicyUtil {
|
||||
|
||||
private PasswordPolicyUtil() {}
|
||||
|
||||
public static void validate(String password) {
|
||||
if (StrUtil.isBlank(password)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "密码不能为空");
|
||||
}
|
||||
if (password.length() < 8 || password.length() > 32) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "密码长度需为 8-32 位");
|
||||
}
|
||||
if (!password.matches(".*[A-Z].*")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "密码需包含大写字母");
|
||||
}
|
||||
if (!password.matches(".*[a-z].*")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "密码需包含小写字母");
|
||||
}
|
||||
if (!password.matches(".*\\d.*")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "密码需包含数字");
|
||||
}
|
||||
if (!password.matches(".*[^A-Za-z0-9].*")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "密码需包含特殊字符");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,6 @@ sa-token:
|
||||
token-style: uuid
|
||||
is-log: false
|
||||
|
||||
# 文件上传路径
|
||||
file:
|
||||
upload-path: /data/upload
|
||||
preview-url: /api/file/preview
|
||||
|
||||
# 接口签名配置
|
||||
security:
|
||||
sign:
|
||||
@@ -84,3 +79,11 @@ management:
|
||||
show-details: when-authorized
|
||||
mail:
|
||||
enabled: false
|
||||
|
||||
|
||||
app:
|
||||
cors:
|
||||
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:}
|
||||
file:
|
||||
upload-path: /data/upload
|
||||
preview-url: /api/file/preview
|
||||
|
||||
@@ -5,6 +5,22 @@
|
||||
|
||||
USE `demo`;
|
||||
|
||||
SET @need_uk_username = (
|
||||
SELECT CASE WHEN COUNT(1) = 0 THEN 1 ELSE 0 END
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_user'
|
||||
AND index_name = 'uk_username'
|
||||
);
|
||||
SET @sql_uk_username = IF(
|
||||
@need_uk_username = 1,
|
||||
'ALTER TABLE `sys_user` ADD UNIQUE KEY `uk_username` (`username`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_uk_username FROM @sql_uk_username;
|
||||
EXECUTE stmt_uk_username;
|
||||
DEALLOCATE PREPARE stmt_uk_username;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sys_role` (
|
||||
`id` BIGINT NOT NULL COMMENT '主键ID',
|
||||
`role_code` VARCHAR(64) NOT NULL COMMENT '角色编码',
|
||||
|
||||
Reference in New Issue
Block a user