feat: 添加用户角色字段并实现权限控制
fix(security): 修复重放攻击拦截器的时间戳验证漏洞 refactor(security): 重构验证码工具类使用线程安全实现 perf(login): 优化登录锁定工具类性能并添加定期清理 fix(editor): 修复笔记编辑器空指针问题 style: 清理数据库索引脚本中的冗余注释 fix(api): 修复前端API调用参数编码问题 feat(image): 实现图片名称同步服务 refactor(markdown): 重构Markdown服务分离图片名称同步逻辑 fix(xss): 添加HTML转义函数防止XSS攻击 fix(user): 修复用户服务权限加载问题 fix(rate-limit): 修复速率限制拦截器并发问题 fix(axios): 生产环境隐藏详细错误信息 fix(image): 修复图片上传和删除的权限验证 refactor(captcha): 重构验证码工具类使用并发安全实现 fix(jwt): 修复JWT过滤器空指针问题 fix(export): 修复笔记导出XSS漏洞 fix(search): 修复Markdown搜索SQL注入问题 fix(interceptor): 修复重放攻击拦截器逻辑错误 fix(controller): 修复用户控制器空指针问题 fix(security): 修复nonce生成使用密码学安全方法
This commit is contained in:
@@ -4,13 +4,17 @@ package com.test.bijihoudaun.controller;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.test.bijihoudaun.common.response.R;
|
||||
import com.test.bijihoudaun.entity.Image;
|
||||
import com.test.bijihoudaun.entity.User;
|
||||
import com.test.bijihoudaun.service.ImageService;
|
||||
import com.test.bijihoudaun.service.UserService;
|
||||
import com.test.bijihoudaun.util.SecurityUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -18,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
@@ -33,18 +38,30 @@ public class ImageController {
|
||||
@Autowired
|
||||
private ImageService imageService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Operation(summary = "上传图片")
|
||||
@PostMapping
|
||||
public R<Image> uploadImage(
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestParam(value = "userId", required = false) Long userId,
|
||||
@RequestParam(value = "markdownId", required = false) Long markdownId) {
|
||||
// 修复:从SecurityContext获取当前用户,不接受客户端传入的userId
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (!(principal instanceof UserDetails)) {
|
||||
return R.fail("请先登录");
|
||||
}
|
||||
String username = ((UserDetails) principal).getUsername();
|
||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||
if (user == null) {
|
||||
return R.fail("用户不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
Image image = imageService.uploadImage(file, userId, markdownId);
|
||||
Image image = imageService.uploadImage(file, user.getId(), markdownId);
|
||||
return R.success(image);
|
||||
} catch (IOException e) {
|
||||
return R.fail();
|
||||
return R.fail("上传失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,27 +71,33 @@ public class ImageController {
|
||||
if (!SecurityUtil.isUserAuthenticated()) {
|
||||
return R.fail("请先登录");
|
||||
}
|
||||
// 修复:添加权限验证,确保用户只能删除自己的图片
|
||||
if (!canModifyImage(id)) {
|
||||
return R.fail("无权删除此图片");
|
||||
}
|
||||
boolean result = imageService.deleteImage(id);
|
||||
if (result) {
|
||||
return R.success();
|
||||
} else {
|
||||
return R.fail();
|
||||
return R.fail("删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "在线预览", description = "浏览器直接打开文件流")
|
||||
@GetMapping("/preview/{url}")
|
||||
public void preview(@PathVariable String url, HttpServletResponse resp) throws IOException {
|
||||
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
|
||||
try (PrintWriter writer = resp.getWriter()) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
resp.setStatus(404);
|
||||
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||
writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
|
||||
String sanitizedUrl = sanitizeFileName(url);
|
||||
if (sanitizedUrl == null) {
|
||||
resp.setStatus(403);
|
||||
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||
writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,14 +106,14 @@ public class ImageController {
|
||||
|
||||
if (!filePath.startsWith(basePath)) {
|
||||
resp.setStatus(403);
|
||||
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||
writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
|
||||
File file = filePath.toFile();
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
resp.setStatus(404);
|
||||
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||
writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +124,7 @@ public class ImageController {
|
||||
StreamUtils.copy(in, resp.getOutputStream());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String fileName) {
|
||||
if (StrUtil.isBlank(fileName)) {
|
||||
@@ -119,11 +143,15 @@ public class ImageController {
|
||||
if (!SecurityUtil.isUserAuthenticated()) {
|
||||
return R.fail("请先登录");
|
||||
}
|
||||
// 修复:添加权限验证
|
||||
if (!canModifyImageByUrl(url)) {
|
||||
return R.fail("无权删除此图片");
|
||||
}
|
||||
boolean result = imageService.deleteImageByUrl(url);
|
||||
if (result) {
|
||||
return R.success();
|
||||
} else {
|
||||
return R.fail();
|
||||
return R.fail("删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,14 +161,62 @@ public class ImageController {
|
||||
if (!SecurityUtil.isUserAuthenticated()) {
|
||||
return R.fail("请先登录");
|
||||
}
|
||||
// 修复:添加权限验证
|
||||
for (String url : urls) {
|
||||
if (!canModifyImageByUrl(url)) {
|
||||
return R.fail("无权删除部分图片");
|
||||
}
|
||||
}
|
||||
boolean result = imageService.deleteImageByUrls(urls);
|
||||
if (result) {
|
||||
return R.success();
|
||||
} else {
|
||||
return R.fail();
|
||||
return R.fail("删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前用户是否有权限操作图片
|
||||
*/
|
||||
private boolean canModifyImage(Long imageId) {
|
||||
// 从数据库查询图片所属用户
|
||||
Image image = imageService.getById(imageId);
|
||||
if (image == null) {
|
||||
return false;
|
||||
}
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (!(principal instanceof UserDetails)) {
|
||||
return false;
|
||||
}
|
||||
String username = ((UserDetails) principal).getUsername();
|
||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
return user.getId().equals(image.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前用户是否有权限操作图片(通过URL)
|
||||
*/
|
||||
private boolean canModifyImageByUrl(String url) {
|
||||
// 从数据库查询图片所属用户
|
||||
Image image = imageService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<Image>().eq("stored_name", url));
|
||||
if (image == null) {
|
||||
return false;
|
||||
}
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (!(principal instanceof UserDetails)) {
|
||||
return false;
|
||||
}
|
||||
String username = ((UserDetails) principal).getUsername();
|
||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
return user.getId().equals(image.getUserId());
|
||||
}
|
||||
|
||||
|
||||
private String getContentTypeFromFileExtension(String fileName) {
|
||||
if (StrUtil.isBlank(fileName) || !StrUtil.contains(fileName, '.')) {
|
||||
|
||||
@@ -98,7 +98,7 @@ public class MarkdownController {
|
||||
@PostMapping("/{id}/title")
|
||||
public R<MarkdownFile> updateMarkdownTitle(
|
||||
@PathVariable Long id,
|
||||
String title) {
|
||||
@RequestParam String title) {
|
||||
MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title);
|
||||
if (ObjectUtil.isNotNull(updatedFile)) {
|
||||
return R.success(updatedFile);
|
||||
|
||||
@@ -11,8 +11,6 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/system")
|
||||
@Tag(name = "系统管理")
|
||||
@@ -31,7 +29,7 @@ public class SystemController {
|
||||
}
|
||||
|
||||
@PostMapping("/registration/toggle")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "切换注册功能状态")
|
||||
public R<Void> toggleRegistration(@RequestBody Boolean enabled) {
|
||||
systemSettingService.setRegistrationEnabled(enabled);
|
||||
@@ -39,7 +37,7 @@ public class SystemController {
|
||||
}
|
||||
|
||||
@PostMapping("/registration/generate-code")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "生成注册码")
|
||||
public R<String> generateRegistrationCode() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
@@ -52,6 +52,10 @@ public class UserController {
|
||||
return R.fail("无效或已过期的注册码");
|
||||
}
|
||||
User user = userService.register(username, password, email);
|
||||
// 修复:添加空值检查
|
||||
if (user == null) {
|
||||
return R.fail("注册失败,请稍后重试");
|
||||
}
|
||||
UserVO userVO = new UserVO();
|
||||
BeanUtils.copyProperties(user, userVO);
|
||||
userVO.setId(String.valueOf(user.getId()));
|
||||
@@ -69,6 +73,11 @@ public class UserController {
|
||||
String token = userService.login(username, password);
|
||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||
|
||||
// 修复:添加空值检查
|
||||
if (user == null) {
|
||||
return R.fail("用户不存在");
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", token);
|
||||
|
||||
@@ -88,7 +97,12 @@ public class UserController {
|
||||
@RequireCaptcha("删除账号")
|
||||
@DeleteMapping("/deleteUser")
|
||||
public R<String> deleteUser(){
|
||||
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
// 修复:添加类型检查
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (!(principal instanceof UserDetails)) {
|
||||
return R.fail("无法获取用户信息");
|
||||
}
|
||||
UserDetails userDetails = (UserDetails) principal;
|
||||
String username = userDetails.getUsername();
|
||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||
|
||||
@@ -110,9 +124,20 @@ public class UserController {
|
||||
@RequireCaptcha("修改密码")
|
||||
@PutMapping("/password")
|
||||
public R<String> updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) {
|
||||
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
// 修复:添加类型检查
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (!(principal instanceof UserDetails)) {
|
||||
return R.fail("无法获取用户信息");
|
||||
}
|
||||
UserDetails userDetails = (UserDetails) principal;
|
||||
String username = userDetails.getUsername();
|
||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||
|
||||
// 修复:添加空值检查
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
return R.fail("用户不存在");
|
||||
}
|
||||
|
||||
userService.updatePassword(user.getId(), updatePasswordBo);
|
||||
return R.success("密码更新成功");
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ public class User {
|
||||
@TableField("`email`")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "用户角色(ADMIN-管理员,USER-普通用户)",implementation = String.class)
|
||||
@TableField("`role`")
|
||||
private String role;
|
||||
|
||||
@Schema(description = "用户创建时间",implementation = Date.class)
|
||||
@TableField("created_at")
|
||||
private Date createdAt;
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.test.bijihoudaun.common.response.R;
|
||||
import com.test.bijihoudaun.common.response.ResultCode;
|
||||
import com.test.bijihoudaun.util.JwtTokenUtil;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.security.SignatureException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -18,6 +19,7 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
|
||||
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||
|
||||
@@ -45,8 +47,8 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
|
||||
// 从请求头中获取认证信息
|
||||
String authHeader = request.getHeader(this.tokenHeader);
|
||||
// 检查请求头是否存在且以指定的token前缀开头
|
||||
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
|
||||
// 修复:添加 tokenHead 空值检查
|
||||
if (authHeader != null && this.tokenHead != null && authHeader.startsWith(this.tokenHead)) {
|
||||
// 提取实际的token值(去除前缀)
|
||||
final String authToken = authHeader.substring(this.tokenHead.length());
|
||||
try {
|
||||
@@ -76,6 +78,10 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||
// 处理token签名异常
|
||||
sendErrorResponse(response, ResultCode.TOKEN_INVALID);
|
||||
return;
|
||||
} catch (JwtException e) {
|
||||
// 修复:捕获所有JWT异常作为兜底
|
||||
sendErrorResponse(response, ResultCode.TOKEN_INVALID);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 继续过滤器链的处理
|
||||
@@ -93,9 +99,13 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
// 设置HTTP状态码为401未授权
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
|
||||
try (PrintWriter writer = response.getWriter()) {
|
||||
// 创建ObjectMapper实例,用于对象与JSON之间的转换
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
// 将失败结果转换为JSON字符串并写入响应输出流
|
||||
response.getWriter().write(mapper.writeValueAsString(R.fail(resultCode)));
|
||||
writer.write(mapper.writeValueAsString(R.fail(resultCode)));
|
||||
writer.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,24 +61,18 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
boolean incrementAndCheck(int maxRequests) {
|
||||
long now = System.currentTimeMillis();
|
||||
lastAccessTime = now;
|
||||
long currentWindow = windowStart;
|
||||
|
||||
if (now - currentWindow > WINDOW_SIZE_MS) {
|
||||
// 尝试进入新窗口
|
||||
synchronized (this) {
|
||||
if (windowStart == currentWindow) {
|
||||
// 确实需要新窗口
|
||||
if (now - windowStart > WINDOW_SIZE_MS) {
|
||||
// 进入新窗口
|
||||
windowStart = now;
|
||||
count.set(1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 其他线程已经更新了窗口,继续检查
|
||||
}
|
||||
|
||||
int currentCount = count.incrementAndGet();
|
||||
return currentCount <= maxRequests;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否过期(超过2个时间窗口没有访问)
|
||||
|
||||
@@ -107,8 +107,9 @@ public class ReplayAttackInterceptor implements HandlerInterceptor {
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (Math.abs(now - timestamp) > TIMESTAMP_VALIDITY) {
|
||||
writeErrorResponse(response, "请求已过期,请重新发起请求");
|
||||
// 修复:不允许未来时间戳,防止预发送攻击
|
||||
if (timestamp > now || now - timestamp > TIMESTAMP_VALIDITY) {
|
||||
writeErrorResponse(response, "请求时间戳无效或已过期");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.test.bijihoudaun.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.test.bijihoudaun.entity.ImageName;
|
||||
import com.test.bijihoudaun.mapper.ImageNameMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 图片名称同步服务
|
||||
* 处理 Markdown 内容中的图片名称同步,独立的事务和异步处理
|
||||
*/
|
||||
@Service
|
||||
public class ImageNameSyncService {
|
||||
|
||||
@Resource
|
||||
private ImageNameMapper imageNameMapper;
|
||||
|
||||
/**
|
||||
* 同步图片名称
|
||||
* 异步执行,独立事务
|
||||
*/
|
||||
@Async("imageNameSyncExecutor")
|
||||
@Transactional
|
||||
public void syncImageNames(Long markdownId, List<String> strings) {
|
||||
// 查询数据库中已存在的文件名
|
||||
List<ImageName> imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper<ImageName>()
|
||||
.eq(ImageName::getMarkdownId, markdownId));
|
||||
|
||||
// 若是数据库中的数据为null,则插入
|
||||
if (CollUtil.isEmpty(imageNames)) {
|
||||
if (CollUtil.isNotEmpty(strings)) {
|
||||
List<ImageName> list = strings.stream().map(fileName -> {
|
||||
ImageName imageName = new ImageName();
|
||||
imageName.setFileName(fileName);
|
||||
imageName.setMarkdownId(markdownId);
|
||||
return imageName;
|
||||
}).toList();
|
||||
// 批量插入新的文件名(使用MyBatis Plus批量插入)
|
||||
imageNameMapper.insert(list);
|
||||
}
|
||||
} else {
|
||||
// 数据库中已有记录,需要对比处理
|
||||
// 获取数据库中的文件名列表
|
||||
List<String> dbFileNames = imageNames.stream()
|
||||
.map(ImageName::getFileName)
|
||||
.toList();
|
||||
|
||||
// 找出需要新增的文件名(在strings中但不在数据库中)
|
||||
List<String> toInsert = strings.stream()
|
||||
.filter(fileName -> !dbFileNames.contains(fileName))
|
||||
.toList();
|
||||
|
||||
// 找出需要删除的记录(在数据库中但不在strings中)
|
||||
List<ImageName> toDelete = imageNames.stream()
|
||||
.filter(imageName -> !strings.contains(imageName.getFileName()))
|
||||
.toList();
|
||||
|
||||
// 插入新增的文件名
|
||||
if (CollUtil.isNotEmpty(toInsert)) {
|
||||
List<ImageName> insertList = toInsert.stream().map(fileName -> {
|
||||
ImageName imageName = new ImageName();
|
||||
imageName.setFileName(fileName);
|
||||
imageName.setMarkdownId(markdownId);
|
||||
return imageName;
|
||||
}).toList();
|
||||
|
||||
imageNameMapper.insert(insertList);
|
||||
}
|
||||
|
||||
// 删除不再需要的记录
|
||||
if (CollUtil.isNotEmpty(toDelete)) {
|
||||
List<Long> deleteIds = toDelete.stream()
|
||||
.map(ImageName::getId)
|
||||
.toList();
|
||||
imageNameMapper.deleteByIds(deleteIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,16 +82,22 @@ public class ImageServiceImpl
|
||||
|
||||
Path filePath = uploadPath.resolve(storedName);
|
||||
|
||||
// 优化:压缩图片后再保存
|
||||
// 优化:压缩图片后再保存,确保流关闭
|
||||
try (InputStream originalStream = file.getInputStream()) {
|
||||
InputStream compressedStream = ImageCompressor.compressIfNeeded(
|
||||
file.getInputStream(), contentType, file.getSize());
|
||||
originalStream, contentType, file.getSize());
|
||||
|
||||
if (compressedStream != null) {
|
||||
// 使用压缩后的图片
|
||||
try (compressedStream) {
|
||||
Files.copy(compressedStream, filePath);
|
||||
}
|
||||
} else {
|
||||
// 使用原图
|
||||
Files.copy(file.getInputStream(), filePath);
|
||||
// 使用原图,需要重新获取流
|
||||
try (InputStream newStream = file.getInputStream()) {
|
||||
Files.copy(newStream, filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image image = new Image();
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.test.bijihoudaun.util.MarkdownImageExtractor;
|
||||
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -34,6 +33,8 @@ public class MarkdownFileServiceImpl
|
||||
ImageNameMapper imageNameMapper;
|
||||
@Resource
|
||||
SnowflakeIdGenerator snowflakeIdGenerator;
|
||||
@Resource
|
||||
ImageNameSyncService imageNameSyncService;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -77,7 +78,8 @@ public class MarkdownFileServiceImpl
|
||||
}
|
||||
|
||||
List<String> strings = MarkdownImageExtractor.extractImageFilenames(markdownFile.getContent());
|
||||
syncImageNames(id, strings);
|
||||
// 修复:调用单独的 Service 处理异步逻辑
|
||||
imageNameSyncService.syncImageNames(id, strings);
|
||||
|
||||
MarkdownFileVO result = markdownFileMapper.selectByIdWithGrouping(id);
|
||||
if (result != null) {
|
||||
@@ -128,8 +130,13 @@ public class MarkdownFileServiceImpl
|
||||
|
||||
@Override
|
||||
public List<MarkdownFile> searchByTitle(String keyword) {
|
||||
// 修复:转义特殊字符防止 SQL 注入
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String escapedKeyword = keyword.replace("%", "\\%").replace("_", "\\_");
|
||||
QueryWrapper<MarkdownFile> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.like("title", keyword);
|
||||
queryWrapper.like("title", escapedKeyword);
|
||||
return this.list(queryWrapper);
|
||||
}
|
||||
|
||||
@@ -149,62 +156,4 @@ public class MarkdownFileServiceImpl
|
||||
return markdownFileMapper.selectRecentWithGrouping(limit);
|
||||
}
|
||||
|
||||
|
||||
@Async("imageNameSyncExecutor")
|
||||
public void syncImageNames(Long markdownId, List<String> strings) {
|
||||
// 查询数据库中已存在的文件名
|
||||
List<ImageName> imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper<ImageName>()
|
||||
.eq(ImageName::getMarkdownId, markdownId));
|
||||
|
||||
// 若是数据库中的数据为null,则插入
|
||||
if (CollUtil.isEmpty(imageNames)) {
|
||||
if (CollUtil.isNotEmpty(strings)) {
|
||||
List<ImageName> list = strings.stream().map(fileName -> {
|
||||
ImageName imageName = new ImageName();
|
||||
imageName.setFileName(fileName);
|
||||
imageName.setMarkdownId(markdownId);
|
||||
return imageName;
|
||||
}).toList();
|
||||
// 批量插入新的文件名(使用MyBatis Plus批量插入)
|
||||
imageNameMapper.insert(list);
|
||||
}
|
||||
} else {
|
||||
// 数据库中已有记录,需要对比处理
|
||||
// 获取数据库中的文件名列表
|
||||
List<String> dbFileNames = imageNames.stream()
|
||||
.map(ImageName::getFileName)
|
||||
.toList();
|
||||
|
||||
// 找出需要新增的文件名(在strings中但不在数据库中)
|
||||
List<String> toInsert = strings.stream()
|
||||
.filter(fileName -> !dbFileNames.contains(fileName))
|
||||
.toList();
|
||||
|
||||
// 找出需要删除的记录(在数据库中但不在strings中)
|
||||
List<ImageName> toDelete = imageNames.stream()
|
||||
.filter(imageName -> !strings.contains(imageName.getFileName()))
|
||||
.toList();
|
||||
|
||||
// 插入新增的文件名
|
||||
if (CollUtil.isNotEmpty(toInsert)) {
|
||||
List<ImageName> insertList = toInsert.stream().map(fileName -> {
|
||||
ImageName imageName = new ImageName();
|
||||
imageName.setFileName(fileName);
|
||||
imageName.setMarkdownId(markdownId);
|
||||
return imageName;
|
||||
}).toList();
|
||||
|
||||
imageNameMapper.insert(insertList);
|
||||
}
|
||||
|
||||
// 删除不再需要的记录
|
||||
if (CollUtil.isNotEmpty(toDelete)) {
|
||||
List<Long> deleteIds = toDelete.stream()
|
||||
.map(ImageName::getId)
|
||||
.toList();
|
||||
imageNameMapper.deleteByIds(deleteIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import com.test.bijihoudaun.util.PasswordUtils;
|
||||
import com.test.bijihoudaun.util.UuidV7;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@@ -30,9 +32,9 @@ import java.util.Date;
|
||||
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
|
||||
|
||||
private static final int MIN_USERNAME_LENGTH = 2;
|
||||
private static final int MAX_USERNAME_LENGTH = 8;
|
||||
private static final int MAX_USERNAME_LENGTH = 20;
|
||||
private static final int MIN_PASSWORD_LENGTH = 6;
|
||||
private static final int MAX_PASSWORD_LENGTH = 12;
|
||||
private static final int MAX_PASSWORD_LENGTH = 128;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
@@ -43,7 +45,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
throw new UsernameNotFoundException("User not found with username: " + username);
|
||||
}
|
||||
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), new ArrayList<>());
|
||||
// 加载用户角色权限
|
||||
java.util.List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
String role = user.getRole();
|
||||
if (role == null || role.isEmpty()) {
|
||||
role = "USER"; // 默认角色
|
||||
}
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
|
||||
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -12,7 +12,10 @@ import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 图形验证码工具类
|
||||
@@ -33,11 +36,23 @@ public class CaptchaUtil {
|
||||
private static final int MAX_CAPTCHAS = 5000;
|
||||
|
||||
// 存储验证码:key=验证码ID,value=验证码记录
|
||||
private static final LRUCache<String, CaptchaRecord> captchaStore = new LRUCache<>(MAX_CAPTCHAS);
|
||||
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
private static final ConcurrentHashMap<String, CaptchaRecord> captchaStore = new ConcurrentHashMap<>();
|
||||
|
||||
// 安全随机数生成器
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
// 使用 ThreadLocal 确保线程安全
|
||||
private static final ThreadLocal<SecureRandom> random = ThreadLocal.withInitial(SecureRandom::new);
|
||||
|
||||
// 定期清理线程池
|
||||
private static final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "captcha-cleanup");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
static {
|
||||
// 每5分钟清理一次过期验证码
|
||||
cleanupScheduler.scheduleAtFixedRate(CaptchaUtil::cleanupExpiredCaptchas,
|
||||
5, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
private static class CaptchaRecord {
|
||||
String code;
|
||||
@@ -53,27 +68,6 @@ public class CaptchaUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 LRU 缓存实现
|
||||
*/
|
||||
private static class LRUCache<K, V> extends LinkedHashMap<K, V> {
|
||||
private final int maxSize;
|
||||
|
||||
LRUCache(int maxSize) {
|
||||
super(maxSize, 0.75f, true);
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
|
||||
boolean shouldRemove = size() > maxSize;
|
||||
if (shouldRemove) {
|
||||
log.warn("验证码存储达到上限,移除最旧的验证码: {}", eldest.getKey());
|
||||
}
|
||||
return shouldRemove;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码结果
|
||||
*/
|
||||
@@ -106,9 +100,6 @@ public class CaptchaUtil {
|
||||
throw new RuntimeException("服务器繁忙,请稍后再试");
|
||||
}
|
||||
|
||||
// 清理过期验证码
|
||||
cleanupExpiredCaptchas();
|
||||
|
||||
// 生成验证码ID
|
||||
String captchaId = UuidV7.uuid();
|
||||
|
||||
@@ -118,13 +109,8 @@ public class CaptchaUtil {
|
||||
// 生成图片
|
||||
String base64Image = generateImage(code);
|
||||
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
// 存储验证码
|
||||
captchaStore.put(captchaId, new CaptchaRecord(code));
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
|
||||
return new CaptchaResult(captchaId, base64Image);
|
||||
}
|
||||
@@ -140,20 +126,11 @@ public class CaptchaUtil {
|
||||
return false;
|
||||
}
|
||||
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
CaptchaRecord record = captchaStore.get(captchaId);
|
||||
if (record == null || record.isExpired()) {
|
||||
// 验证码不存在或已过期,移除
|
||||
if (record != null) {
|
||||
lock.readLock().unlock();
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
captchaStore.remove(captchaId);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
lock.readLock().lock();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -163,20 +140,10 @@ public class CaptchaUtil {
|
||||
|
||||
// 验证成功后立即删除(一次性使用)
|
||||
if (success) {
|
||||
lock.readLock().unlock();
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
captchaStore.remove(captchaId);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
lock.readLock().lock();
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,8 +152,9 @@ public class CaptchaUtil {
|
||||
private static String generateCode() {
|
||||
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
SecureRandom r = random.get();
|
||||
for (int i = 0; i < CAPTCHA_LENGTH; i++) {
|
||||
sb.append(chars.charAt(random.nextInt(chars.length())));
|
||||
sb.append(chars.charAt(r.nextInt(chars.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
@@ -196,56 +164,59 @@ public class CaptchaUtil {
|
||||
*/
|
||||
private static String generateImage(String code) {
|
||||
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
try {
|
||||
// 设置背景色
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
||||
g2d.setColor(Color.WHITE);
|
||||
g2d.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
||||
|
||||
// 绘制干扰线
|
||||
g.setColor(Color.LIGHT_GRAY);
|
||||
g2d.setColor(Color.LIGHT_GRAY);
|
||||
SecureRandom r = random.get();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
int x1 = random.nextInt(IMAGE_WIDTH);
|
||||
int y1 = random.nextInt(IMAGE_HEIGHT);
|
||||
int x2 = random.nextInt(IMAGE_WIDTH);
|
||||
int y2 = random.nextInt(IMAGE_HEIGHT);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
int x1 = r.nextInt(IMAGE_WIDTH);
|
||||
int y1 = r.nextInt(IMAGE_HEIGHT);
|
||||
int x2 = r.nextInt(IMAGE_WIDTH);
|
||||
int y2 = r.nextInt(IMAGE_HEIGHT);
|
||||
g2d.drawLine(x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
// 绘制验证码字符
|
||||
g.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
g2d.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
int x = 15;
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
// 随机颜色
|
||||
g.setColor(new Color(
|
||||
random.nextInt(100),
|
||||
random.nextInt(100),
|
||||
random.nextInt(100)
|
||||
g2d.setColor(new Color(
|
||||
r.nextInt(100),
|
||||
r.nextInt(100),
|
||||
r.nextInt(100)
|
||||
));
|
||||
// 随机旋转角度
|
||||
int angle = random.nextInt(30) - 15;
|
||||
g.rotate(Math.toRadians(angle), x + 10, 25);
|
||||
g.drawString(String.valueOf(code.charAt(i)), x, 28);
|
||||
g.rotate(-Math.toRadians(angle), x + 10, 25);
|
||||
int angle = r.nextInt(30) - 15;
|
||||
g2d.rotate(Math.toRadians(angle), x + 10, 25);
|
||||
g2d.drawString(String.valueOf(code.charAt(i)), x, 28);
|
||||
g2d.rotate(-Math.toRadians(angle), x + 10, 25);
|
||||
x += 25;
|
||||
}
|
||||
|
||||
// 添加噪点
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int x1 = random.nextInt(IMAGE_WIDTH);
|
||||
int y1 = random.nextInt(IMAGE_HEIGHT);
|
||||
int x1 = r.nextInt(IMAGE_WIDTH);
|
||||
int y1 = r.nextInt(IMAGE_HEIGHT);
|
||||
image.setRGB(x1, y1, Color.GRAY.getRGB());
|
||||
}
|
||||
|
||||
g.dispose();
|
||||
|
||||
// 转为 Base64
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, "png", baos);
|
||||
byte[] imageBytes = baos.toByteArray();
|
||||
return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(imageBytes);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("生成验证码图片失败", e);
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,16 +224,19 @@ public class CaptchaUtil {
|
||||
* 清理过期验证码
|
||||
*/
|
||||
private static void cleanupExpiredCaptchas() {
|
||||
// 每80%容量时清理一次,减少开销
|
||||
if (captchaStore.size() < MAX_CAPTCHAS * 0.8) {
|
||||
return;
|
||||
}
|
||||
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
captchaStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
int removed = 0;
|
||||
for (Map.Entry<String, CaptchaRecord> entry : captchaStore.entrySet()) {
|
||||
if (entry.getValue().isExpired()) {
|
||||
captchaStore.remove(entry.getKey());
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
log.debug("清理了 {} 个过期验证码", removed);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("清理过期验证码失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,11 +244,6 @@ public class CaptchaUtil {
|
||||
* 获取当前验证码数量(用于监控)
|
||||
*/
|
||||
public static int getCaptchaCount() {
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
return captchaStore.size();
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
/**
|
||||
* 登录锁定工具类
|
||||
* 使用本地内存存储登录失败记录,带容量限制
|
||||
* 使用本地内存存储登录失败记录,带容量限制和定期清理
|
||||
*/
|
||||
@Slf4j
|
||||
public class LoginLockUtil {
|
||||
@@ -28,6 +31,19 @@ public class LoginLockUtil {
|
||||
private static final LRUCache<String, LoginAttempt> attempts = new LRUCache<>(MAX_RECORDS);
|
||||
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
|
||||
// 定期清理线程池
|
||||
private static final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "login-lock-cleanup");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
static {
|
||||
// 每10分钟清理一次过期记录
|
||||
cleanupScheduler.scheduleAtFixedRate(LoginLockUtil::cleanupExpiredLocks,
|
||||
10, 10, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
private static class LoginAttempt {
|
||||
int failedCount;
|
||||
LocalDateTime lastAttemptTime;
|
||||
@@ -38,6 +54,10 @@ public class LoginLockUtil {
|
||||
this.lastAttemptTime = LocalDateTime.now();
|
||||
this.lockUntil = null;
|
||||
}
|
||||
|
||||
boolean isExpired() {
|
||||
return ChronoUnit.MINUTES.between(lastAttemptTime, LocalDateTime.now()) > RECORD_EXPIRE_MINUTES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,8 +88,6 @@ public class LoginLockUtil {
|
||||
public static void recordFailedAttempt(String username) {
|
||||
if (username == null || username.isEmpty()) return;
|
||||
|
||||
cleanupExpiredRecords();
|
||||
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
LoginAttempt attempt = attempts.computeIfAbsent(username, k -> new LoginAttempt());
|
||||
@@ -116,20 +134,7 @@ public class LoginLockUtil {
|
||||
|
||||
// 检查是否仍在锁定时间内
|
||||
if (attempt.lockUntil != null) {
|
||||
if (LocalDateTime.now().isBefore(attempt.lockUntil)) {
|
||||
return true;
|
||||
} else {
|
||||
// 锁定时间已过,清除记录
|
||||
lock.readLock().unlock();
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
attempts.remove(username);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
lock.readLock().lock();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return LocalDateTime.now().isBefore(attempt.lockUntil);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
@@ -177,14 +182,9 @@ public class LoginLockUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期记录
|
||||
* 清理过期的锁定记录
|
||||
*/
|
||||
private static void cleanupExpiredRecords() {
|
||||
// 每100次操作清理一次,减少开销
|
||||
if (attempts.size() < MAX_RECORDS * 0.8) {
|
||||
return;
|
||||
}
|
||||
|
||||
private static void cleanupExpiredLocks() {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -192,7 +192,7 @@ public class LoginLockUtil {
|
||||
LoginAttempt attempt = entry.getValue();
|
||||
// 未锁定且长时间没有登录的记录
|
||||
if (attempt.lockUntil == null) {
|
||||
return ChronoUnit.MINUTES.between(attempt.lastAttemptTime, now) > RECORD_EXPIRE_MINUTES;
|
||||
return attempt.isExpired();
|
||||
}
|
||||
// 锁定已过期的记录
|
||||
return now.isAfter(attempt.lockUntil);
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
-- 数据库性能优化索引
|
||||
-- 创建时间: 2024-03-03
|
||||
-- 数据库性能优化索引(完整修正版)
|
||||
|
||||
-- markdown_file 表索引
|
||||
-- 按分组查询
|
||||
CREATE INDEX IF NOT EXISTS idx_markdown_grouping_id ON markdown_file(grouping_id);
|
||||
-- 按删除状态查询(软删除)
|
||||
CREATE INDEX IF NOT EXISTS idx_markdown_is_deleted ON markdown_file(is_deleted);
|
||||
-- 按创建时间排序
|
||||
CREATE INDEX IF NOT EXISTS idx_markdown_created_at ON markdown_file(created_at);
|
||||
-- 复合索引:查询未删除的分组笔记
|
||||
CREATE INDEX IF NOT EXISTS idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted);
|
||||
-- ==================== markdown_file 表索引 ====================
|
||||
CREATE INDEX idx_markdown_grouping_id ON markdown_file(grouping_id);
|
||||
CREATE INDEX idx_markdown_is_deleted ON markdown_file(is_deleted);
|
||||
CREATE INDEX idx_markdown_created_at ON markdown_file(created_at);
|
||||
CREATE INDEX idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted);
|
||||
|
||||
-- image 表索引
|
||||
-- 按 markdown_id 查询(关联查询)
|
||||
CREATE INDEX IF NOT EXISTS idx_image_markdown_id ON image(markdown_id);
|
||||
-- 按存储文件名查询
|
||||
CREATE INDEX IF NOT EXISTS idx_image_stored_name ON image(stored_name);
|
||||
-- 按创建时间查询(清理旧图片)
|
||||
CREATE INDEX IF NOT EXISTS idx_image_created_at ON image(created_at);
|
||||
-- ==================== image 表索引 ====================
|
||||
CREATE INDEX idx_image_markdown_id ON image(markdown_id);
|
||||
CREATE INDEX idx_image_stored_name ON image(stored_name);
|
||||
CREATE INDEX idx_image_created_at ON image(created_at);
|
||||
|
||||
-- grouping 表索引
|
||||
-- 按父分组查询
|
||||
CREATE INDEX IF NOT EXISTS idx_grouping_parent_id ON grouping(parent_id);
|
||||
-- 按删除状态查询
|
||||
CREATE INDEX IF NOT EXISTS idx_grouping_is_deleted ON grouping(is_deleted);
|
||||
-- ==================== grouping 表索引(注意反引号!)====================
|
||||
CREATE INDEX idx_grouping_parent_id ON `grouping`(parentId);
|
||||
CREATE INDEX idx_grouping_is_deleted ON `grouping`(is_deleted);
|
||||
|
||||
-- trash 表索引
|
||||
-- 按用户查询
|
||||
CREATE INDEX IF NOT EXISTS idx_trash_user_id ON trash(user_id);
|
||||
-- 按删除时间排序(清理过期数据)
|
||||
CREATE INDEX IF NOT EXISTS idx_trash_deleted_at ON trash(deleted_at);
|
||||
-- 按类型查询
|
||||
CREATE INDEX IF NOT EXISTS idx_trash_item_type ON trash(item_type);
|
||||
-- ==================== trash 表索引 ====================
|
||||
CREATE INDEX idx_trash_user_id ON trash(user_id);
|
||||
CREATE INDEX idx_trash_deleted_at ON trash(deleted_at);
|
||||
CREATE INDEX idx_trash_item_type ON trash(item_type);
|
||||
|
||||
-- user 表索引
|
||||
-- 按用户名查询(登录)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
|
||||
-- ==================== user 表索引 ====================
|
||||
CREATE INDEX idx_user_username ON user(username);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 添加用户角色字段
|
||||
-- 创建时间: 2024-03-04
|
||||
|
||||
-- 为用户表添加 role 字段
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `role` VARCHAR(50) DEFAULT 'USER' COMMENT '用户角色:ADMIN-管理员,USER-普通用户';
|
||||
|
||||
-- 将第一个用户设置为管理员(可选,根据实际需求)
|
||||
-- UPDATE `user` SET `role` = 'ADMIN' WHERE `id` = 1;
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_user_role ON `user`(`role`);
|
||||
@@ -2,9 +2,14 @@ import axiosApi from '@/utils/axios.js'
|
||||
|
||||
|
||||
|
||||
export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${data}`)
|
||||
// 修复:使用 encodeURIComponent 编码 URL 参数,防止注入
|
||||
export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${encodeURIComponent(data)}`)
|
||||
// 获取所有分组
|
||||
export const groupingAll = (data) => axiosApi.get(`/api/groupings?parentId=${data}`);
|
||||
export const groupingAll = (data) => {
|
||||
const params = new URLSearchParams();
|
||||
if (data) params.append('parentId', data);
|
||||
return axiosApi.get(`/api/groupings?${params.toString()}`);
|
||||
};
|
||||
// 获取所有Markdown文件
|
||||
export const markdownAll = () => axiosApi.get(`/api/markdown`);
|
||||
// 预览markdown文件
|
||||
@@ -19,20 +24,15 @@ export const updateMarkdown = (data) => {
|
||||
return axiosApi.post(`/api/markdown/updateMarkdown`, data)
|
||||
}
|
||||
// 批量删除图片
|
||||
// 修复:后端接收 JSON 数组,不是 FormData
|
||||
export const deleteImages = (list) => {
|
||||
const formData = new FormData()
|
||||
formData.append('urls', list)
|
||||
return axiosApi.post('/api/images/batch', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
return axiosApi.post('/api/images/batch', list)
|
||||
}
|
||||
// 上传图片
|
||||
export const uploadImage = (file, userId, markdownId) => {
|
||||
// 修复:移除 userId 参数,后端从 SecurityContext 获取当前用户
|
||||
export const uploadImage = (file, markdownId) => {
|
||||
const formData = new FormData()
|
||||
if (file) formData.append('file', file)
|
||||
if (userId) formData.append('userId', userId)
|
||||
if (markdownId) formData.append('markdownId', markdownId)
|
||||
return axiosApi.post('/api/images', formData, {
|
||||
headers: {
|
||||
@@ -59,7 +59,11 @@ export const login = (data) => {
|
||||
}
|
||||
|
||||
// 搜索
|
||||
export const searchMarkdown = (keyword) => axiosApi.get(`/api/markdown/search?keyword=${keyword}`);
|
||||
export const searchMarkdown = (keyword) => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('keyword', keyword);
|
||||
return axiosApi.get(`/api/markdown/search?${params.toString()}`);
|
||||
};
|
||||
|
||||
// 注册
|
||||
export const register = (data) => {
|
||||
|
||||
@@ -134,6 +134,7 @@ import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import Vditor from 'vditor';
|
||||
import 'vditor/dist/index.css';
|
||||
import { escapeHtml } from '@/utils/security';
|
||||
import {
|
||||
groupingAll,
|
||||
markdownList,
|
||||
@@ -501,9 +502,11 @@ const handleExport = async (format) => {
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
downloadBlob(blob, `${title}.md`);
|
||||
} else if (format === 'html') {
|
||||
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${title}</h1>${previewElement.innerHTML}</body></html>`;
|
||||
// 修复:对title进行HTML转义,防止XSS
|
||||
const escapedTitle = escapeHtml(title);
|
||||
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapedTitle}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${escapedTitle}</h1>${previewElement.innerHTML}</body></html>`;
|
||||
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
|
||||
downloadBlob(blob, `${title}.html`);
|
||||
downloadBlob(blob, `${escapedTitle}.html`);
|
||||
} else if (format === 'pdf') {
|
||||
const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true });
|
||||
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
|
||||
|
||||
@@ -92,8 +92,14 @@ const initVditor = () => {
|
||||
const save = async (value) => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
// 修复:添加空值检查
|
||||
if (!vditor.value) {
|
||||
console.warn('编辑器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(saveTimeout);
|
||||
const content = typeof value === 'string' ? value : vditor.value.getValue();
|
||||
const content = typeof value === 'string' ? value : vditor.value?.getValue() || '';
|
||||
|
||||
if (content === lastSavedContent.value && currentId.value) {
|
||||
return;
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
>
|
||||
<div class="menu-section" v-if="isMobile">
|
||||
<div v-if="userStore.isLoggedIn" class="user-info">
|
||||
<el-avatar :size="40" class="user-avatar">{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() }}</el-avatar>
|
||||
<span class="username">{{ userStore.userInfo?.username }}</span>
|
||||
<!-- 修复:添加默认值防止空指针 -->
|
||||
<el-avatar :size="40" class="user-avatar">{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() || '?' }}</el-avatar>
|
||||
<span class="username">{{ userStore.userInfo?.username || '访客' }}</span>
|
||||
</div>
|
||||
<div v-else class="guest-info">
|
||||
<el-button type="primary" @click="goToLogin">登录</el-button>
|
||||
|
||||
@@ -92,8 +92,11 @@ instance.interceptors.response.use(
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 其他错误,显示后端返回的消息
|
||||
const msg = data?.msg || error.message;
|
||||
// 其他错误,生产环境隐藏详细错误信息
|
||||
const isDev = import.meta.env.DEV;
|
||||
const msg = isDev
|
||||
? (data?.msg || error.message)
|
||||
: (data?.msg || '操作失败,请稍后重试');
|
||||
ElMessage.error(msg);
|
||||
} else {
|
||||
ElMessage({
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
/**
|
||||
* 生成随机 nonce (32位随机字符串)
|
||||
* 使用 crypto.getRandomValues 确保密码学安全
|
||||
* @returns {string} nonce
|
||||
*/
|
||||
export function generateNonce() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let nonce = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return nonce;
|
||||
const array = new Uint8Array(24); // 24 bytes = 32 base64 chars
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/[+/=]/g, '') // 移除特殊字符
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,3 +61,15 @@ export function needsReplayAttackValidation(method, url) {
|
||||
}
|
||||
return REPLAY_ATTACK_METHODS.includes(method.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义函数,防止 XSS
|
||||
* @param {string} text 需要转义的文本
|
||||
* @returns {string} 转义后的文本
|
||||
*/
|
||||
export function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user