Compare commits
10 Commits
07454a28d2
...
46ac106f9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ac106f9b | ||
|
|
e495011f57 | ||
|
|
23ced99e20 | ||
|
|
5ea9c776e7 | ||
|
|
90626e73d9 | ||
|
|
25b52f87aa | ||
|
|
a4f95e7315 | ||
|
|
375ccb89ff | ||
|
|
d54719d82d | ||
|
|
64daf3cb0b |
@@ -67,6 +67,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- H2 数据库(测试用) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
@@ -126,13 +133,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<!-- Bouncy Castle 加密库 -->
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.76</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.test.bijihoudaun.common.response.ResultCode;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authorization.AuthorizationDeniedException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@@ -71,9 +72,28 @@ public class GlobalExceptionHandler {
|
||||
return R.fail(ResultCode.VALIDATE_FAILED.getCode(), "参数错误");
|
||||
}
|
||||
|
||||
// 修复:添加权限拒绝异常处理
|
||||
@ExceptionHandler(AuthorizationDeniedException.class)
|
||||
public R<Void> handleAuthorizationDeniedException(AuthorizationDeniedException e, HttpServletRequest request) {
|
||||
log.warn("Access denied at {}: {}", request.getRequestURI(), e.getMessage());
|
||||
return R.fail(ResultCode.FORBIDDEN.getCode(), "无权操作,需要管理员权限");
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public R<Void> handleException(Exception e, HttpServletRequest request) {
|
||||
log.error("Unexpected error at {} - Error type: {}", request.getRequestURI(), e.getClass().getSimpleName());
|
||||
return R.fail(ResultCode.FAILED.getCode(), "系统繁忙,请稍后再试");
|
||||
}
|
||||
|
||||
// 修复:处理 IllegalStateException(getWriter() 已调用等问题)
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public R<Void> handleIllegalStateException(IllegalStateException e, HttpServletRequest request) {
|
||||
log.warn("Illegal state at {}: {}", request.getRequestURI(), e.getMessage());
|
||||
// 如果是图片预览相关请求,可能是响应已经提交
|
||||
if (request.getRequestURI().contains("/api/images/preview")) {
|
||||
// 响应可能已经提交,直接返回 null,避免再次写入响应
|
||||
return null;
|
||||
}
|
||||
return R.fail(ResultCode.FAILED.getCode(), "请求处理失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
@@ -22,6 +23,7 @@ import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(prePostEnabled = true)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Autowired
|
||||
|
||||
@@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -23,6 +24,7 @@ public class GroupingController {
|
||||
private GroupingService groupingService;
|
||||
|
||||
@Operation(summary = "创建分组")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping
|
||||
public R<Grouping> createGrouping(@RequestBody Grouping grouping) {
|
||||
if (ObjectUtil.isNull(grouping.getParentId())) {
|
||||
@@ -47,22 +49,21 @@ public class GroupingController {
|
||||
}
|
||||
|
||||
@Operation(summary = "更新分组名称")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PutMapping("/{id}")
|
||||
public R<Grouping> updateGrouping(
|
||||
@PathVariable String id,
|
||||
@PathVariable Long id,
|
||||
@RequestBody Grouping grouping) {
|
||||
|
||||
long l = Long.parseLong(id);
|
||||
grouping.setId(l);
|
||||
grouping.setId(id);
|
||||
Grouping updated = groupingService.updateGrouping(grouping);
|
||||
return R.success(updated);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除分组")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@DeleteMapping("/{id}")
|
||||
public R<Void> deleteGrouping(@PathVariable String id) {
|
||||
Long idLong = Long.parseLong(id);
|
||||
groupingService.deleteGrouping(idLong);
|
||||
public R<Void> deleteGrouping(@PathVariable Long id) {
|
||||
groupingService.deleteGrouping(id);
|
||||
return R.success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@ 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.access.prepost.PreAuthorize;
|
||||
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 +23,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,40 +39,53 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "根据id删除图片")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/{id}")
|
||||
public R<Void> deleteImage(@PathVariable Long id) {
|
||||
if (!SecurityUtil.isUserAuthenticated()) {
|
||||
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 {
|
||||
// 注意:不能同时调用 resp.getWriter() 和 resp.getOutputStream()
|
||||
|
||||
if (StrUtil.isBlank(url)) {
|
||||
resp.setStatus(404);
|
||||
resp.setContentType("application/json;charset=UTF-8");
|
||||
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
@@ -74,6 +93,7 @@ public class ImageController {
|
||||
String sanitizedUrl = sanitizeFileName(url);
|
||||
if (sanitizedUrl == null) {
|
||||
resp.setStatus(403);
|
||||
resp.setContentType("application/json;charset=UTF-8");
|
||||
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
@@ -83,6 +103,7 @@ public class ImageController {
|
||||
|
||||
if (!filePath.startsWith(basePath)) {
|
||||
resp.setStatus(403);
|
||||
resp.setContentType("application/json;charset=UTF-8");
|
||||
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
@@ -90,6 +111,7 @@ public class ImageController {
|
||||
File file = filePath.toFile();
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
resp.setStatus(404);
|
||||
resp.setContentType("application/json;charset=UTF-8");
|
||||
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
@@ -97,8 +119,10 @@ public class ImageController {
|
||||
String contentTypeFromFileExtension = getContentTypeFromFileExtension(url);
|
||||
resp.setContentType(contentTypeFromFileExtension);
|
||||
resp.setContentLengthLong(file.length());
|
||||
// 文件流直接输出,不使用 try-with-resources 包装整个方法
|
||||
try (FileInputStream in = new FileInputStream(file)) {
|
||||
StreamUtils.copy(in, resp.getOutputStream());
|
||||
resp.getOutputStream().flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +130,9 @@ public class ImageController {
|
||||
if (StrUtil.isBlank(fileName)) {
|
||||
return null;
|
||||
}
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\") || fileName.contains(":")) {
|
||||
// 修复:使用白名单验证文件名格式(只允许 UUID 格式)
|
||||
// 例如:550e8400-e29b-41d4-a716-446655440000.jpg
|
||||
if (!fileName.matches("^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\\.[a-zA-Z0-9]+$")) {
|
||||
return null;
|
||||
}
|
||||
return fileName;
|
||||
@@ -114,40 +140,36 @@ public class ImageController {
|
||||
|
||||
|
||||
@Operation(summary = "根据url删除图片")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/deleteByUrl")
|
||||
public R<Void> deleteImageByUrl(@RequestParam String url) {
|
||||
if (!SecurityUtil.isUserAuthenticated()) {
|
||||
return R.fail("请先登录");
|
||||
}
|
||||
boolean result = imageService.deleteImageByUrl(url);
|
||||
if (result) {
|
||||
return R.success();
|
||||
} else {
|
||||
return R.fail();
|
||||
return R.fail("删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "根据url批量删除图片")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/batch")
|
||||
public R<Void> deleteImageByUrls(@RequestBody List<String> urls) {
|
||||
if (!SecurityUtil.isUserAuthenticated()) {
|
||||
return R.fail("请先登录");
|
||||
}
|
||||
boolean result = imageService.deleteImageByUrls(urls);
|
||||
if (result) {
|
||||
return R.success();
|
||||
} else {
|
||||
return R.fail();
|
||||
return R.fail("删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String getContentTypeFromFileExtension(String fileName) {
|
||||
if (StrUtil.isBlank(fileName) || !StrUtil.contains(fileName, '.')) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
// 使用更安全的 MIME 类型映射,只允许图片类型
|
||||
switch (extension) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
@@ -163,8 +185,27 @@ public class ImageController {
|
||||
case "svg":
|
||||
return "image/svg+xml";
|
||||
default:
|
||||
// 对于未知的扩展名,返回通用的二进制流类型,避免执行风险
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件是否为允许的图片类型
|
||||
* @param contentType 文件内容类型
|
||||
* @return 是否允许
|
||||
*/
|
||||
private boolean isAllowedImageType(String contentType) {
|
||||
if (StrUtil.isBlank(contentType)) {
|
||||
return false;
|
||||
}
|
||||
// 只允许标准的图片 MIME 类型
|
||||
return contentType.equals("image/jpeg") ||
|
||||
contentType.equals("image/png") ||
|
||||
contentType.equals("image/gif") ||
|
||||
contentType.equals("image/bmp") ||
|
||||
contentType.equals("image/webp") ||
|
||||
contentType.equals("image/svg+xml");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.test.bijihoudaun.controller;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.test.bijihoudaun.common.response.R;
|
||||
import com.test.bijihoudaun.entity.MarkdownFile;
|
||||
import com.test.bijihoudaun.entity.MarkdownFileChunk;
|
||||
import com.test.bijihoudaun.entity.MarkdownFileVO;
|
||||
import com.test.bijihoudaun.service.MarkdownFileService;
|
||||
import com.test.bijihoudaun.util.SecurityUtil;
|
||||
@@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.Parameters;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Date;
|
||||
@@ -55,6 +57,7 @@ public class MarkdownController {
|
||||
|
||||
|
||||
@Operation(summary = "更新Markdown文件")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/updateMarkdown")
|
||||
public R<MarkdownFile> updateMarkdown(@RequestBody MarkdownFile markdownFile) {
|
||||
MarkdownFile file = markdownFileService.updateMarkdownContent(markdownFile);
|
||||
@@ -69,6 +72,7 @@ public class MarkdownController {
|
||||
}
|
||||
|
||||
@Operation(summary = "删除Markdown文件")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Parameters({
|
||||
@Parameter(name = "id", description = "Markdown文件ID", required = true),
|
||||
})
|
||||
@@ -82,23 +86,29 @@ public class MarkdownController {
|
||||
|
||||
@Operation(summary = "根据分组ID获取Markdown文件")
|
||||
@GetMapping("/grouping/{groupingId}")
|
||||
public R<List<MarkdownFileVO>> getFilesByGroupingId(@PathVariable String groupingId) {
|
||||
public R<List<MarkdownFileVO>> getFilesByGroupingId(@PathVariable Long groupingId) {
|
||||
List<MarkdownFileVO> files = markdownFileService.getFilesByGroupingId(groupingId);
|
||||
return R.success(files);
|
||||
}
|
||||
|
||||
@Operation(summary = "根据标题模糊搜索")
|
||||
@GetMapping("/search")
|
||||
public R<List<MarkdownFile>> searchByTitle(@RequestParam String keyword) {
|
||||
List<MarkdownFile> files = markdownFileService.searchByTitle(keyword);
|
||||
public R<List<MarkdownFile>> searchByTitle(
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 100) pageSize = 20;
|
||||
List<MarkdownFile> files = markdownFileService.searchByTitle(keyword, page, pageSize);
|
||||
return R.success(files);
|
||||
}
|
||||
|
||||
@Operation(summary = "更新Markdown文件标题")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@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);
|
||||
@@ -113,4 +123,26 @@ public class MarkdownController {
|
||||
return R.success(files);
|
||||
}
|
||||
|
||||
@Operation(summary = "分块加载Markdown文件内容", description = "用于大文件(> 500KB)的分页加载")
|
||||
@Parameters({
|
||||
@Parameter(name = "id", description = "文件ID", required = true),
|
||||
@Parameter(name = "chunkIndex", description = "块索引(从0开始)", required = false),
|
||||
@Parameter(name = "chunkSize", description = "块大小(字符数),默认10000", required = false)
|
||||
})
|
||||
@GetMapping("/{id}/chunk")
|
||||
public R<MarkdownFileChunk> getMarkdownChunk(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "0") int chunkIndex,
|
||||
@RequestParam(defaultValue = "10000") int chunkSize) {
|
||||
|
||||
// 获取当前认证状态
|
||||
boolean isAuthenticated = SecurityUtil.isUserAuthenticated();
|
||||
|
||||
MarkdownFileChunk chunk = markdownFileService.getMarkdownChunk(id, chunkIndex, chunkSize, isAuthenticated);
|
||||
if (ObjectUtil.isNotNull(chunk)) {
|
||||
return R.success(chunk);
|
||||
}
|
||||
return R.fail("文件未找到");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -47,4 +45,4 @@ public class SystemController {
|
||||
String code = registrationCodeService.generateCode(currentUserName);
|
||||
return R.success(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.test.bijihoudaun.service.TrashService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -26,6 +27,7 @@ public class TrashController {
|
||||
}
|
||||
|
||||
@PostMapping("/restore/{type}/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "恢复项目")
|
||||
public R<Void> restoreItem(@PathVariable String type, @PathVariable String id) {
|
||||
trashService.restoreItem(id, type);
|
||||
@@ -33,6 +35,7 @@ public class TrashController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/permanently/{type}/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequireCaptcha("永久删除")
|
||||
@Operation(summary = "永久删除项目")
|
||||
public R<Void> permanentlyDeleteItem(@PathVariable String type, @PathVariable String id) {
|
||||
@@ -41,10 +44,11 @@ public class TrashController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/clean")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequireCaptcha("清空回收站")
|
||||
@Operation(summary = "清空回收站")
|
||||
public R<Void> cleanTrash() {
|
||||
trashService.cleanTrash();
|
||||
return R.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -76,6 +85,8 @@ public class UserController {
|
||||
userInfo.put("id", String.valueOf(user.getId()));
|
||||
userInfo.put("username", user.getUsername());
|
||||
userInfo.put("email", user.getEmail());
|
||||
String role = user.getRole();
|
||||
userInfo.put("role", (role != null && !role.isEmpty()) ? role : "USER");
|
||||
result.put("userInfo", userInfo);
|
||||
|
||||
return R.success(result);
|
||||
@@ -88,7 +99,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));
|
||||
|
||||
@@ -96,7 +112,7 @@ public class UserController {
|
||||
return R.fail("无法获取用户信息,删除失败");
|
||||
}
|
||||
|
||||
userService.deleteUser(user.getId().intValue());
|
||||
userService.deleteUser(user.getId());
|
||||
return R.success("用户删除成功");
|
||||
}
|
||||
|
||||
@@ -110,9 +126,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("密码更新成功");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.util.Date;
|
||||
|
||||
@Data
|
||||
@Schema(name = "文本实体")
|
||||
@TableName("`markdown_file`")
|
||||
@TableName(value = "`markdown_file`", autoResultMap = true)
|
||||
public class MarkdownFile implements Serializable {
|
||||
@Schema(description = "文本id",implementation = Long.class)
|
||||
@TableId(type = IdType.AUTO)
|
||||
@@ -62,4 +62,4 @@ public class MarkdownFile implements Serializable {
|
||||
@Schema(description = "是否私密 0-公开 1-私密", implementation = Integer.class)
|
||||
@TableField("is_private")
|
||||
private Integer isPrivate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.test.bijihoudaun.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Markdown文件分块加载响应实体
|
||||
* 用于大文件分页加载
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "Markdown文件分块")
|
||||
public class MarkdownFileChunk implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "当前块的内容")
|
||||
private String chunk;
|
||||
|
||||
@Schema(description = "当前块索引(从0开始)")
|
||||
private int chunkIndex;
|
||||
|
||||
@Schema(description = "总块数")
|
||||
private int totalChunks;
|
||||
|
||||
@Schema(description = "是否还有更多块")
|
||||
private boolean hasMore;
|
||||
|
||||
@Schema(description = "笔记ID")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private Long fileId;
|
||||
|
||||
@Schema(description = "笔记标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "是否私密 0-公开 1-私密")
|
||||
private Integer isPrivate;
|
||||
|
||||
@Schema(description = "总字符数")
|
||||
private long totalLength;
|
||||
|
||||
@Schema(description = "当前块字符数")
|
||||
private int chunkLength;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -46,4 +50,4 @@ public class User {
|
||||
@Schema(description = "用户token过期时间",implementation = Date.class)
|
||||
@TableField("token_enddata")
|
||||
private Date tokenEnddata;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ public class UserVO implements Serializable {
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "用户角色")
|
||||
private String role;
|
||||
|
||||
@Schema(description = "用户创建时间")
|
||||
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);
|
||||
// 创建ObjectMapper实例,用于对象与JSON之间的转换
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
// 将失败结果转换为JSON字符串并写入响应输出流
|
||||
response.getWriter().write(mapper.writeValueAsString(R.fail(resultCode)));
|
||||
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
|
||||
try (PrintWriter writer = response.getWriter()) {
|
||||
// 创建ObjectMapper实例,用于对象与JSON之间的转换
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
// 将失败结果转换为JSON字符串并写入响应输出流
|
||||
writer.write(mapper.writeValueAsString(R.fail(resultCode)));
|
||||
writer.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.test.bijihoudaun.common.response.R;
|
||||
import com.test.bijihoudaun.common.response.ResultCode;
|
||||
import com.test.bijihoudaun.util.MemoryProtector;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -11,12 +13,16 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
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;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 限流拦截器 - 支持按 IP 和按用户双重限流,带容量限制
|
||||
* 限流拦截器 - 支持按 IP 和按用户双重限流,使用 ConcurrentHashMap 提高并发性能
|
||||
* 带定期清理机制防止内存泄漏
|
||||
*/
|
||||
@Slf4j
|
||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
@@ -29,59 +35,110 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
private static final int MAX_LOGIN_REQUESTS_PER_MINUTE_USER = 10;
|
||||
// 时间窗口(毫秒)
|
||||
private static final long WINDOW_SIZE_MS = 60_000;
|
||||
// 最大存储记录数(防止内存溢出)
|
||||
private static final int MAX_RECORDS = 50000;
|
||||
// 清理间隔(毫秒):每5分钟清理一次
|
||||
private static final long CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// IP 级别限流
|
||||
private static final LRUCache<String, RequestCounter> ipCounters = new LRUCache<>(MAX_RECORDS / 2);
|
||||
private static final LRUCache<String, RequestCounter> ipLoginCounters = new LRUCache<>(MAX_RECORDS / 4);
|
||||
// 用户级别限流
|
||||
private static final LRUCache<String, RequestCounter> userCounters = new LRUCache<>(MAX_RECORDS / 4);
|
||||
private static final LRUCache<String, RequestCounter> userLoginCounters = new LRUCache<>(MAX_RECORDS / 4);
|
||||
// 使用 ConcurrentHashMap + 原子操作,避免锁竞争
|
||||
private static final ConcurrentHashMap<String, RequestCounter> ipCounters = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<String, RequestCounter> ipLoginCounters = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<String, RequestCounter> userCounters = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<String, RequestCounter> userLoginCounters = new ConcurrentHashMap<>();
|
||||
|
||||
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
// 定期清理线程池
|
||||
private ScheduledExecutorService cleanupScheduler;
|
||||
|
||||
private static class RequestCounter {
|
||||
int count;
|
||||
long windowStart;
|
||||
private final AtomicInteger count = new AtomicInteger(0);
|
||||
private volatile long windowStart;
|
||||
// 记录最后访问时间,用于清理
|
||||
private volatile long lastAccessTime;
|
||||
|
||||
RequestCounter() {
|
||||
this.count = 1;
|
||||
this.windowStart = System.currentTimeMillis();
|
||||
this.lastAccessTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
boolean incrementAndCheck(int maxRequests) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - windowStart > WINDOW_SIZE_MS) {
|
||||
// 新窗口
|
||||
count = 1;
|
||||
windowStart = now;
|
||||
return true;
|
||||
lastAccessTime = now;
|
||||
|
||||
synchronized (this) {
|
||||
if (now - windowStart > WINDOW_SIZE_MS) {
|
||||
// 进入新窗口
|
||||
windowStart = now;
|
||||
count.set(1);
|
||||
return true;
|
||||
}
|
||||
int currentCount = count.incrementAndGet();
|
||||
return currentCount <= maxRequests;
|
||||
}
|
||||
count++;
|
||||
return count <= maxRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否过期(超过2个时间窗口没有访问)
|
||||
*/
|
||||
boolean isExpired() {
|
||||
return System.currentTimeMillis() - lastAccessTime > WINDOW_SIZE_MS * 2;
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// 启动定期清理任务
|
||||
cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "rate-limit-cleanup");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredCounters,
|
||||
CLEANUP_INTERVAL_MS, CLEANUP_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||
log.info("限流拦截器初始化完成,清理间隔:{}分钟", CLEANUP_INTERVAL_MS / 60000);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
// 关闭清理线程池
|
||||
if (cleanupScheduler != null && !cleanupScheduler.isShutdown()) {
|
||||
cleanupScheduler.shutdown();
|
||||
try {
|
||||
if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
cleanupScheduler.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
cleanupScheduler.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
log.info("限流拦截器已销毁");
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的计数器
|
||||
*/
|
||||
private void cleanupExpiredCounters() {
|
||||
try {
|
||||
int removed = 0;
|
||||
removed += cleanupMap(ipCounters);
|
||||
removed += cleanupMap(ipLoginCounters);
|
||||
removed += cleanupMap(userCounters);
|
||||
removed += cleanupMap(userLoginCounters);
|
||||
if (removed > 0) {
|
||||
log.debug("限流计数器清理完成,移除 {} 个过期条目", removed);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("限流计数器清理失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 LRU 缓存实现
|
||||
* 清理单个 Map 中的过期条目
|
||||
* 使用 removeIf 方法避免并发修改异常
|
||||
*/
|
||||
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.debug("限流记录达到上限,移除最旧的记录");
|
||||
}
|
||||
return shouldRemove;
|
||||
}
|
||||
private int cleanupMap(ConcurrentHashMap<String, RequestCounter> map) {
|
||||
int sizeBefore = map.size();
|
||||
// 使用 ConcurrentHashMap 的 removeIf 方法,线程安全
|
||||
map.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
||||
return sizeBefore - map.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -117,19 +174,14 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
*/
|
||||
private boolean checkIpLimit(String clientIp, boolean isLoginRequest,
|
||||
HttpServletResponse response) throws Exception {
|
||||
LRUCache<String, RequestCounter> counters = isLoginRequest ? ipLoginCounters : ipCounters;
|
||||
ConcurrentHashMap<String, RequestCounter> counters = isLoginRequest ? ipLoginCounters : ipCounters;
|
||||
int maxRequests = isLoginRequest ? MAX_LOGIN_REQUESTS_PER_MINUTE : MAX_REQUESTS_PER_MINUTE;
|
||||
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
RequestCounter counter = counters.computeIfAbsent(clientIp, k -> new RequestCounter());
|
||||
RequestCounter counter = counters.computeIfAbsent(clientIp, k -> new RequestCounter());
|
||||
|
||||
if (!counter.incrementAndCheck(maxRequests)) {
|
||||
writeRateLimitResponse(response, "请求过于频繁,请稍后再试");
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
if (!counter.incrementAndCheck(maxRequests)) {
|
||||
writeRateLimitResponse(response, "请求过于频繁,请稍后再试");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -139,19 +191,14 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
*/
|
||||
private boolean checkUserLimit(String username, boolean isLoginRequest,
|
||||
HttpServletResponse response) throws Exception {
|
||||
LRUCache<String, RequestCounter> counters = isLoginRequest ? userLoginCounters : userCounters;
|
||||
ConcurrentHashMap<String, RequestCounter> counters = isLoginRequest ? userLoginCounters : userCounters;
|
||||
int maxRequests = isLoginRequest ? MAX_LOGIN_REQUESTS_PER_MINUTE_USER : MAX_REQUESTS_PER_MINUTE_USER;
|
||||
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
RequestCounter counter = counters.computeIfAbsent(username, k -> new RequestCounter());
|
||||
RequestCounter counter = counters.computeIfAbsent(username, k -> new RequestCounter());
|
||||
|
||||
if (!counter.incrementAndCheck(maxRequests)) {
|
||||
writeRateLimitResponse(response, "您的操作过于频繁,请稍后再试");
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
if (!counter.incrementAndCheck(maxRequests)) {
|
||||
writeRateLimitResponse(response, "您的操作过于频繁,请稍后再试");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,19 @@ package com.test.bijihoudaun.interceptor;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HtmlUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.test.bijihoudaun.common.response.R;
|
||||
import com.test.bijihoudaun.common.response.ResultCode;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* XSS 过滤拦截器
|
||||
@@ -14,12 +22,38 @@ import java.util.Enumeration;
|
||||
* 注意:此拦截器只能过滤 URL 参数和表单数据,无法过滤 @RequestBody 的 JSON 数据
|
||||
* JSON 数据的 XSS 过滤由 XssStringDeserializer 处理
|
||||
*/
|
||||
@Slf4j
|
||||
public class XSSInterceptor implements HandlerInterceptor {
|
||||
|
||||
// 修复:添加不需要检查的请求头白名单(浏览器标准请求头)
|
||||
private static final Set<String> HEADER_WHITELIST = new HashSet<>(Arrays.asList(
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"sec-fetch-dest",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-user",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cache-control",
|
||||
"connection",
|
||||
"host",
|
||||
"referer",
|
||||
"upgrade-insecure-requests",
|
||||
"content-type",
|
||||
"content-length",
|
||||
"origin"
|
||||
));
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// 过滤请求头
|
||||
filterHeaders(request);
|
||||
// 过滤请求头,发现 XSS 攻击则拒绝请求
|
||||
if (!filterHeaders(request, response)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 过滤请求参数(URL 参数和表单数据)
|
||||
filterParameters(request);
|
||||
@@ -29,24 +63,32 @@ public class XSSInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 过滤请求头
|
||||
* @return true-通过,false-拒绝请求
|
||||
*/
|
||||
private void filterHeaders(HttpServletRequest request) {
|
||||
private boolean filterHeaders(HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
if (headerNames == null) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
// 修复:跳过白名单中的请求头
|
||||
if (HEADER_WHITELIST.contains(headerName.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
String headerValue = request.getHeader(headerName);
|
||||
if (StrUtil.isNotBlank(headerValue)) {
|
||||
String filteredValue = HtmlUtil.filter(headerValue);
|
||||
// 注意:请求头无法直接修改,这里只是记录日志
|
||||
// 发现 XSS 内容,记录日志并拒绝请求
|
||||
if (!headerValue.equals(filteredValue)) {
|
||||
// 发现 XSS 内容,记录日志
|
||||
log.warn("检测到XSS攻击,请求头:{},原始值:{}", headerName, headerValue);
|
||||
writeErrorResponse(response, "检测到恶意内容,请求被拒绝");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,4 +107,20 @@ public class XSSInterceptor implements HandlerInterceptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入错误响应
|
||||
*/
|
||||
private void writeErrorResponse(HttpServletResponse response, String message) throws Exception {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(400);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
|
||||
try (PrintWriter writer = response.getWriter()) {
|
||||
writer.write(mapper.writeValueAsString(
|
||||
R.fail(ResultCode.FAILED.getCode(), message)
|
||||
));
|
||||
writer.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public interface MarkdownFileMapper extends BaseMapper<MarkdownFile> {
|
||||
"LEFT JOIN `grouping` g ON mf.grouping_id = g.id " +
|
||||
"WHERE mf.grouping_id = #{groupingId} AND mf.is_deleted = 0 " +
|
||||
"ORDER BY mf.updated_at DESC")
|
||||
List<MarkdownFileVO> selectByGroupingIdWithGrouping(@Param("groupingId") String groupingId);
|
||||
List<MarkdownFileVO> selectByGroupingIdWithGrouping(@Param("groupingId") Long groupingId);
|
||||
|
||||
/**
|
||||
* 查询已删除的笔记(不包含content大字段)
|
||||
|
||||
@@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Select;
|
||||
@Mapper
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
// 自定义查询方法示例
|
||||
@Select("SELECT id, `username`, `password`, `email`, created_at, updated_at, `token`, token_enddata FROM `user` WHERE username = #{username}")
|
||||
@Select("SELECT id, `username`, `password`, `email`, `role`, created_at, updated_at, `token`, token_enddata FROM `user` WHERE username = #{username}")
|
||||
User findByUsername(String username);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.test.bijihoudaun.scheduler;
|
||||
|
||||
import com.test.bijihoudaun.service.ImageCleanupService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -10,6 +11,7 @@ import org.springframework.stereotype.Component;
|
||||
* 定期自动清理冗余图片
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ImageCleanupScheduler {
|
||||
|
||||
@Autowired
|
||||
@@ -22,6 +24,7 @@ public class ImageCleanupScheduler {
|
||||
@Scheduled(cron = "0 0 3 * * ?")
|
||||
public void scheduledCleanup() {
|
||||
int deletedCount = imageCleanupService.cleanupRedundantImages();
|
||||
System.out.println("定时清理任务完成,清理了 " + deletedCount + " 个冗余图片");
|
||||
// 优化:使用日志框架代替 System.out.println
|
||||
log.info("定时清理任务完成,清理了 {} 个冗余图片", deletedCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.test.bijihoudaun.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.test.bijihoudaun.entity.MarkdownFile;
|
||||
import com.test.bijihoudaun.entity.MarkdownFileChunk;
|
||||
import com.test.bijihoudaun.entity.MarkdownFileVO;
|
||||
|
||||
import java.util.List;
|
||||
@@ -28,7 +29,7 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
|
||||
* @param groupingId 分组ID
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<MarkdownFileVO> getFilesByGroupingId(String groupingId);
|
||||
List<MarkdownFileVO> getFilesByGroupingId(Long groupingId);
|
||||
|
||||
/**
|
||||
* 删除Markdown文件
|
||||
@@ -48,9 +49,11 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
|
||||
/**
|
||||
* 根据标题模糊搜索
|
||||
* @param keyword 关键词
|
||||
* @param page 页码(从1开始)
|
||||
* @param pageSize 每页数量
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<MarkdownFile> searchByTitle(String keyword);
|
||||
List<MarkdownFile> searchByTitle(String keyword, int page, int pageSize);
|
||||
|
||||
/**
|
||||
* 更新Markdown文件标题
|
||||
@@ -66,4 +69,15 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<MarkdownFileVO> getRecentFiles(int limit);
|
||||
|
||||
/**
|
||||
* 分块加载Markdown文件内容
|
||||
* 用于大文件(> 500KB)的分页加载
|
||||
* @param id 文件ID
|
||||
* @param chunkIndex 块索引(从0开始)
|
||||
* @param chunkSize 块大小(字符数),默认10000
|
||||
* @param isAuthenticated 是否已认证
|
||||
* @return 文件块对象
|
||||
*/
|
||||
MarkdownFileChunk getMarkdownChunk(Long id, int chunkIndex, int chunkSize, boolean isAuthenticated);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public interface UserService extends IService<User> {
|
||||
* 用户删除
|
||||
* @param id 用户id
|
||||
*/
|
||||
void deleteUser(Integer id);
|
||||
void deleteUser(Long id);
|
||||
|
||||
/**
|
||||
* 查询用户token是否过期
|
||||
|
||||
@@ -40,11 +40,15 @@ public class GroupingServiceImpl
|
||||
|
||||
@Override
|
||||
public List<Grouping> getAllGroupings(Long parentId) {
|
||||
if (ObjectUtil.isNull(parentId)){
|
||||
return groupingMapper.selectList(null);
|
||||
LambdaQueryWrapper<Grouping> queryWrapper = new LambdaQueryWrapper<>();
|
||||
// 只查询未删除的分组
|
||||
queryWrapper.eq(Grouping::getIsDeleted, 0);
|
||||
if (ObjectUtil.isNotNull(parentId)){
|
||||
queryWrapper.eq(Grouping::getParentId, parentId);
|
||||
}
|
||||
return groupingMapper.selectList(new LambdaQueryWrapper<Grouping>()
|
||||
.eq(Grouping::getParentId, parentId));
|
||||
// 限制最大返回数量,防止内存溢出
|
||||
queryWrapper.last("LIMIT 1000");
|
||||
return groupingMapper.selectList(queryWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.test.bijihoudaun.common.exception.BusinessException;
|
||||
import com.test.bijihoudaun.entity.Image;
|
||||
import com.test.bijihoudaun.mapper.ImageMapper;
|
||||
import com.test.bijihoudaun.service.ImageService;
|
||||
import com.test.bijihoudaun.util.ImageCompressor;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -16,11 +17,15 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -33,9 +38,18 @@ public class ImageServiceImpl
|
||||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"
|
||||
);
|
||||
|
||||
|
||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
private static final Map<String, byte[]> FILE_SIGNATURES = Map.ofEntries(
|
||||
Map.entry("jpg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
|
||||
Map.entry("jpeg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
|
||||
Map.entry("png", new byte[]{(byte)0x89, 0x50, 0x4E, 0x47}),
|
||||
Map.entry("gif", new byte[]{0x47, 0x49, 0x46}),
|
||||
Map.entry("bmp", new byte[]{0x42, 0x4D}),
|
||||
Map.entry("webp", new byte[]{0x52, 0x49, 0x46, 0x46})
|
||||
);
|
||||
|
||||
@Value("${file.upload-dir}")
|
||||
private String uploadDir;
|
||||
@Resource
|
||||
@@ -71,6 +85,12 @@ public class ImageServiceImpl
|
||||
throw new BusinessException("文件内容类型无效");
|
||||
}
|
||||
|
||||
// 验证文件头
|
||||
String extWithoutDot = extension.substring(1).toLowerCase();
|
||||
if (!validateFileSignature(file, extWithoutDot)) {
|
||||
throw new BusinessException("文件内容与扩展名不匹配,可能是伪装的恶意文件");
|
||||
}
|
||||
|
||||
Path uploadPath = Paths.get(uploadDir);
|
||||
if (!Files.exists(uploadPath)) {
|
||||
Files.createDirectories(uploadPath);
|
||||
@@ -79,11 +99,29 @@ public class ImageServiceImpl
|
||||
String storedName = UUID.randomUUID() + extension;
|
||||
|
||||
Path filePath = uploadPath.resolve(storedName);
|
||||
Files.copy(file.getInputStream(), filePath);
|
||||
|
||||
// 优化:压缩图片后再保存,确保流关闭
|
||||
try (InputStream originalStream = file.getInputStream()) {
|
||||
InputStream compressedStream = ImageCompressor.compressIfNeeded(
|
||||
originalStream, contentType, file.getSize());
|
||||
|
||||
if (compressedStream != null) {
|
||||
// 使用压缩后的图片
|
||||
try (compressedStream) {
|
||||
Files.copy(compressedStream, filePath);
|
||||
}
|
||||
} else {
|
||||
// 使用原图,需要重新获取流
|
||||
try (InputStream newStream = file.getInputStream()) {
|
||||
Files.copy(newStream, filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image image = new Image();
|
||||
image.setOriginalName(originalFilename);
|
||||
image.setStoredName(storedName);
|
||||
// 返回相对路径,前端会根据环境自动拼接 baseURL
|
||||
image.setUrl("/api/images/preview/" + storedName);
|
||||
image.setSize(file.getSize());
|
||||
image.setContentType(contentType);
|
||||
@@ -94,6 +132,18 @@ public class ImageServiceImpl
|
||||
return image;
|
||||
}
|
||||
|
||||
private boolean validateFileSignature(MultipartFile file, String extension) throws IOException {
|
||||
byte[] signature = FILE_SIGNATURES.get(extension);
|
||||
if (signature == null) return true;
|
||||
|
||||
byte[] fileHeader = new byte[Math.max(4, signature.length)];
|
||||
try (InputStream is = file.getInputStream()) {
|
||||
int read = is.read(fileHeader);
|
||||
if (read < signature.length) return false;
|
||||
return Arrays.equals(Arrays.copyOf(fileHeader, signature.length), signature);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteImage(Long id) {
|
||||
Image image = imageMapper.selectById(id);
|
||||
|
||||
@@ -2,20 +2,22 @@ package com.test.bijihoudaun.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.test.bijihoudaun.entity.ImageName;
|
||||
import com.test.bijihoudaun.entity.MarkdownFile;
|
||||
import com.test.bijihoudaun.entity.MarkdownFileChunk;
|
||||
import com.test.bijihoudaun.entity.MarkdownFileVO;
|
||||
import com.test.bijihoudaun.mapper.ImageNameMapper;
|
||||
import com.test.bijihoudaun.mapper.MarkdownFileMapper;
|
||||
import com.test.bijihoudaun.service.MarkdownFileService;
|
||||
import com.test.bijihoudaun.util.MarkdownImageExtractor;
|
||||
import com.test.bijihoudaun.util.SecurityUtil;
|
||||
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 +36,8 @@ public class MarkdownFileServiceImpl
|
||||
ImageNameMapper imageNameMapper;
|
||||
@Resource
|
||||
SnowflakeIdGenerator snowflakeIdGenerator;
|
||||
@Resource
|
||||
ImageNameSyncService imageNameSyncService;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -77,7 +81,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) {
|
||||
@@ -103,16 +108,18 @@ public class MarkdownFileServiceImpl
|
||||
|
||||
|
||||
@Override
|
||||
public List<MarkdownFileVO> getFilesByGroupingId(String groupingId) {
|
||||
public List<MarkdownFileVO> getFilesByGroupingId(Long groupingId) {
|
||||
return markdownFileMapper.selectByGroupingIdWithGrouping(groupingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteMarkdownFile(Long id) {
|
||||
Long currentUserId = SecurityUtil.getCurrentUserId();
|
||||
LambdaUpdateWrapper<MarkdownFile> updateWrapper = new LambdaUpdateWrapper<>();
|
||||
updateWrapper.eq(MarkdownFile::getId, id)
|
||||
.set(MarkdownFile::getIsDeleted, 1)
|
||||
.set(MarkdownFile::getDeletedAt, new Date());
|
||||
.set(MarkdownFile::getDeletedAt, new Date())
|
||||
.set(MarkdownFile::getDeletedBy, currentUserId);
|
||||
return this.update(updateWrapper);
|
||||
}
|
||||
|
||||
@@ -127,9 +134,16 @@ public class MarkdownFileServiceImpl
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MarkdownFile> searchByTitle(String keyword) {
|
||||
QueryWrapper<MarkdownFile> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.like("title", keyword);
|
||||
public List<MarkdownFile> searchByTitle(String keyword, int page, int pageSize) {
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
int offset = (page - 1) * pageSize;
|
||||
LambdaQueryWrapper<MarkdownFile> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.like(MarkdownFile::getTitle, keyword)
|
||||
.eq(MarkdownFile::getIsDeleted, 0)
|
||||
.orderByDesc(MarkdownFile::getUpdatedAt)
|
||||
.last("LIMIT " + offset + "," + pageSize);
|
||||
return this.list(queryWrapper);
|
||||
}
|
||||
|
||||
@@ -149,62 +163,92 @@ 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));
|
||||
@Override
|
||||
public MarkdownFileChunk getMarkdownChunk(Long id, int chunkIndex, int chunkSize, boolean isAuthenticated) {
|
||||
// 获取文件基本信息(不包含内容)
|
||||
MarkdownFileVO fileVO = markdownFileMapper.selectByIdWithGrouping(id);
|
||||
if (fileVO == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 若是数据库中的数据为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);
|
||||
// 检查权限:私密笔记需要登录
|
||||
if (fileVO.getIsPrivate() != null && fileVO.getIsPrivate() == 1 && !isAuthenticated) {
|
||||
MarkdownFileChunk chunk = new MarkdownFileChunk();
|
||||
chunk.setFileId(id);
|
||||
chunk.setTitle(fileVO.getTitle());
|
||||
chunk.setIsPrivate(fileVO.getIsPrivate());
|
||||
chunk.setChunk("该笔记为私密笔记,请登录后查看");
|
||||
chunk.setChunkIndex(0);
|
||||
chunk.setTotalChunks(1);
|
||||
chunk.setHasMore(false);
|
||||
chunk.setTotalLength(0);
|
||||
chunk.setChunkLength(0);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
// 获取完整内容
|
||||
MarkdownFile file = this.getById(id);
|
||||
if (file == null || file.getContent() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String fullContent = file.getContent();
|
||||
int totalLength = fullContent.length();
|
||||
|
||||
// 如果文件小于 500KB(约50万字符),直接返回全部内容
|
||||
if (totalLength <= 500000) {
|
||||
MarkdownFileChunk chunk = new MarkdownFileChunk();
|
||||
chunk.setFileId(id);
|
||||
chunk.setTitle(file.getTitle());
|
||||
chunk.setIsPrivate(file.getIsPrivate());
|
||||
chunk.setChunk(fullContent);
|
||||
chunk.setChunkIndex(0);
|
||||
chunk.setTotalChunks(1);
|
||||
chunk.setHasMore(false);
|
||||
chunk.setTotalLength(totalLength);
|
||||
chunk.setChunkLength(totalLength);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
// 大文件分页加载
|
||||
// 计算总块数
|
||||
int totalChunks = (int) Math.ceil((double) totalLength / chunkSize);
|
||||
|
||||
// 确保 chunkIndex 在有效范围内
|
||||
if (chunkIndex < 0) {
|
||||
chunkIndex = 0;
|
||||
}
|
||||
if (chunkIndex >= totalChunks) {
|
||||
chunkIndex = totalChunks - 1;
|
||||
}
|
||||
|
||||
// 计算当前块的起止位置
|
||||
int start = chunkIndex * chunkSize;
|
||||
int end = Math.min(start + chunkSize, totalLength);
|
||||
|
||||
// 截取内容
|
||||
String chunkContent = fullContent.substring(start, end);
|
||||
|
||||
// 如果不是第一块,尝试从完整的换行处开始
|
||||
if (chunkIndex > 0 && start < totalLength) {
|
||||
// 找到第一个换行符,从下一行开始显示
|
||||
int firstNewLine = chunkContent.indexOf('\n');
|
||||
if (firstNewLine > 0) {
|
||||
chunkContent = chunkContent.substring(firstNewLine + 1);
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownFileChunk chunk = new MarkdownFileChunk();
|
||||
chunk.setFileId(id);
|
||||
chunk.setTitle(file.getTitle());
|
||||
chunk.setIsPrivate(file.getIsPrivate());
|
||||
chunk.setChunk(chunkContent);
|
||||
chunk.setChunkIndex(chunkIndex);
|
||||
chunk.setTotalChunks(totalChunks);
|
||||
chunk.setHasMore(chunkIndex < totalChunks - 1);
|
||||
chunk.setTotalLength(totalLength);
|
||||
chunk.setChunkLength(chunkContent.length());
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,18 @@ import java.util.UUID;
|
||||
@Transactional
|
||||
public class RegistrationCodeServiceImpl extends ServiceImpl<RegistrationCodeMapper, RegistrationCode> implements RegistrationCodeService {
|
||||
|
||||
// 修复:定义日期时间格式器
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Override
|
||||
public String generateCode(String creator) {
|
||||
RegistrationCode registrationCode = new RegistrationCode();
|
||||
String code = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 16);
|
||||
registrationCode.setCode(code);
|
||||
registrationCode.setCreatedBy(creator);
|
||||
registrationCode.setCreatedAt(LocalDateTime.now().toString());
|
||||
registrationCode.setExpiryTime(LocalDateTime.now().plusDays(1).toString());
|
||||
// 修复:使用格式化后的日期字符串
|
||||
registrationCode.setCreatedAt(LocalDateTime.now().format(DATE_TIME_FORMATTER));
|
||||
registrationCode.setExpiryTime(LocalDateTime.now().plusDays(1).format(DATE_TIME_FORMATTER));
|
||||
save(registrationCode);
|
||||
return code;
|
||||
}
|
||||
@@ -41,9 +45,16 @@ public class RegistrationCodeServiceImpl extends ServiceImpl<RegistrationCodeMap
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalDateTime expiryTime = LocalDateTime.parse(registrationCode.getExpiryTime());
|
||||
if (expiryTime.isBefore(LocalDateTime.now())) {
|
||||
remove(queryWrapper); // 注册码过期,删除
|
||||
try {
|
||||
// 修复:使用格式器解析日期
|
||||
LocalDateTime expiryTime = LocalDateTime.parse(registrationCode.getExpiryTime(), DATE_TIME_FORMATTER);
|
||||
if (expiryTime.isBefore(LocalDateTime.now())) {
|
||||
remove(queryWrapper); // 注册码过期,删除
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 如果解析失败(可能是旧数据格式),认为已过期
|
||||
remove(queryWrapper);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -55,6 +66,7 @@ public class RegistrationCodeServiceImpl extends ServiceImpl<RegistrationCodeMap
|
||||
@Override
|
||||
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行
|
||||
public void deleteExpiredCodes() {
|
||||
remove(new QueryWrapper<RegistrationCode>().lt("expiry_time", LocalDateTime.now().toString()));
|
||||
// 修复:使用格式化后的日期字符串进行比较
|
||||
remove(new QueryWrapper<RegistrationCode>().lt("expiry_time", LocalDateTime.now().format(DATE_TIME_FORMATTER)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import com.test.bijihoudaun.service.UserService;
|
||||
import com.test.bijihoudaun.util.JwtTokenUtil;
|
||||
import com.test.bijihoudaun.util.LoginLockUtil;
|
||||
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 +31,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 +44,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
|
||||
@@ -78,6 +86,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
user.setUsername(username);
|
||||
user.setPassword(encrypt);
|
||||
user.setEmail(email);
|
||||
user.setRole("USER"); // 设置默认角色
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
@@ -102,7 +111,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (remainingAttempts <= 0) {
|
||||
throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟");
|
||||
}
|
||||
throw new BadCredentialsException("用户名或密码错误,还剩" + remainingAttempts + "次机会");
|
||||
throw new BadCredentialsException("登录失败,请稍后重试");
|
||||
}
|
||||
|
||||
// 登录成功,清除失败记录
|
||||
@@ -116,7 +125,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUser(Integer id) {
|
||||
public void deleteUser(Long id) {
|
||||
userMapper.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -135,7 +144,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
|
||||
|
||||
String newPassword = updatePasswordBo.getNewPassword();
|
||||
if (newPassword == null || newPassword.isEmpty()) {
|
||||
throw new BusinessException("新密码不能为空");
|
||||
@@ -143,10 +152,15 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (newPassword.length() < MIN_PASSWORD_LENGTH || newPassword.length() > MAX_PASSWORD_LENGTH) {
|
||||
throw new BusinessException("密码长度必须在" + MIN_PASSWORD_LENGTH + "-" + MAX_PASSWORD_LENGTH + "位之间");
|
||||
}
|
||||
|
||||
|
||||
if (!PasswordUtils.verify(updatePasswordBo.getOldPassword(), user.getPassword())) {
|
||||
throw new BusinessException("旧密码不正确");
|
||||
}
|
||||
|
||||
if (PasswordUtils.verify(newPassword, user.getPassword())) {
|
||||
throw new BusinessException("新密码不能与旧密码相同");
|
||||
}
|
||||
|
||||
user.setPassword(PasswordUtils.encrypt(newPassword));
|
||||
updateById(user);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
// 存储验证码
|
||||
captchaStore.put(captchaId, new CaptchaRecord(code));
|
||||
|
||||
return new CaptchaResult(captchaId, base64Image);
|
||||
}
|
||||
@@ -140,43 +126,24 @@ 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;
|
||||
CaptchaRecord record = captchaStore.get(captchaId);
|
||||
if (record == null || record.isExpired()) {
|
||||
// 验证码不存在或已过期,移除
|
||||
if (record != null) {
|
||||
captchaStore.remove(captchaId);
|
||||
}
|
||||
|
||||
// 验证码比对(不区分大小写)
|
||||
boolean success = record.code.equalsIgnoreCase(code);
|
||||
|
||||
// 验证成功后立即删除(一次性使用)
|
||||
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();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证码比对(不区分大小写)
|
||||
boolean success = record.code.equalsIgnoreCase(code);
|
||||
|
||||
// 验证成功后立即删除(一次性使用)
|
||||
if (success) {
|
||||
captchaStore.remove(captchaId);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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();
|
||||
|
||||
// 设置背景色
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
||||
try {
|
||||
// 设置背景色
|
||||
g2d.setColor(Color.WHITE);
|
||||
g2d.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
||||
|
||||
// 绘制干扰线
|
||||
g.setColor(Color.LIGHT_GRAY);
|
||||
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);
|
||||
}
|
||||
// 绘制干扰线
|
||||
g2d.setColor(Color.LIGHT_GRAY);
|
||||
SecureRandom r = random.get();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
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));
|
||||
int x = 15;
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
// 随机颜色
|
||||
g.setColor(new Color(
|
||||
random.nextInt(100),
|
||||
random.nextInt(100),
|
||||
random.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);
|
||||
x += 25;
|
||||
}
|
||||
// 绘制验证码字符
|
||||
g2d.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
int x = 15;
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
// 随机颜色
|
||||
g2d.setColor(new Color(
|
||||
r.nextInt(100),
|
||||
r.nextInt(100),
|
||||
r.nextInt(100)
|
||||
));
|
||||
// 随机旋转角度
|
||||
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);
|
||||
image.setRGB(x1, y1, Color.GRAY.getRGB());
|
||||
}
|
||||
// 添加噪点
|
||||
for (int i = 0; i < 30; i++) {
|
||||
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);
|
||||
// 转为 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();
|
||||
}
|
||||
return captchaStore.size();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.test.bijihoudaun.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* 图片压缩工具类
|
||||
* 用于压缩上传的图片,减少存储和带宽
|
||||
*/
|
||||
@Slf4j
|
||||
public class ImageCompressor {
|
||||
|
||||
// 最大宽度(像素)
|
||||
private static final int MAX_WIDTH = 1920;
|
||||
// 最大高度(像素)
|
||||
private static final int MAX_HEIGHT = 1080;
|
||||
// 压缩质量(0.0 - 1.0)
|
||||
private static final float COMPRESS_QUALITY = 0.85f;
|
||||
// 需要压缩的最小文件大小(字节)
|
||||
private static final long COMPRESS_THRESHOLD = 500 * 1024; // 500KB
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
* @param inputStream 原始图片输入流
|
||||
* @param contentType 图片类型
|
||||
* @param originalSize 原始文件大小
|
||||
* @return 压缩后的输入流,如果不需要压缩则返回null
|
||||
*/
|
||||
public static InputStream compressIfNeeded(InputStream inputStream, String contentType, long originalSize) {
|
||||
// 小于阈值的图片不压缩
|
||||
if (originalSize < COMPRESS_THRESHOLD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 只压缩 JPG 和 PNG
|
||||
if (!contentType.equals("image/jpeg") && !contentType.equals("image/png")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedImage originalImage = ImageIO.read(inputStream);
|
||||
if (originalImage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算新的尺寸
|
||||
int originalWidth = originalImage.getWidth();
|
||||
int originalHeight = originalImage.getHeight();
|
||||
|
||||
// 如果图片尺寸小于限制,只进行质量压缩
|
||||
int newWidth = originalWidth;
|
||||
int newHeight = originalHeight;
|
||||
|
||||
// 如果图片尺寸超过限制,等比例缩放
|
||||
if (originalWidth > MAX_WIDTH || originalHeight > MAX_HEIGHT) {
|
||||
double scale = Math.min(
|
||||
(double) MAX_WIDTH / originalWidth,
|
||||
(double) MAX_HEIGHT / originalHeight
|
||||
);
|
||||
newWidth = (int) (originalWidth * scale);
|
||||
newHeight = (int) (originalHeight * scale);
|
||||
}
|
||||
|
||||
// 创建压缩后的图片
|
||||
BufferedImage compressedImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = compressedImage.createGraphics();
|
||||
|
||||
// 设置高质量渲染
|
||||
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 绘制图片
|
||||
g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
|
||||
g2d.dispose();
|
||||
|
||||
// 输出为字节流
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
String formatName = contentType.equals("image/png") ? "png" : "jpg";
|
||||
ImageIO.write(compressedImage, formatName, baos);
|
||||
|
||||
byte[] compressedBytes = baos.toByteArray();
|
||||
|
||||
// 如果压缩后更大,返回null使用原图
|
||||
if (compressedBytes.length >= originalSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("图片压缩成功:{} -> {} ({}%)",
|
||||
formatSize(originalSize),
|
||||
formatSize(compressedBytes.length),
|
||||
(compressedBytes.length * 100 / originalSize));
|
||||
|
||||
return new ByteArrayInputStream(compressedBytes);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("图片压缩失败,使用原图", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
private static String formatSize(long size) {
|
||||
if (size < 1024) {
|
||||
return size + "B";
|
||||
} else if (size < 1024 * 1024) {
|
||||
return String.format("%.2fKB", size / 1024.0);
|
||||
} else {
|
||||
return String.format("%.2fMB", size / (1024.0 * 1024));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,14 @@ public class JwtTokenUtil {
|
||||
private Long expiration;
|
||||
|
||||
private Key key;
|
||||
// 优化:复用 JwtParser,避免每次请求都创建
|
||||
private io.jsonwebtoken.JwtParser jwtParser;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
// 优化:只创建一次 parser
|
||||
this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
|
||||
}
|
||||
|
||||
// 从token中获取用户名
|
||||
@@ -47,9 +51,9 @@ public class JwtTokenUtil {
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
// 为了从token中获取任何信息,我们都需要密钥
|
||||
// 优化:使用复用的 parser
|
||||
private Claims getAllClaimsFromToken(String token) {
|
||||
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
|
||||
return jwtParser.parseClaimsJws(token).getBody();
|
||||
}
|
||||
|
||||
// 检查token是否过期
|
||||
@@ -76,4 +80,4 @@ public class JwtTokenUtil {
|
||||
final String username = getUsernameFromToken(token);
|
||||
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,16 @@ import java.util.ArrayList;
|
||||
*/
|
||||
public class MarkdownImageExtractor {
|
||||
|
||||
// 优化:预编译正则表达式,避免每次调用都编译
|
||||
private static final Pattern IMAGE_FILENAME_PATTERN = Pattern.compile(
|
||||
"!\\[.*?\\]\\([^)]*?([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\\.[a-zA-Z0-9]+)\\)"
|
||||
);
|
||||
|
||||
// 优化:预编译URL匹配正则
|
||||
private static final Pattern IMAGE_URL_PATTERN = Pattern.compile(
|
||||
"!\\[.*?\\]\\(([^)]+)\\)"
|
||||
);
|
||||
|
||||
/**
|
||||
* 从Markdown内容中提取图片文件名
|
||||
* 支持各种URL格式:
|
||||
@@ -26,10 +36,8 @@ public class MarkdownImageExtractor {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 使用正则表达式匹配Markdown图片语法中的文件名
|
||||
// 模式:  其中url以UUID格式的文件名结尾
|
||||
Pattern pattern = Pattern.compile("!\\[.*?\\]\\([^)]*?([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\\.[a-zA-Z0-9]+)\\)");
|
||||
Matcher matcher = pattern.matcher(markdownContent);
|
||||
// 使用预编译的正则表达式
|
||||
Matcher matcher = IMAGE_FILENAME_PATTERN.matcher(markdownContent);
|
||||
|
||||
List<String> filenames = new ArrayList<>();
|
||||
while (matcher.find()) {
|
||||
@@ -65,9 +73,8 @@ public class MarkdownImageExtractor {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 匹配Markdown图片语法中的完整URL
|
||||
Pattern pattern = Pattern.compile("!\\[.*?\\]\\(([^)]+)\\)");
|
||||
Matcher matcher = pattern.matcher(markdownContent);
|
||||
// 使用预编译的正则表达式
|
||||
Matcher matcher = IMAGE_URL_PATTERN.matcher(markdownContent);
|
||||
|
||||
List<String> urls = new ArrayList<>();
|
||||
while (matcher.find()) {
|
||||
|
||||
@@ -110,10 +110,23 @@ public class SecurityUtil {
|
||||
/**
|
||||
* 检查当前用户是否为管理员
|
||||
* 在本项目中,管理员角色定义为"ADMIN"
|
||||
*
|
||||
*
|
||||
* @return 如果用户是管理员返回true,否则返回false
|
||||
*/
|
||||
public static boolean isAdmin() {
|
||||
return hasRole("ADMIN");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前已认证用户的ID
|
||||
*
|
||||
* @return 当前用户的ID,如果用户未认证则返回null
|
||||
*/
|
||||
public static Long getCurrentUserId() {
|
||||
String username = getCurrentUsername();
|
||||
if (username == null) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,47 @@ knife4j:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
# 服务器配置
|
||||
server:
|
||||
port: 8084
|
||||
servlet:
|
||||
context-path: /
|
||||
|
||||
# 文件上传路径
|
||||
file:
|
||||
upload-dir: /data/uploads
|
||||
|
||||
# 内存保护阈值 (MB)
|
||||
memory:
|
||||
threshold: 200
|
||||
|
||||
# Snowflake ID 配置
|
||||
worker:
|
||||
id: 1
|
||||
datacenter:
|
||||
id: 1
|
||||
|
||||
# JWT 配置 - 使用环境变量
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: 86400
|
||||
header: Authorization
|
||||
tokenHead: "Bearer "
|
||||
|
||||
# 管理员用户名
|
||||
admin:
|
||||
username: ${ADMIN_USERNAME}
|
||||
|
||||
# 日志配置 - 生产环境只记录 WARN 及以上
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.test.bijihoudaun: INFO
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: /var/log/biji/application.log
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
@@ -1,12 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
url: jdbc:sqlite:/data/mydatabase.db
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.SQLiteDialect
|
||||
@@ -1,6 +1,6 @@
|
||||
# 服务器配置
|
||||
server:
|
||||
port: 8084
|
||||
forward-headers-strategy: framework
|
||||
|
||||
spring:
|
||||
web:
|
||||
@@ -12,13 +12,16 @@ spring:
|
||||
active: dev
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB # ???????5MB
|
||||
max-request-size: 10MB # ???????5MB
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
# 文件上传路径
|
||||
file:
|
||||
upload-dir: uploads
|
||||
|
||||
|
||||
#??
|
||||
# 内存保护阈值 (MB)
|
||||
memory:
|
||||
threshold: 100
|
||||
|
||||
|
||||
## Snowflake ID?????
|
||||
@@ -29,11 +32,20 @@ worker:
|
||||
datacenter:
|
||||
id: 1
|
||||
|
||||
|
||||
|
||||
# JWT 配置
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:V2VsbCwgSSBzdXBwb3NlIHRoYXQgaWYgeW91J3JlIHJlYWRpbmcgdGhpcywgeW91J3JlIHByZXR0eSBjdXJpb3VzLg==}
|
||||
expiration: 86400
|
||||
header: Authorization
|
||||
tokenHead: "Bearer "
|
||||
|
||||
# 管理员用户名配置
|
||||
admin:
|
||||
username: ${ADMIN_USERNAME:admin}
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.test.bijihoudaun: INFO
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 数据库性能优化索引(完整修正版)
|
||||
|
||||
-- ==================== 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 表索引 ====================
|
||||
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 idx_grouping_parent_id ON `grouping`(parentId);
|
||||
CREATE INDEX idx_grouping_is_deleted ON `grouping`(is_deleted);
|
||||
|
||||
-- ==================== 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 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`);
|
||||
28
biji-qianduan/package-lock.json
generated
28
biji-qianduan/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@kangc/v-md-editor": "^2.2.4",
|
||||
"codemirror": "^6.0.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"element-plus": "^2.7.6",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
@@ -2585,10 +2586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
|
||||
"integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.10.4",
|
||||
@@ -3414,16 +3418,6 @@
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf/node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.13.24",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.13.24.tgz",
|
||||
@@ -3705,6 +3699,12 @@
|
||||
"web-worker": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/dompurify": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
|
||||
"integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/mermaid/node_modules/katex": {
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@kangc/v-md-editor": "^2.2.4",
|
||||
"codemirror": "^6.0.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"element-plus": "^2.7.6",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
@@ -24,7 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.1",
|
||||
"terser": "^5.31.3"
|
||||
"terser": "^5.31.3",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,24 @@ import axiosApi from '@/utils/axios.js'
|
||||
|
||||
|
||||
|
||||
export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${data}`)
|
||||
// 根据分组ID获取Markdown文件列表
|
||||
export const groupingId = (groupingId) => axiosApi.get(`/api/markdown/grouping/${encodeURIComponent(groupingId)}`)
|
||||
// 获取所有分组
|
||||
export const groupingAll = (data) => axiosApi.get(`/api/groupings?parentId=${data}`);
|
||||
export const groupingAll = (data) => {
|
||||
if (data) {
|
||||
return axiosApi.get(`/api/groupings?parentId=${encodeURIComponent(data)}`);
|
||||
}
|
||||
return axiosApi.get('/api/groupings');
|
||||
};
|
||||
// 获取所有Markdown文件
|
||||
export const markdownAll = () => axiosApi.get(`/api/markdown`);
|
||||
// 预览markdown文件
|
||||
export const Preview = (id) => axiosApi.get(`/api/markdown/${id}`);
|
||||
|
||||
// 分块加载markdown文件内容(用于大文件)
|
||||
export const PreviewChunk = (id, chunkIndex = 0, chunkSize = 10000) =>
|
||||
axiosApi.get(`/api/markdown/${id}/chunk?chunkIndex=${chunkIndex}&chunkSize=${chunkSize}`);
|
||||
|
||||
// 创建分类分组
|
||||
export const addGroupings = (group) => {
|
||||
return axiosApi.post('/api/groupings', group);
|
||||
@@ -19,20 +29,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,23 +64,28 @@ 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) => {
|
||||
const formData = new FormData()
|
||||
formData.append('username', data.username)
|
||||
formData.append('password', data.password)
|
||||
formData.append('email', data.email || '') // 修复:添加 email 参数
|
||||
formData.append('registrationCode', data.registrationCode)
|
||||
return axiosApi.post('/api/user/register', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// 更新分组名称
|
||||
// 修复:使用与后端 Grouping 实体类匹配的字段名
|
||||
export const updateGroupingName = (id, newName) => {
|
||||
return axiosApi.put(`/api/groupings/${id}`, { grouping: newName });
|
||||
}
|
||||
@@ -101,20 +111,6 @@ export const getRecentFiles = (limit = 16) => axiosApi.get(`/api/markdown/recent
|
||||
|
||||
|
||||
|
||||
// MD5哈希
|
||||
export const MD5 = (data, file) => {
|
||||
const formData = new FormData()
|
||||
if (data) formData.append('input', data)
|
||||
if (file) formData.append('file', file)
|
||||
return axiosApi.post('/api/common/md5', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ const refreshCaptcha = async () => {
|
||||
captchaCode.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取验证码失败,请重试');
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('获取验证码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
:file="selectedFile"
|
||||
:is-mobile="isMobile"
|
||||
:is-user-logged-in="userStore.isLoggedIn"
|
||||
:has-more-chunks="hasMoreChunks"
|
||||
:user-role="userStore.userInfo?.role || 'USER'"
|
||||
@back="selectedFile = null"
|
||||
@edit="editNote(selectedFile)"
|
||||
@delete="deleteNote(selectedFile)"
|
||||
@@ -66,8 +68,11 @@
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="hasMoreFiles && !showEditor && !selectedFile" class="load-more-trigger">
|
||||
<el-button @click="loadMoreFiles" type="primary" plain>加载更多</el-button>
|
||||
<div v-else-if="hasMoreFiles && !showEditor && !selectedFile" class="load-more-hint">
|
||||
<span>继续滚动加载更多...</span>
|
||||
</div>
|
||||
<div v-else-if="!hasMoreFiles && displayedFiles.length > 0" class="no-more-hint">
|
||||
<span>没有更多笔记了</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,14 +135,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
|
||||
import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount, watchEffect, provide } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import Vditor from 'vditor';
|
||||
import 'vditor/dist/index.css';
|
||||
import { escapeHtml } from '@/utils/security';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
groupingAll,
|
||||
markdownList,
|
||||
Preview,
|
||||
PreviewChunk,
|
||||
updateMarkdown,
|
||||
searchMarkdown,
|
||||
deleteMarkdown,
|
||||
@@ -178,6 +186,7 @@ const displayedFiles = ref([]);
|
||||
const currentPage = ref(0);
|
||||
const pageSize = ref(16);
|
||||
const isLoadingMore = ref(false);
|
||||
const loadCount = ref(0); // 记录加载次数,用于动态增加加载数量
|
||||
const noteListWrapper = ref(null);
|
||||
const showEditor = ref(false);
|
||||
const selectedFile = ref(null);
|
||||
@@ -196,6 +205,17 @@ const showSystemSettingsDialog = ref(false);
|
||||
const showUpdatePasswordDialog = ref(false);
|
||||
const showPrivacyDialog = ref(false);
|
||||
|
||||
// Vditor 渲染引擎就绪状态
|
||||
const vditorReady = ref(false);
|
||||
// 提供给子组件使用
|
||||
provide('vditorReady', vditorReady);
|
||||
|
||||
// 大文件分块加载状态
|
||||
const currentChunkIndex = ref(0);
|
||||
const hasMoreChunks = ref(false);
|
||||
const isLoadingChunk = ref(false);
|
||||
const totalChunks = ref(0);
|
||||
|
||||
// Data for dialogs
|
||||
const itemToRename = ref(null);
|
||||
const fileToImport = ref(null);
|
||||
@@ -251,19 +271,33 @@ const resetToHomeView = async () => {
|
||||
searchKeyword.value = '';
|
||||
activeMenu.value = 'all';
|
||||
currentPage.value = 0;
|
||||
loadCount.value = 0; // 重置加载计数
|
||||
try {
|
||||
groupMarkdownFiles.value = await getRecentFiles(100) || [];
|
||||
updateDisplayedFiles();
|
||||
} catch (error) {
|
||||
ElMessage.error('获取最近文件失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('获取最近文件失败:', error);
|
||||
groupMarkdownFiles.value = [];
|
||||
displayedFiles.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 计算动态加载数量:基础16个,每次增加8个,最多48个
|
||||
const getDynamicPageSize = () => {
|
||||
const baseSize = 16;
|
||||
const increment = 8;
|
||||
const maxSize = 48;
|
||||
return Math.min(baseSize + loadCount.value * increment, maxSize);
|
||||
};
|
||||
|
||||
const updateDisplayedFiles = () => {
|
||||
const start = 0;
|
||||
const end = (currentPage.value + 1) * pageSize.value;
|
||||
const dynamicPageSize = getDynamicPageSize();
|
||||
// 计算实际显示数量:首次16个,之后动态增加
|
||||
const end = displayedFiles.value.length === 0
|
||||
? dynamicPageSize
|
||||
: displayedFiles.value.length + dynamicPageSize;
|
||||
displayedFiles.value = groupMarkdownFiles.value.slice(start, end);
|
||||
};
|
||||
|
||||
@@ -272,6 +306,7 @@ const loadMoreFiles = () => {
|
||||
isLoadingMore.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
loadCount.value++;
|
||||
currentPage.value++;
|
||||
updateDisplayedFiles();
|
||||
isLoadingMore.value = false;
|
||||
@@ -280,11 +315,21 @@ const loadMoreFiles = () => {
|
||||
|
||||
const handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100 && hasMoreFiles.value && !isLoadingMore.value) {
|
||||
// 距离底部 200px 时自动加载,给用户更流畅的体验
|
||||
if (scrollHeight - scrollTop - clientHeight < 200 && hasMoreFiles.value && !isLoadingMore.value) {
|
||||
loadMoreFiles();
|
||||
}
|
||||
};
|
||||
|
||||
// 预览区域滚动处理(用于大文件分块加载)
|
||||
const handlePreviewScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
// 距离底部 300px 时加载下一页内容
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && hasMoreChunks.value && !isLoadingChunk.value) {
|
||||
loadMoreContent();
|
||||
}
|
||||
};
|
||||
|
||||
// Event Handlers from Components
|
||||
const handleSelectFile = async (data) => {
|
||||
resetEdit();
|
||||
@@ -292,12 +337,14 @@ const handleSelectFile = async (data) => {
|
||||
const files = await markdownList(data.id);
|
||||
groupMarkdownFiles.value = files || [];
|
||||
currentPage.value = 0;
|
||||
loadCount.value = 0; // 重置加载计数
|
||||
updateDisplayedFiles();
|
||||
selectedFile.value = null;
|
||||
showEditor.value = false;
|
||||
activeMenu.value = `group-${data.id}`;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取笔记列表失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('获取笔记列表失败:', error);
|
||||
groupMarkdownFiles.value = [];
|
||||
displayedFiles.value = [];
|
||||
}
|
||||
@@ -316,14 +363,26 @@ const handleCreateNote = (payload) => {
|
||||
selectedFile.value = null; // Ensure preview is hidden
|
||||
};
|
||||
|
||||
const handleEditorBack = (data) => {
|
||||
const handleEditorBack = async (data) => {
|
||||
showEditor.value = false;
|
||||
if (data && data.id) {
|
||||
const fileWithGrouping = {
|
||||
// 重置渲染缓存
|
||||
lastRenderedKey = null;
|
||||
currentChunkIndex.value = 0;
|
||||
hasMoreChunks.value = false;
|
||||
isLoadingChunk.value = false;
|
||||
totalChunks.value = 0;
|
||||
|
||||
// 设置预览状态并加载内容
|
||||
selectedFile.value = {
|
||||
...data,
|
||||
groupingName: data.groupingName || getCategoryName(data.groupingId)
|
||||
groupingName: data.groupingName || getCategoryName(data.groupingId),
|
||||
isLoading: true,
|
||||
isRendering: false
|
||||
};
|
||||
selectedFile.value = fileWithGrouping;
|
||||
|
||||
// 加载笔记内容
|
||||
await loadNoteChunk(data.id, 0);
|
||||
} else {
|
||||
selectedFile.value = null;
|
||||
resetToHomeView();
|
||||
@@ -366,23 +425,73 @@ const previewFile = async (file) => {
|
||||
selectedFile.value = null;
|
||||
return;
|
||||
}
|
||||
// 重置渲染缓存,确保每次打开笔记都重新渲染
|
||||
lastRenderedKey = null;
|
||||
// 重置分块加载状态
|
||||
currentChunkIndex.value = 0;
|
||||
hasMoreChunks.value = false;
|
||||
isLoadingChunk.value = false;
|
||||
totalChunks.value = 0;
|
||||
|
||||
// 先立即显示预览页(加载状态),让用户感知到响应
|
||||
selectedFile.value = { ...file, content: '', isLoading: true };
|
||||
selectedFile.value = { ...file, content: '', isLoading: true, isRendering: false };
|
||||
showEditor.value = false;
|
||||
|
||||
// 异步加载内容
|
||||
// 异步加载内容(使用分块加载)
|
||||
await loadNoteChunk(file.id, 0);
|
||||
};
|
||||
|
||||
// 分块加载笔记内容
|
||||
const loadNoteChunk = async (fileId, chunkIndex) => {
|
||||
if (isLoadingChunk.value) return;
|
||||
|
||||
isLoadingChunk.value = true;
|
||||
try {
|
||||
const content = await Preview(file.id) || '';
|
||||
// 内容加载完成后更新
|
||||
if (selectedFile.value && selectedFile.value.id === file.id) {
|
||||
selectedFile.value = { ...file, content, isLoading: false };
|
||||
const chunkData = await PreviewChunk(fileId, chunkIndex, 10000);
|
||||
|
||||
if (selectedFile.value && selectedFile.value.id === fileId) {
|
||||
// 更新分块加载状态
|
||||
currentChunkIndex.value = chunkData.chunkIndex;
|
||||
hasMoreChunks.value = chunkData.hasMore;
|
||||
totalChunks.value = chunkData.totalChunks;
|
||||
|
||||
// 如果是第一块,直接设置内容;否则追加内容
|
||||
let newContent;
|
||||
if (chunkIndex === 0) {
|
||||
newContent = chunkData.chunk;
|
||||
} else {
|
||||
newContent = selectedFile.value.content + chunkData.chunk;
|
||||
}
|
||||
|
||||
// 如果 Vditor 渲染引擎未就绪,显示渲染中状态
|
||||
const isRendering = !vditorReady.value;
|
||||
|
||||
selectedFile.value = {
|
||||
...selectedFile.value,
|
||||
content: newContent,
|
||||
isLoading: false,
|
||||
isRendering,
|
||||
title: chunkData.title || selectedFile.value.title,
|
||||
isPrivate: chunkData.isPrivate !== undefined ? chunkData.isPrivate : selectedFile.value.isPrivate
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取笔记内容失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('获取笔记内容失败:', error);
|
||||
selectedFile.value = null;
|
||||
} finally {
|
||||
isLoadingChunk.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多内容(滚动到底部时调用)
|
||||
const loadMoreContent = async () => {
|
||||
if (!selectedFile.value || !hasMoreChunks.value || isLoadingChunk.value) return;
|
||||
|
||||
const nextChunkIndex = currentChunkIndex.value + 1;
|
||||
await loadNoteChunk(selectedFile.value.id, nextChunkIndex);
|
||||
};
|
||||
|
||||
const editNote = (file) => {
|
||||
editData.value = { ...file };
|
||||
showEditor.value = true;
|
||||
@@ -400,7 +509,8 @@ const deleteNote = (file) => {
|
||||
await fetchGroupings();
|
||||
await resetToHomeView();
|
||||
} catch (error) {
|
||||
ElMessage.error('删除笔记失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('删除笔记失败:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -459,12 +569,14 @@ const handleSearch = async () => {
|
||||
try {
|
||||
groupMarkdownFiles.value = await searchMarkdown(searchKeyword.value) || [];
|
||||
currentPage.value = 0;
|
||||
loadCount.value = 0; // 重置加载计数
|
||||
updateDisplayedFiles();
|
||||
selectedFile.value = null;
|
||||
showEditor.value = false;
|
||||
activeMenu.value = 'search';
|
||||
} catch (error) {
|
||||
ElMessage.error('搜索失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('搜索失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -501,9 +613,15 @@ 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转义,使用DOMPurify清理内容防止XSS
|
||||
const escapedTitle = escapeHtml(title);
|
||||
const sanitizedContent = DOMPurify.sanitize(previewElement.innerHTML, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'img', 'br', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],
|
||||
ALLOWED_ATTR: ['href', 'title', 'class', 'src', 'alt', 'width', 'height']
|
||||
});
|
||||
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>${sanitizedContent}</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' });
|
||||
@@ -525,7 +643,8 @@ const handleExport = async (format) => {
|
||||
}
|
||||
ElMessage.success(`${format.toUpperCase()} 导出成功`);
|
||||
} catch (error) {
|
||||
ElMessage.error(`导出失败: ${error.message}`);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('导出失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -546,8 +665,33 @@ onMounted(async () => {
|
||||
await fetchGroupings();
|
||||
await resetToHomeView();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 预加载 Vditor 渲染引擎(lute.min.js),避免第一次点击笔记时等待下载
|
||||
preloadVditor();
|
||||
});
|
||||
|
||||
// 预加载 Vditor 渲染引擎
|
||||
const preloadVditor = () => {
|
||||
// 创建一个隐藏的容器用于预加载
|
||||
const preloadContainer = document.createElement('div');
|
||||
preloadContainer.style.cssText = 'position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden;';
|
||||
document.body.appendChild(preloadContainer);
|
||||
|
||||
// 调用 Vditor.preview 会触发 lute.min.js 的下载
|
||||
Vditor.preview(preloadContainer, '# 预加载', {
|
||||
mode: 'light',
|
||||
after: () => {
|
||||
// 渲染引擎加载完成
|
||||
vditorReady.value = true;
|
||||
// 清理预加载容器
|
||||
if (preloadContainer.parentNode) {
|
||||
preloadContainer.parentNode.removeChild(preloadContainer);
|
||||
}
|
||||
console.log('Vditor 渲染引擎已就绪');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
@@ -562,26 +706,95 @@ const handleResize = () => {
|
||||
// Vditor 渲染优化
|
||||
let lastRenderedKey = null;
|
||||
|
||||
watch([selectedFile, showEditor], ([newFile, newShowEditor]) => {
|
||||
if (newFile && !newShowEditor) {
|
||||
// 使用文件ID+内容长度作为渲染标识,内容变化时重新渲染
|
||||
const renderKey = `${newFile.id}-${newFile.content?.length || 0}`;
|
||||
if (lastRenderedKey === renderKey) return;
|
||||
|
||||
// 使用 requestAnimationFrame 确保流畅渲染
|
||||
requestAnimationFrame(() => {
|
||||
const previewElement = document.querySelector('.markdown-preview');
|
||||
if (previewElement) {
|
||||
const contentToRender = (newFile.isPrivate === 1 && !userStore.isLoggedIn) ? privateNoteContent : newFile.content;
|
||||
Vditor.preview(previewElement, contentToRender || '', {
|
||||
mode: 'light',
|
||||
hljs: { enable: true, style: 'github' }
|
||||
});
|
||||
lastRenderedKey = renderKey;
|
||||
}
|
||||
});
|
||||
// 处理 Markdown 内容中的图片 URL,将相对路径转换为完整路径
|
||||
const processImageUrls = (content) => {
|
||||
if (!content) return '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
if (!baseUrl) return content;
|
||||
|
||||
// 匹配 Markdown 图片语法 
|
||||
return content.replace(
|
||||
/!\[([^\]]*)\]\((\/api\/images\/preview\/[^)]+)\)/g,
|
||||
(_match, alt, relativeUrl) => {
|
||||
// 确保 URL 格式正确
|
||||
const fullUrl = baseUrl.endsWith('/') && relativeUrl.startsWith('/')
|
||||
? baseUrl + relativeUrl.substring(1)
|
||||
: baseUrl + relativeUrl;
|
||||
return ``;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染 Markdown 内容的函数
|
||||
const renderMarkdown = async (file) => {
|
||||
if (!file || showEditor.value) return;
|
||||
|
||||
// 使用文件ID+内容长度作为渲染标识,内容变化时重新渲染
|
||||
const renderKey = `${file.id}-${file.content?.length || 0}`;
|
||||
if (lastRenderedKey === renderKey) return;
|
||||
|
||||
// 如果正在加载中,等待加载完成再渲染
|
||||
if (file.isLoading) {
|
||||
return;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 等待 DOM 更新完成
|
||||
await nextTick();
|
||||
|
||||
// 使用 requestAnimationFrame 确保浏览器已完成布局和绘制
|
||||
requestAnimationFrame(() => {
|
||||
const previewElement = document.querySelector('.markdown-preview');
|
||||
if (previewElement) {
|
||||
let contentToRender = (file.isPrivate === 1 && !userStore.isLoggedIn) ? privateNoteContent : file.content;
|
||||
// 处理图片 URL
|
||||
contentToRender = processImageUrls(contentToRender);
|
||||
// 先清空内容,避免闪烁
|
||||
previewElement.innerHTML = '';
|
||||
Vditor.preview(previewElement, contentToRender || '', {
|
||||
mode: 'light',
|
||||
hljs: { enable: true, style: 'github' },
|
||||
after: () => {
|
||||
// 渲染完成后,如果文件有 isRendering 标记,移除它
|
||||
if (selectedFile.value && selectedFile.value.isRendering) {
|
||||
selectedFile.value = { ...selectedFile.value, isRendering: false };
|
||||
}
|
||||
// 为图片添加懒加载属性
|
||||
const images = previewElement.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
if (!img.hasAttribute('loading')) {
|
||||
img.setAttribute('loading', 'lazy');
|
||||
}
|
||||
});
|
||||
// 添加滚动监听,用于大文件分块加载
|
||||
if (hasMoreChunks.value) {
|
||||
previewElement.addEventListener('scroll', handlePreviewScroll);
|
||||
}
|
||||
}
|
||||
});
|
||||
lastRenderedKey = renderKey;
|
||||
} else {
|
||||
// 如果元素不存在,100ms 后重试
|
||||
setTimeout(() => renderMarkdown(file), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听选中文件变化,自动渲染
|
||||
watch(() => selectedFile.value, (newFile) => {
|
||||
if (newFile && !showEditor.value) {
|
||||
renderMarkdown(newFile);
|
||||
} else if (!newFile) {
|
||||
// 用户返回列表页,重置渲染缓存
|
||||
lastRenderedKey = null;
|
||||
}
|
||||
}, { deep: true, immediate: true });
|
||||
|
||||
// 监听编辑器状态变化
|
||||
watch(showEditor, (isEditor) => {
|
||||
if (!isEditor && selectedFile.value) {
|
||||
renderMarkdown(selectedFile.value);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -663,4 +876,68 @@ watch([selectedFile, showEditor], ([newFile, newShowEditor]) => {
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 没有更多笔记了 - 美化样式 */
|
||||
.no-more-hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.no-more-hint span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
|
||||
border-radius: 24px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.no-more-hint span::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: #c0c4cc;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.no-more-hint span::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: #c0c4cc;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
.dark-theme .no-more-hint span {
|
||||
background: linear-gradient(135deg, #2c2c3d 0%, #1e1e2f 100%);
|
||||
color: #606266;
|
||||
border-color: #2c2c3d;
|
||||
}
|
||||
|
||||
.dark-theme .no-more-hint span::before,
|
||||
.dark-theme .no-more-hint span::after {
|
||||
background-color: #606266;
|
||||
}
|
||||
|
||||
/* 继续滚动加载更多提示 */
|
||||
.load-more-hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,7 @@
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="请输入您的密码" show-password size="large">
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="请输入您的密码" show-password size="large" @keyup.enter="handleLogin">
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
@@ -61,9 +61,8 @@ const handleLogin = async () => {
|
||||
const success = await userStore.login(loginForm.value.username, loginForm.value.password);
|
||||
if (success) {
|
||||
ElMessage.success('登录成功');
|
||||
loginForm.value.password = '';
|
||||
router.push('/home');
|
||||
} else {
|
||||
// ElMessage.error('用户名或密码错误'); // 错误已由 axios 拦截器处理
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,7 +71,8 @@ const fetchTrashItems = async () => {
|
||||
const response = await getTrash();
|
||||
trashItems.value = response || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取回收站内容失败');
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('获取回收站内容失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,7 +82,8 @@ const handleRestore = async (item) => {
|
||||
ElMessage.success('恢复成功');
|
||||
fetchTrashItems();
|
||||
} catch (error) {
|
||||
ElMessage.error('恢复失败');
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('恢复失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +128,8 @@ const handleCaptchaConfirm = async ({ captchaId, captchaCode }) => {
|
||||
pendingItem.value = null;
|
||||
fetchTrashItems();
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败: ' + (error.response?.data?.msg || error.message));
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('操作失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
51
biji-qianduan/src/components/VirtualList.vue
Normal file
51
biji-qianduan/src/components/VirtualList.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VirtualList',
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
itemHeight: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
containerHeight: {
|
||||
type: Number,
|
||||
default: 600
|
||||
}
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const visibleStart = ref(0)
|
||||
const visibleEnd = ref(Math.ceil(props.containerHeight / props.itemHeight))
|
||||
|
||||
const handleScroll = (e) => {
|
||||
const scrollTop = e.target.scrollTop
|
||||
visibleStart.value = Math.floor(scrollTop / props.itemHeight)
|
||||
visibleEnd.value = visibleStart.value + Math.ceil(props.containerHeight / props.itemHeight)
|
||||
}
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
return props.items.slice(visibleStart.value, visibleEnd.value)
|
||||
})
|
||||
|
||||
return () => h('div', {
|
||||
style: { height: `${props.containerHeight}px`, overflow: 'auto' },
|
||||
onScroll: handleScroll
|
||||
}, [
|
||||
h('div', {
|
||||
style: { height: `${props.items.length * props.itemHeight}px`, position: 'relative' }
|
||||
}, visibleItems.value.map((item, idx) =>
|
||||
h('div', {
|
||||
key: visibleStart.value + idx,
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: `${(visibleStart.value + idx) * props.itemHeight}px`,
|
||||
height: `${props.itemHeight}px`
|
||||
}
|
||||
}, slots.default?.({ item, index: visibleStart.value + idx }))
|
||||
))
|
||||
])
|
||||
}
|
||||
})
|
||||
@@ -5,7 +5,11 @@
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="handleBack">返回</el-button>
|
||||
<el-button type="success" @click="save">保存</el-button>
|
||||
<span class="save-status">{{ saveStatus }}</span>
|
||||
<div class="save-status">
|
||||
<el-icon v-if="saveStatus === '正在输入...'" class="is-loading saving-icon"><Loading /></el-icon>
|
||||
<el-icon v-else-if="saveStatus === '保存失败'" class="error-icon"><CircleCloseFilled /></el-icon>
|
||||
<el-icon v-else-if="saveStatus === '已保存'" class="success-icon"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
<div id="vditor-editor" class="vditor" />
|
||||
@@ -16,8 +20,10 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import Vditor from 'vditor';
|
||||
import 'vditor/dist/index.css';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Loading, CircleCloseFilled, CircleCheckFilled } from '@element-plus/icons-vue';
|
||||
import { updateMarkdown, uploadImage } from '@/api/CommonApi.js';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
const props = defineProps({
|
||||
editData: {
|
||||
@@ -32,11 +38,13 @@ const vditor = ref(null);
|
||||
const currentId = ref(null);
|
||||
const isInitialized = ref(false);
|
||||
const saveStatus = ref('');
|
||||
let saveTimeout = null;
|
||||
let lastSavedContent = ref('');
|
||||
let isSaving = ref(false);
|
||||
const saveTimeout = ref(null); // 修复:使用 ref 替代 let,确保响应式追踪
|
||||
const lastSavedContent = ref('');
|
||||
const isSaving = ref(false);
|
||||
// 维护当前最新的笔记数据
|
||||
const currentData = ref({ ...props.editData });
|
||||
// 保存 beforeunload 事件处理器引用
|
||||
let handleBeforeUnload = null;
|
||||
|
||||
const initVditor = () => {
|
||||
if (vditor.value) {
|
||||
@@ -62,14 +70,17 @@ const initVditor = () => {
|
||||
},
|
||||
input: (value) => {
|
||||
if (!isInitialized.value) return;
|
||||
|
||||
clearTimeout(saveTimeout);
|
||||
saveStatus.value = '正在输入...';
|
||||
saveTimeout = setTimeout(() => {
|
||||
if (!isSaving.value && value !== lastSavedContent.value) {
|
||||
save(value);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 只有在内容真正改变时才启动定时器
|
||||
if (value !== lastSavedContent.value) {
|
||||
clearTimeout(saveTimeout.value);
|
||||
saveStatus.value = '正在输入...';
|
||||
saveTimeout.value = setTimeout(() => {
|
||||
if (!isSaving.value && value !== lastSavedContent.value) {
|
||||
save(value);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
},
|
||||
upload: {
|
||||
accept: 'image/*',
|
||||
@@ -78,11 +89,17 @@ const initVditor = () => {
|
||||
if (!file) return;
|
||||
|
||||
uploadImage(file).then(res => {
|
||||
const url = res.url;
|
||||
// 后端返回相对路径,拼接成完整 URL
|
||||
const relativeUrl = res.url;
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
vditor.value.insertValue(``);
|
||||
}).catch(() => {
|
||||
ElMessage.error('图片上传失败');
|
||||
// 确保 URL 格式正确,避免双斜杠
|
||||
const fullUrl = baseUrl.endsWith('/') && relativeUrl.startsWith('/')
|
||||
? baseUrl + relativeUrl.substring(1)
|
||||
: baseUrl + relativeUrl;
|
||||
vditor.value.insertValue(``);
|
||||
}).catch((error) => {
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('图片上传失败:', error);
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -91,26 +108,32 @@ const initVditor = () => {
|
||||
|
||||
const save = async (value) => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
clearTimeout(saveTimeout);
|
||||
const content = typeof value === 'string' ? value : vditor.value.getValue();
|
||||
|
||||
|
||||
// 修复:添加空值检查
|
||||
if (!vditor.value) {
|
||||
console.warn('编辑器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(saveTimeout.value);
|
||||
const content = typeof value === 'string' ? value : vditor.value?.getValue() || '';
|
||||
|
||||
if (content === lastSavedContent.value && currentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
saveStatus.value = '正在保存...';
|
||||
|
||||
|
||||
// 确保groupingId不会丢失:优先使用currentData中的值
|
||||
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
||||
|
||||
|
||||
// 将ID转为字符串以避免JavaScript精度丢失
|
||||
const idString = currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null);
|
||||
const groupingIdString = groupingId ? String(groupingId) : null;
|
||||
|
||||
const payload = {
|
||||
|
||||
const payload = {
|
||||
id: idString,
|
||||
content: content,
|
||||
title: currentData.value.title || props.editData.title,
|
||||
@@ -118,15 +141,13 @@ const save = async (value) => {
|
||||
fileName: currentData.value.fileName || props.editData.fileName,
|
||||
isPrivate: currentData.value.isPrivate !== undefined ? currentData.value.isPrivate : props.editData.isPrivate
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
const response = await updateMarkdown(payload);
|
||||
|
||||
|
||||
if (response && response.id) {
|
||||
currentId.value = response.id;
|
||||
lastSavedContent.value = content;
|
||||
|
||||
|
||||
// 使用后端返回的数据,但确保groupingId不会丢失
|
||||
// 注意:后端返回的ID是字符串,保持字符串格式避免精度丢失
|
||||
const updatedFile = {
|
||||
@@ -136,52 +157,94 @@ const save = async (value) => {
|
||||
groupingId: response.groupingId || groupingIdString,
|
||||
groupingName: response.groupingName || currentData.value.groupingName
|
||||
};
|
||||
|
||||
|
||||
// 更新currentData为最新数据
|
||||
currentData.value = updatedFile;
|
||||
emit('update:editData', updatedFile);
|
||||
saveStatus.value = '已保存';
|
||||
|
||||
// 2秒后清除成功状态
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === '已保存') {
|
||||
saveStatus.value = '';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
saveStatus.value = '保存失败';
|
||||
ElMessage.error('保存失败: ' + (error.message || '未知错误'));
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = async () => {
|
||||
// 清除定时器,防止返回后继续保存
|
||||
clearTimeout(saveTimeout.value);
|
||||
|
||||
const content = vditor.value ? vditor.value.getValue() : '';
|
||||
if (content !== lastSavedContent.value && !isSaving.value) {
|
||||
const hasChanges = content !== lastSavedContent.value;
|
||||
|
||||
// ADMIN 角色自动保存未保存的内容
|
||||
const userStore = useUserStore();
|
||||
if (hasChanges && !isSaving.value && userStore.userInfo?.role === 'ADMIN') {
|
||||
await save(content);
|
||||
}
|
||||
|
||||
|
||||
// 确保groupingId不会丢失(保持字符串格式)
|
||||
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
||||
const groupingName = currentData.value.groupingName || props.editData.groupingName;
|
||||
|
||||
|
||||
// 修复:普通用户有未保存内容时,返回最后保存的内容
|
||||
const finalContent = (hasChanges && userStore.userInfo?.role !== 'ADMIN')
|
||||
? lastSavedContent.value
|
||||
: content;
|
||||
|
||||
const returnData = {
|
||||
...currentData.value,
|
||||
...props.editData,
|
||||
id: currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null),
|
||||
content: content,
|
||||
content: finalContent,
|
||||
groupingId: groupingId ? String(groupingId) : null,
|
||||
groupingName: groupingName
|
||||
};
|
||||
|
||||
|
||||
emit('back', returnData);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initVditor();
|
||||
|
||||
// 页面刷新时提示用户保存
|
||||
handleBeforeUnload = (e) => {
|
||||
const content = vditor.value ? vditor.value.getValue() : '';
|
||||
if (content !== lastSavedContent.value && !isSaving.value) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '您有未保存的内容,确定要离开吗?';
|
||||
return '您有未保存的内容,确定要离开吗?';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(saveTimeout);
|
||||
// 清理定时器
|
||||
if (saveTimeout.value) {
|
||||
clearTimeout(saveTimeout.value);
|
||||
saveTimeout.value = null;
|
||||
}
|
||||
// 清理编辑器
|
||||
if (vditor.value) {
|
||||
vditor.value.destroy();
|
||||
vditor.value = null;
|
||||
}
|
||||
// 移除页面刷新提示
|
||||
if (handleBeforeUnload) {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
handleBeforeUnload = null;
|
||||
}
|
||||
currentId.value = null;
|
||||
isInitialized.value = false;
|
||||
});
|
||||
@@ -219,9 +282,46 @@ watch(() => props.editData, (newVal, oldVal) => {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.actions .save-status {
|
||||
margin-left: 10px;
|
||||
color: #909399;
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.saving-icon {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 20px;
|
||||
color: #67c23a;
|
||||
animation: fadeOut 2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 20px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vditor {
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
<el-icon v-if="isMobile"><Back /></el-icon>
|
||||
<span v-else>返回</span>
|
||||
</el-button>
|
||||
<el-button v-if="isUserLoggedIn && !isMobile" type="warning" @click="$emit('show-move-note-dialog')">移动</el-button>
|
||||
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" @click="$emit('edit')">
|
||||
<el-button v-if="isUserLoggedIn && !isMobile" type="warning" :disabled="userRole !== 'ADMIN'" @click="$emit('show-move-note-dialog')">移动</el-button>
|
||||
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" :disabled="userRole !== 'ADMIN'" @click="$emit('edit')">
|
||||
<el-icon v-if="isMobile"><Edit /></el-icon>
|
||||
<span v-else>编辑</span>
|
||||
</el-button>
|
||||
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="danger" @click="$emit('delete')">
|
||||
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="danger" :disabled="userRole !== 'ADMIN'" @click="$emit('delete')">
|
||||
<el-icon v-if="isMobile"><Delete /></el-icon>
|
||||
<span v-else>删除</span>
|
||||
</el-button>
|
||||
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="info" @click="$emit('show-privacy-dialog')">
|
||||
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="info" :disabled="userRole !== 'ADMIN'" @click="$emit('show-privacy-dialog')">
|
||||
<el-icon v-if="isMobile"><Lock /></el-icon>
|
||||
<span v-else>{{ file.isPrivate === 1 ? '设为公开' : '设为私密' }}</span>
|
||||
</el-button>
|
||||
@@ -46,6 +46,16 @@
|
||||
<el-icon class="loading-icon is-loading"><Loading /></el-icon>
|
||||
<span class="loading-text">内容加载中...</span>
|
||||
</div>
|
||||
<!-- 渲染状态遮罩 -->
|
||||
<div v-else-if="file.isRendering" class="content-loading">
|
||||
<el-icon class="loading-icon is-loading"><Loading /></el-icon>
|
||||
<span class="loading-text">正在渲染...</span>
|
||||
</div>
|
||||
<!-- 大文件分块加载提示 -->
|
||||
<div v-if="file.hasMoreChunks" class="chunk-loading-hint">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载更多内容...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,6 +69,14 @@ const props = defineProps({
|
||||
},
|
||||
isMobile: Boolean,
|
||||
isUserLoggedIn: Boolean,
|
||||
hasMoreChunks: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userRole: {
|
||||
type: String,
|
||||
default: 'USER'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -187,4 +205,25 @@ const handleExport = (format) => {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 分块加载提示 */
|
||||
.chunk-loading-hint {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dark-theme .chunk-loading-hint {
|
||||
background-color: rgba(30, 30, 47, 0.9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
:collapse="isCollapsed && !isMobile"
|
||||
popper-effect="light"
|
||||
:collapse-transition="true"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<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>
|
||||
@@ -106,6 +108,24 @@ const goToTrash = () => router.push({ name: 'Trash' });
|
||||
const goToLogin = () => router.push('/login');
|
||||
const goToRegister = () => router.push('/register');
|
||||
|
||||
const handleMenuSelect = (index) => {
|
||||
if (index.startsWith('group-')) {
|
||||
const id = index.replace('group-', '');
|
||||
const findItem = (items) => {
|
||||
for (let item of items) {
|
||||
if (String(item.id) === id) return item;
|
||||
if (item.children) {
|
||||
const found = findItem(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const item = findItem(props.categoryTree);
|
||||
if (item) emit('select-file', item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGroup = (group) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除分类 “${group.grouping}” 吗?这将同时删除该分类下的所有子分类和笔记。`,
|
||||
@@ -117,7 +137,8 @@ const handleDeleteGroup = (group) => {
|
||||
ElMessage.success('分类已删除');
|
||||
emit('group-deleted');
|
||||
} catch (error) {
|
||||
ElMessage.error('删除分类失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('删除分类失败:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -143,18 +164,19 @@ const renderMenu = (item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
return h(ElSubMenu, {
|
||||
index: `group-${item.id}`,
|
||||
popperClass: props.isCollapsed ? 'hide-popper' : ''
|
||||
popperClass: props.isCollapsed ? 'hide-popper' : '',
|
||||
class: { 'is-active': props.activeMenu === `group-${item.id}` },
|
||||
disabled: false
|
||||
}, {
|
||||
title: () => h('div', {
|
||||
class: 'submenu-title-wrapper',
|
||||
onClick: () => emit('select-file', item),
|
||||
style: 'width: 100%; display: flex; align-items: center;'
|
||||
}, [ wrappedContent() ]),
|
||||
default: () => item.children.map(child => renderMenu(child))
|
||||
});
|
||||
}
|
||||
|
||||
return h(ElMenuItem, { index: `group-${item.id}`, onClick: () => emit('select-file', item) }, {
|
||||
return h(ElMenuItem, { index: `group-${item.id}` }, {
|
||||
default: wrappedContent
|
||||
});
|
||||
};
|
||||
@@ -347,6 +369,30 @@ const renderMenu = (item) => {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 移除嵌套 SubMenu 的 active 样式 */
|
||||
:deep(.el-menu--inline .el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
background: transparent !important;
|
||||
color: var(--text-color-secondary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu--inline .el-sub-menu.is-active > .el-sub-menu__title::before) {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
/* 只有顶级 SubMenu 被直接选中时才显示 active 样式 */
|
||||
:deep(.el-menu > .el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-color-light) 100%) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600 !important;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu > .el-sub-menu.is-active > .el-sub-menu__title::before) {
|
||||
height: 24px !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover), :deep(.el-sub-menu__title:hover) {
|
||||
background-color: rgba(64, 158, 255, 0.08);
|
||||
color: var(--primary-color);
|
||||
|
||||
@@ -113,7 +113,8 @@ const handleSubmit = async () => {
|
||||
emit('group-created'); // 通知父组件刷新
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('创建分类失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('创建分类失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,7 +154,8 @@ const handleSubmit = async () => {
|
||||
emit('move-success');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('移动失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('移动失败:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ const handleSubmit = async () => {
|
||||
emit('privacy-changed', updatedFile);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('修改笔记状态失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('修改笔记状态失败:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -60,7 +60,8 @@ const handleSubmit = async () => {
|
||||
// 传递新名称给父组件
|
||||
emit('renamed', newName.value);
|
||||
} catch (error) {
|
||||
ElMessage.error('重命名失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('重命名失败:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -80,7 +80,8 @@ const handleSubmit = () => {
|
||||
emit('import-success');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('导入失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('导入失败:', error);
|
||||
}
|
||||
};
|
||||
reader.readAsText(props.fileToImport);
|
||||
|
||||
@@ -59,8 +59,8 @@ const fetchRegistrationStatus = async () => {
|
||||
try {
|
||||
isRegistrationEnabled.value = await getRegistrationStatus();
|
||||
} catch (error) {
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error("Failed to fetch registration status:", error);
|
||||
ElMessage.error('获取注册状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ const handleToggleRegistration = async (value) => {
|
||||
await toggleRegistration(value);
|
||||
ElMessage.success(`注册功能已${value ? '开启' : '关闭'}`);
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
isRegistrationEnabled.value = !value; // Revert on failure
|
||||
}
|
||||
};
|
||||
@@ -80,7 +80,8 @@ const handleGenerateCode = async () => {
|
||||
generatedCode.value = code;
|
||||
ElMessage.success('注册码生成成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('生成注册码失败: ' + error.message);
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('生成注册码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ const handleCaptchaConfirm = async ({ captchaId, captchaCode }) => {
|
||||
emit('password-updated');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('密码修改失败: ' + (error.response?.data?.msg || error.message));
|
||||
// 错误已在 axios 拦截器中显示,这里不再重复显示
|
||||
console.error('密码修改失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { login as loginApi } from '../api/CommonApi';
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
token: '',
|
||||
userInfo: null,
|
||||
token: localStorage.getItem('user-token') || '',
|
||||
userInfo: JSON.parse(localStorage.getItem('user-info') || 'null'),
|
||||
tokenExpiry: parseInt(localStorage.getItem('user-token-expiry') || '0'),
|
||||
}),
|
||||
actions: {
|
||||
async login(username, password) {
|
||||
@@ -12,9 +13,15 @@ export const useUserStore = defineStore('user', {
|
||||
const response = await loginApi({ username, password });
|
||||
if (response && response.token) {
|
||||
this.token = response.token;
|
||||
const payload = JSON.parse(atob(response.token.split('.')[1]));
|
||||
this.tokenExpiry = payload.exp * 1000;
|
||||
if (response.userInfo) {
|
||||
this.userInfo = response.userInfo;
|
||||
}
|
||||
// 持久化到 localStorage
|
||||
localStorage.setItem('user-token', response.token);
|
||||
localStorage.setItem('user-info', JSON.stringify(response.userInfo));
|
||||
localStorage.setItem('user-token-expiry', this.tokenExpiry.toString());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -26,18 +33,18 @@ export const useUserStore = defineStore('user', {
|
||||
logout() {
|
||||
this.token = '';
|
||||
this.userInfo = null;
|
||||
this.tokenExpiry = null;
|
||||
localStorage.removeItem('user-token');
|
||||
localStorage.removeItem('user-info');
|
||||
localStorage.removeItem('user-token-expiry');
|
||||
},
|
||||
isTokenExpired() {
|
||||
if (!this.tokenExpiry) return true;
|
||||
return Date.now() >= this.tokenExpiry;
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
isLoggedIn: (state) => !!state.token,
|
||||
isLoggedIn: (state) => !!state.token && Date.now() < (state.tokenExpiry || 0),
|
||||
isAdmin: (state) => state.userInfo?.role === 'ADMIN',
|
||||
},
|
||||
persist: {
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
key: 'user-store',
|
||||
storage: sessionStorage,
|
||||
}
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,11 @@ import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
import { getReplayAttackHeaders, needsReplayAttackValidation } from './security'
|
||||
|
||||
let retryCount = 0
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
// 开发环境使用withCredentials,生产环境关闭
|
||||
// withCredentials: import.meta.env.DEV,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -21,7 +22,13 @@ instance.interceptors.request.use(
|
||||
if (userStore.token) {
|
||||
config.headers['Authorization'] = `Bearer ${userStore.token}`
|
||||
}
|
||||
|
||||
|
||||
// 添加 CSRF token
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
if (csrfToken) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
|
||||
// 添加防重放攻击请求头(POST/PUT/DELETE 请求)
|
||||
if (needsReplayAttackValidation(config.method, config.url)) {
|
||||
const replayHeaders = getReplayAttackHeaders()
|
||||
@@ -41,6 +48,7 @@ instance.interceptors.request.use(
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
response => {
|
||||
retryCount = 0
|
||||
const res = response.data;
|
||||
if (res.code !== 200) {
|
||||
ElMessage({
|
||||
@@ -53,11 +61,19 @@ instance.interceptors.response.use(
|
||||
return res.data;
|
||||
}
|
||||
},
|
||||
error => {
|
||||
async error => {
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
|
||||
// 503 - 服务器繁忙,重试
|
||||
if (status === 503 && retryCount < MAX_RETRIES) {
|
||||
retryCount++
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount))
|
||||
return instance(error.config)
|
||||
}
|
||||
retryCount = 0
|
||||
|
||||
// 401 - 未授权
|
||||
if (status === 401) {
|
||||
try {
|
||||
@@ -70,30 +86,33 @@ instance.interceptors.response.use(
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
|
||||
// 403 - 权限不足
|
||||
if (status === 403) {
|
||||
const msg = data?.msg || '无权操作';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 429 - 请求过于频繁
|
||||
if (status === 429) {
|
||||
const msg = data?.msg || '请求过于频繁,请稍后再试';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
|
||||
// 400 - 验证码错误等
|
||||
if (status === 400) {
|
||||
const msg = data?.msg || '请求参数错误';
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 503 - 服务器繁忙(内存不足)
|
||||
if (status === 503) {
|
||||
const msg = data?.msg || '服务器繁忙,请稍后再试';
|
||||
ElMessage.error(msg);
|
||||
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;
|
||||
}
|
||||
|
||||
12
biji-qianduan/src/utils/xss.js
Normal file
12
biji-qianduan/src/utils/xss.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export const sanitizeHtml = (html) => {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'img'],
|
||||
ALLOWED_ATTR: ['href', 'title', 'src', 'alt']
|
||||
})
|
||||
}
|
||||
|
||||
export const sanitizeText = (text) => {
|
||||
return DOMPurify.sanitize(text, { ALLOWED_TAGS: [] })
|
||||
}
|
||||
264
doc/feedback_system_analysis.md
Normal file
264
doc/feedback_system_analysis.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 反馈系统功能分析(未实现)
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
在左侧菜单回收站下方添加一个新的功能模块,包含三个子功能:
|
||||
- 反馈BUG
|
||||
- 申请管理
|
||||
- 提出意见
|
||||
|
||||
**访问权限:** 仅登录用户可见和使用
|
||||
**处理权限:** 仅ADMIN用户可处理
|
||||
|
||||
---
|
||||
|
||||
## 二、用户角色权限矩阵
|
||||
|
||||
### 普通用户(USER)能做什么
|
||||
|
||||
| 功能 | 权限 | 说明 |
|
||||
|------|------|------|
|
||||
| 查看反馈列表 | ✅ | 仅查看自己提交的反馈 |
|
||||
| 提交BUG反馈 | ✅ | 创建新的BUG反馈 |
|
||||
| 提交管理申请 | ✅ | 申请成为管理员 |
|
||||
| 提出意见建议 | ✅ | 提交产品意见 |
|
||||
| 编辑自己的反馈 | ✅ | 仅在未处理状态下可编辑 |
|
||||
| 删除自己的反馈 | ✅ | 仅在未处理状态下可删除 |
|
||||
| 查看反馈状态 | ✅ | 实时查看处理进度 |
|
||||
| 处理他人反馈 | ❌ | 无权限 |
|
||||
| 删除他人反馈 | ❌ | 无权限 |
|
||||
|
||||
### 管理员(ADMIN)能做什么
|
||||
|
||||
| 功能 | 权限 | 说明 |
|
||||
|------|------|------|
|
||||
| 查看所有反馈 | ✅ | 查看全部用户的反馈 |
|
||||
| 查看反馈详情 | ✅ | 包括用户信息、提交时间等 |
|
||||
| 处理反馈 | ✅ | 更新状态、添加处理备注 |
|
||||
| 批准/拒绝申请 | ✅ | 处理管理员申请 |
|
||||
| 删除反馈 | ✅ | 删除任何反馈 |
|
||||
| 导出反馈 | ✅ | 导出为CSV/Excel |
|
||||
| 统计分析 | ✅ | 查看反馈统计信息 |
|
||||
|
||||
---
|
||||
|
||||
## 三、数据表设计
|
||||
|
||||
### 1. 反馈表(feedback)
|
||||
|
||||
```sql
|
||||
CREATE TABLE feedback (
|
||||
id BIGINT PRIMARY KEY COMMENT '反馈ID',
|
||||
user_id BIGINT NOT NULL COMMENT '提交用户ID',
|
||||
type VARCHAR(20) NOT NULL COMMENT '反馈类型: BUG, ADMIN_REQUEST, SUGGESTION',
|
||||
title VARCHAR(255) NOT NULL COMMENT '反馈标题',
|
||||
content LONGTEXT NOT NULL COMMENT '反馈内容',
|
||||
status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态: PENDING(待处理), PROCESSING(处理中), RESOLVED(已解决), REJECTED(已拒绝)',
|
||||
priority VARCHAR(20) DEFAULT 'NORMAL' COMMENT '优先级: LOW, NORMAL, HIGH, URGENT',
|
||||
|
||||
-- 处理信息
|
||||
handler_id BIGINT COMMENT '处理人ID',
|
||||
handler_remark VARCHAR(500) COMMENT '处理备注',
|
||||
handled_at DATETIME COMMENT '处理时间',
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
-- 软删除
|
||||
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除',
|
||||
deleted_at DATETIME COMMENT '删除时间',
|
||||
deleted_by BIGINT COMMENT '删除人ID',
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_type (type),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) COMMENT='用户反馈表';
|
||||
```
|
||||
|
||||
### 2. 管理员申请表(admin_request)
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin_request (
|
||||
id BIGINT PRIMARY KEY COMMENT '申请ID',
|
||||
user_id BIGINT NOT NULL COMMENT '申请用户ID',
|
||||
reason VARCHAR(500) NOT NULL COMMENT '申请理由',
|
||||
status VARCHAR(20) DEFAULT 'PENDING' COMMENT '状态: PENDING(待审核), APPROVED(已批准), REJECTED(已拒绝)',
|
||||
|
||||
-- 审核信息
|
||||
reviewer_id BIGINT COMMENT '审核人ID',
|
||||
review_remark VARCHAR(500) COMMENT '审核备注',
|
||||
reviewed_at DATETIME COMMENT '审核时间',
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
-- 软删除
|
||||
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除',
|
||||
deleted_at DATETIME COMMENT '删除时间',
|
||||
deleted_by BIGINT COMMENT '删除人ID',
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) COMMENT='管理员申请表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、业务逻辑设计
|
||||
|
||||
### 4.1 反馈流程
|
||||
|
||||
```
|
||||
普通用户提交反馈
|
||||
↓
|
||||
反馈进入待处理队列 (PENDING)
|
||||
↓
|
||||
管理员查看反馈
|
||||
↓
|
||||
管理员处理反馈 (更新状态、添加备注)
|
||||
↓
|
||||
用户查看处理结果
|
||||
```
|
||||
|
||||
### 4.2 反馈类型处理
|
||||
|
||||
| 类型 | 流程 | 特殊处理 |
|
||||
|------|------|--------|
|
||||
| BUG | 提交 → 待处理 → 处理中 → 已解决/已拒绝 | 可设置优先级 |
|
||||
| ADMIN_REQUEST | 提交 → 待审核 → 已批准/已拒绝 | 批准后自动升级用户角色 |
|
||||
| SUGGESTION | 提交 → 待处理 → 已解决/已拒绝 | 仅供参考 |
|
||||
|
||||
### 4.3 管理员申请特殊处理
|
||||
|
||||
当管理员批准申请时:
|
||||
1. 更新 admin_request 表状态为 APPROVED
|
||||
2. 更新 user 表的 role 字段为 ADMIN
|
||||
3. 记录审核人和审核时间
|
||||
4. 可选:发送通知给申请用户
|
||||
|
||||
---
|
||||
|
||||
## 五、API 接口设计
|
||||
|
||||
### 5.1 普通用户接口
|
||||
|
||||
```
|
||||
POST /api/feedback - 提交反馈
|
||||
GET /api/feedback/my - 查看自己的反馈列表
|
||||
GET /api/feedback/{id} - 查看反馈详情
|
||||
PUT /api/feedback/{id} - 编辑反馈(仅未处理状态)
|
||||
DELETE /api/feedback/{id} - 删除反馈(仅未处理状态)
|
||||
|
||||
POST /api/admin-request - 提交管理员申请
|
||||
GET /api/admin-request/my - 查看自己的申请
|
||||
GET /api/admin-request/{id} - 查看申请详情
|
||||
```
|
||||
|
||||
### 5.2 管理员接口
|
||||
|
||||
```
|
||||
GET /api/feedback - 查看所有反馈(分页)
|
||||
GET /api/feedback/stats - 反馈统计
|
||||
PUT /api/feedback/{id}/status - 更新反馈状态
|
||||
PUT /api/feedback/{id}/handle - 处理反馈(添加备注)
|
||||
DELETE /api/feedback/{id} - 删除反馈
|
||||
|
||||
GET /api/admin-request - 查看所有申请(分页)
|
||||
PUT /api/admin-request/{id}/approve - 批准申请
|
||||
PUT /api/admin-request/{id}/reject - 拒绝申请
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、前端UI设计
|
||||
|
||||
### 6.1 菜单结构
|
||||
|
||||
```
|
||||
左侧菜单
|
||||
├── 分类列表
|
||||
├── 回收站
|
||||
├── 反馈中心 (新增)
|
||||
│ ├── 反馈BUG
|
||||
│ ├── 申请管理
|
||||
│ └── 提出意见
|
||||
└── 系统管理 (仅ADMIN)
|
||||
```
|
||||
|
||||
### 6.2 反馈中心页面
|
||||
|
||||
**普通用户视图:**
|
||||
- 反馈列表(仅显示自己的)
|
||||
- 提交新反馈表单
|
||||
- 反馈详情和状态查看
|
||||
- 编辑/删除按钮(仅未处理状态)
|
||||
|
||||
**管理员视图:**
|
||||
- 所有反馈列表(可筛选、搜索)
|
||||
- 反馈统计仪表板
|
||||
- 处理反馈表单(更新状态、添加备注)
|
||||
- 批量操作(删除、导出)
|
||||
|
||||
---
|
||||
|
||||
## 七、实现步骤
|
||||
|
||||
### 第一阶段:数据库
|
||||
1. 创建 feedback 表
|
||||
2. 创建 admin_request 表
|
||||
3. 添加索引优化查询
|
||||
|
||||
### 第二阶段:后端
|
||||
1. 创建 Feedback 和 AdminRequest 实体类
|
||||
2. 创建 Mapper 和 Service
|
||||
3. 创建 Controller 和 API 接口
|
||||
4. 添加权限控制注解
|
||||
|
||||
### 第三阶段:前端
|
||||
1. 创建反馈中心菜单项
|
||||
2. 创建反馈列表页面
|
||||
3. 创建反馈提交表单
|
||||
4. 创建管理员处理页面
|
||||
5. 集成到 SidebarMenu
|
||||
|
||||
### 第四阶段:测试
|
||||
1. 单元测试
|
||||
2. 集成测试
|
||||
3. 权限测试
|
||||
|
||||
---
|
||||
|
||||
## 八、关键技术点
|
||||
|
||||
### 权限控制
|
||||
- 使用 @PreAuthorize 注解控制接口访问
|
||||
- 在 Service 层验证用户权限
|
||||
- 确保用户只能操作自己的反馈
|
||||
|
||||
### 数据隔离
|
||||
- 普通用户查询时自动过滤为自己的反馈
|
||||
- 管理员可查看所有反馈
|
||||
|
||||
### 状态管理
|
||||
- 反馈状态流转:PENDING → PROCESSING → RESOLVED/REJECTED
|
||||
- 申请状态流转:PENDING → APPROVED/REJECTED
|
||||
|
||||
### 审计日志
|
||||
- 记录处理人、处理时间、处理备注
|
||||
- 支持软删除,保留历史记录
|
||||
|
||||
---
|
||||
|
||||
## 九、扩展考虑
|
||||
|
||||
1. **通知系统** - 反馈被处理时通知用户
|
||||
2. **评论功能** - 用户和管理员可在反馈下评论
|
||||
3. **附件支持** - 反馈可上传截图或文件
|
||||
4. **优先级排序** - BUG 按优先级排序
|
||||
5. **导出功能** - 管理员可导出反馈报告
|
||||
6. **统计分析** - 反馈类型、状态分布统计
|
||||
|
||||
@@ -168,6 +168,7 @@ CREATE TABLE "user" (
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"role" TEXT DEFAULT 'USER',
|
||||
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"token" TEXT,
|
||||
|
||||
@@ -6,7 +6,7 @@ SET FOREIGN_KEY_CHECKS = 0;
|
||||
-- 1. 分组表
|
||||
DROP TABLE IF EXISTS `grouping`;
|
||||
CREATE TABLE `grouping` (
|
||||
`id` bigintBIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`grouping` VARCHAR(255) NOT NULL,
|
||||
`parentId` BIGINT(20) DEFAULT NULL,
|
||||
`is_deleted` TINYINT(1) DEFAULT 0,
|
||||
@@ -84,12 +84,14 @@ CREATE TABLE `user` (
|
||||
`username` VARCHAR(50) NOT NULL,
|
||||
`password` VARCHAR(255) NOT NULL,
|
||||
`email` VARCHAR(100) DEFAULT NULL,
|
||||
`role` VARCHAR(50) DEFAULT 'USER' COMMENT '用户角色:ADMIN-管理员,USER-普通用户',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`token` VARCHAR(255) DEFAULT NULL,
|
||||
`token_enddata` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_username` (`username`)
|
||||
UNIQUE KEY `uk_username` (`username`),
|
||||
KEY `idx_user_role` (`role`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
BIN
uploads/8357c5d3-eaa4-4aed-8306-ae91da62340b.png
Normal file
BIN
uploads/8357c5d3-eaa4-4aed-8306-ae91da62340b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Reference in New Issue
Block a user