From d54719d82d61f21fbcf1166986414e6339180822 Mon Sep 17 00:00:00 2001 From: ikmkj Date: Tue, 3 Mar 2026 19:14:48 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E5=92=8C=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化JWT解析器性能,复用JwtParser减少重复创建 - 使用Slf4j日志框架替代System.out.println - 添加图片压缩功能,减少存储和带宽消耗 - 预编译正则表达式提升Markdown图片提取性能 - 重构限流拦截器,使用ConcurrentHashMap提高并发性能 - 添加数据库索引优化查询性能 - 为MarkdownFile实体添加autoResultMap配置 --- .../test/bijihoudaun/entity/MarkdownFile.java | 4 +- .../interceptor/RateLimitInterceptor.java | 171 ++++++++++++------ .../scheduler/ImageCleanupScheduler.java | 5 +- .../service/impl/ImageServiceImpl.java | 15 +- .../bijihoudaun/util/ImageCompressor.java | 121 +++++++++++++ .../test/bijihoudaun/util/JwtTokenUtil.java | 10 +- .../util/MarkdownImageExtractor.java | 21 ++- .../db/migration/V20240303__add_indexes.sql | 38 ++++ 8 files changed, 312 insertions(+), 73 deletions(-) create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/util/ImageCompressor.java create mode 100644 biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFile.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFile.java index 289e86c..f45a592 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFile.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFile.java @@ -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; -} \ No newline at end of file +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java index 9871a68..3461af7 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java @@ -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,116 @@ 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 ipCounters = new LRUCache<>(MAX_RECORDS / 2); - private static final LRUCache ipLoginCounters = new LRUCache<>(MAX_RECORDS / 4); - // 用户级别限流 - private static final LRUCache userCounters = new LRUCache<>(MAX_RECORDS / 4); - private static final LRUCache userLoginCounters = new LRUCache<>(MAX_RECORDS / 4); + // 使用 ConcurrentHashMap + 原子操作,避免锁竞争 + private static final ConcurrentHashMap ipCounters = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap ipLoginCounters = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap userCounters = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap 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; + long currentWindow = windowStart; + + if (now - currentWindow > WINDOW_SIZE_MS) { + // 尝试进入新窗口 + synchronized (this) { + if (windowStart == currentWindow) { + // 确实需要新窗口 + windowStart = now; + count.set(1); + return true; + } + } + // 其他线程已经更新了窗口,继续检查 } - count++; - return count <= maxRequests; + + int currentCount = count.incrementAndGet(); + return currentCount <= 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 extends LinkedHashMap { - private final int maxSize; - - LRUCache(int maxSize) { - super(maxSize, 0.75f, true); - this.maxSize = maxSize; - } - - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - boolean shouldRemove = size() > maxSize; - if (shouldRemove) { - log.debug("限流记录达到上限,移除最旧的记录"); - } - return shouldRemove; - } + private int cleanupMap(ConcurrentHashMap map) { + int sizeBefore = map.size(); + // 使用 ConcurrentHashMap 的 removeIf 方法,线程安全 + map.entrySet().removeIf(entry -> entry.getValue().isExpired()); + return sizeBefore - map.size(); } @Override @@ -117,19 +180,14 @@ public class RateLimitInterceptor implements HandlerInterceptor { */ private boolean checkIpLimit(String clientIp, boolean isLoginRequest, HttpServletResponse response) throws Exception { - LRUCache counters = isLoginRequest ? ipLoginCounters : ipCounters; + ConcurrentHashMap 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 +197,14 @@ public class RateLimitInterceptor implements HandlerInterceptor { */ private boolean checkUserLimit(String username, boolean isLoginRequest, HttpServletResponse response) throws Exception { - LRUCache counters = isLoginRequest ? userLoginCounters : userCounters; + ConcurrentHashMap 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; } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/scheduler/ImageCleanupScheduler.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/scheduler/ImageCleanupScheduler.java index c7574b9..2e569f8 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/scheduler/ImageCleanupScheduler.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/scheduler/ImageCleanupScheduler.java @@ -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); } } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java index 3dc2840..1dd8458 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java @@ -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,6 +17,7 @@ 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; @@ -79,7 +81,18 @@ public class ImageServiceImpl String storedName = UUID.randomUUID() + extension; Path filePath = uploadPath.resolve(storedName); - Files.copy(file.getInputStream(), filePath); + + // 优化:压缩图片后再保存 + InputStream compressedStream = ImageCompressor.compressIfNeeded( + file.getInputStream(), contentType, file.getSize()); + + if (compressedStream != null) { + // 使用压缩后的图片 + Files.copy(compressedStream, filePath); + } else { + // 使用原图 + Files.copy(file.getInputStream(), filePath); + } Image image = new Image(); image.setOriginalName(originalFilename); diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/ImageCompressor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/ImageCompressor.java new file mode 100644 index 0000000..3f89a21 --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/ImageCompressor.java @@ -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)); + } + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/JwtTokenUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/JwtTokenUtil.java index f56b329..c7e134d 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/JwtTokenUtil.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/JwtTokenUtil.java @@ -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)); } -} \ No newline at end of file +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/MarkdownImageExtractor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/MarkdownImageExtractor.java index a13061e..4c2e95b 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/MarkdownImageExtractor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/MarkdownImageExtractor.java @@ -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图片语法中的文件名 - // 模式: ![alt](url) 其中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 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 urls = new ArrayList<>(); while (matcher.find()) { diff --git a/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql b/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql new file mode 100644 index 0000000..ef6581c --- /dev/null +++ b/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql @@ -0,0 +1,38 @@ +-- 数据库性能优化索引 +-- 创建时间: 2024-03-03 + +-- markdown_file 表索引 +-- 按分组查询 +CREATE INDEX IF NOT EXISTS idx_markdown_grouping_id ON markdown_file(grouping_id); +-- 按删除状态查询(软删除) +CREATE INDEX IF NOT EXISTS idx_markdown_is_deleted ON markdown_file(is_deleted); +-- 按创建时间排序 +CREATE INDEX IF NOT EXISTS idx_markdown_created_at ON markdown_file(created_at); +-- 复合索引:查询未删除的分组笔记 +CREATE INDEX IF NOT EXISTS idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted); + +-- image 表索引 +-- 按 markdown_id 查询(关联查询) +CREATE INDEX IF NOT EXISTS idx_image_markdown_id ON image(markdown_id); +-- 按存储文件名查询 +CREATE INDEX IF NOT EXISTS idx_image_stored_name ON image(stored_name); +-- 按创建时间查询(清理旧图片) +CREATE INDEX IF NOT EXISTS idx_image_created_at ON image(created_at); + +-- grouping 表索引 +-- 按父分组查询 +CREATE INDEX IF NOT EXISTS idx_grouping_parent_id ON grouping(parent_id); +-- 按删除状态查询 +CREATE INDEX IF NOT EXISTS idx_grouping_is_deleted ON grouping(is_deleted); + +-- trash 表索引 +-- 按用户查询 +CREATE INDEX IF NOT EXISTS idx_trash_user_id ON trash(user_id); +-- 按删除时间排序(清理过期数据) +CREATE INDEX IF NOT EXISTS idx_trash_deleted_at ON trash(deleted_at); +-- 按类型查询 +CREATE INDEX IF NOT EXISTS idx_trash_item_type ON trash(item_type); + +-- user 表索引 +-- 按用户名查询(登录) +CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);