feat: 添加XSS防护工具函数并优化多个功能模块

refactor(前端): 重构登录页面和用户状态管理逻辑

fix(后端): 修复用户密码更新逻辑和错误提示

feat(后端): 实现分页搜索功能并优化文件删除逻辑

perf(前端): 优化笔记编辑器自动保存和状态提示

fix(后端): 增强图片上传安全验证

style(前端): 调整笔记预览页面按钮权限控制

chore: 更新生产环境配置和测试数据库依赖

feat(前端): 添加虚拟列表组件优化性能

fix(前端): 修复笔记编辑器返回逻辑和状态保存

refactor(前端): 优化axios拦截器错误处理逻辑

docs: 更新方法注释和参数说明
This commit is contained in:
ikmkj
2026-03-04 18:29:52 +08:00
parent 5ea9c776e7
commit 23ced99e20
17 changed files with 369 additions and 104 deletions

View File

@@ -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>

View File

@@ -93,8 +93,13 @@ public class MarkdownController {
@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);
}

View File

@@ -49,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文件标题

View File

@@ -21,8 +21,11 @@ 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;
@@ -35,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
@@ -73,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);
@@ -114,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);

View File

@@ -14,6 +14,7 @@ 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;
@@ -113,10 +114,12 @@ public class MarkdownFileServiceImpl
@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);
}
@@ -131,13 +134,16 @@ public class MarkdownFileServiceImpl
}
@Override
public List<MarkdownFile> searchByTitle(String keyword) {
// 修复:使用 LambdaQueryWrapper 避免 SQL 注入风险
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);
queryWrapper.like(MarkdownFile::getTitle, keyword)
.eq(MarkdownFile::getIsDeleted, 0)
.orderByDesc(MarkdownFile::getUpdatedAt)
.last("LIMIT " + offset + "," + pageSize);
return this.list(queryWrapper);
}

View File

@@ -111,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("登录失败,请稍后重试");
}
// 登录成功,清除失败记录
@@ -144,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("新密码不能为空");
@@ -152,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);
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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