From 42921ba97b074fed958a72732cffcf16ec2bb296 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 21 Sep 2026 16:17:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E5=A4=84=E9=9A=90?= =?UTF-8?q?=E6=82=A3=E5=B9=B6=E5=81=9A=E4=BD=8E=E9=A3=8E=E9=99=A9=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺陷修复: - GalleryUtil.parseGid 只捕 IndexOutOfBoundsException:非数字段抛 NumberFormatException、 link 为 null 抛 NPE,都会穿透成 500(按链接查询直接把用户输入喂进来)。现统一返回 null, 由调用方转成业务失败。 - 图片索引缓存不是整体生效:先写 gidToKey 再逐页写 ImageKeyCache,中途失败会留下 「gidToKey 命中但页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。 现任何异常都回滚已写入部分,并识别历史半截缓存自动重建。 - 全站请求日志把 AuthCode 明文记入 INFO,改为只记 present/absent。 - selectEnableAuthCode 的 SQL 与全量查询完全相同(都无 isEnable 条件),停用用户的 授权码刷新后仍放行,isEnable 形同虚设;补齐条件并允许 null 按默认 true 处理。 - /validate 手工 String.format 拼 JSON,用户名含引号或反斜杠会产出非法 JSON, 前端 JSON.parse 失败;改用 ObjectMapper 组装。 性能与并发: - Netty IO 线程不再直接做 JDBC:节点上报的任务状态改由单线程顺序执行器落库与推送, 既不打乱「按上报顺序覆盖状态」的语义,也不阻塞心跳与响应。 - 取订阅不再等待第三方归属地查询:先以 unknown 落库并立即分发,归属地由后台线程补齐。 - 搜索关键词与分页参数按 UTF-8 编码,&、#、中文不再破坏上游查询串。 - selectAllGallery 的收藏标记由 O(n*m) 嵌套循环改为集合查找。 - 复用 CustomUtil.objectMapper(8 处 new ObjectMapper);gid2MpvKey 改为有界 LRU(2048); 缩略图清理排序不再每次比较都重读文件属性;FileDownload 关闭响应流,避免大文件下载的 临时文件堆积到 Full GC;移除从未使用的 downloadThread 线程池。 测试:431 项全过(新增索引回滚、半截缓存重建、用户名特殊字符、畸形链接查询、 搜索编码、分发不等待定位、批量收藏标记等用例;异步状态处理用例加 awaitStatusApplied 屏障保持断言确定性)。 --- .../Controller/PublicController.java | 17 ++- .../Dao/cache/ImageCacheMapper.java | 10 ++ .../lionwebsite/Dao/normal/SubMapper.java | 4 + .../lionwebsite/Dao/normal/UserMapper.java | 9 +- .../lionwebsite/Filter/AdaptorFilter.java | 17 ++- .../Interceptor/TaskHandlerInterceptor.java | 4 +- .../Service/GalleryManageService.java | 79 ++++++++----- .../lionwebsite/Service/LocalService.java | 24 ++-- .../lionwebsite/Service/PersonalService.java | 5 +- .../lionwebsite/Service/QueryService.java | 15 ++- .../lionwebsite/Service/RemoteService.java | 106 ++++++++++++------ .../lion/lionwebsite/Service/SubService.java | 71 +++++++++--- .../lion/lionwebsite/Service/UserService.java | 5 +- .../lion/lionwebsite/Util/FileDownload.java | 23 ++-- .../lion/lionwebsite/Util/GalleryUtil.java | 41 ++++++- .../Controller/PublicControllerTest.java | 39 ++++++- .../Service/GalleryManageServiceTest.java | 88 +++++++++++++++ .../lionwebsite/Service/GalleryQueryTest.java | 22 ++++ .../lionwebsite/Service/QueryServiceTest.java | 25 +++++ .../Service/RemoteServiceStatusTest.java | 10 ++ .../lionwebsite/Service/SubServiceTest.java | 44 ++++++++ .../lionwebsite/Util/GalleryParsingTest.java | 4 + 22 files changed, 526 insertions(+), 136 deletions(-) diff --git a/src/main/java/com/lion/lionwebsite/Controller/PublicController.java b/src/main/java/com/lion/lionwebsite/Controller/PublicController.java index fd2b722..502b813 100644 --- a/src/main/java/com/lion/lionwebsite/Controller/PublicController.java +++ b/src/main/java/com/lion/lionwebsite/Controller/PublicController.java @@ -7,7 +7,9 @@ import com.lion.lionwebsite.Service.QueryService; import com.lion.lionwebsite.Service.RemoteService; import com.lion.lionwebsite.Service.SubService; import com.lion.lionwebsite.Service.UserService; +import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.Response; +import tools.jackson.databind.node.ObjectNode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -60,13 +62,16 @@ public class PublicController { public String validate(String AuthCode){ Response response = Response.generateResponse(); User user = publicService.getUserId(AuthCode); - String isAvailable = remoteService.isDead() ? "false": "true"; + // 用 ObjectMapper 组装内层 JSON:手工 String.format 拼用户名时, + // 名字里带引号或反斜杠会直接产出非法 JSON,前端 JSON.parse 随即失败。 + // 这里仍以「JSON 文本」形式放进 data(历史契约,前端按字符串再解析一次)。 + ObjectNode identity = CustomUtil.objectMapper.createObjectNode(); + identity.put("userId", user.getId()); + identity.put("username", user.getUsername()); + identity.put("isAvailable", !remoteService.isDead()); // 管理员标记随登录一起下发,前端据此决定是否显示下载人信息与筛选。 - String isAdmin = user.getId() == UserService.ADMIN_USER_ID ? "true" : "false"; - response.success(String.format("{\"userId\": %d, " + - "\"username\": \"%s\", " + - "\"isAvailable\": %s, " + - "\"isAdmin\": %s}", user.getId(), user.getUsername(), isAvailable, isAdmin)); + identity.put("isAdmin", user.getId() == UserService.ADMIN_USER_ID); + response.success(identity.toString()); return response.toJSONString(); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java b/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java index df6255b..3130885 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/cache/ImageCacheMapper.java @@ -5,6 +5,7 @@ import com.lion.lionwebsite.Domain.ImageKeyCache; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Select; @Mapper @@ -21,4 +22,13 @@ public interface ImageCacheMapper { @Select("select * from gidToKey where gid=#{gid}") GidToKey selectKeyByGid(String gid); + /** 已缓存的页 key 行数,用于判断索引是否完整(半截缓存需要重建)。 */ + @Select("select count(*) from ImageKeyCache where gid=#{gid}") + int countImageKeyCacheByGid(String gid); + + @Delete("delete from ImageKeyCache where gid=#{gid}") + void deleteImageKeyCacheByGid(String gid); + + @Delete("delete from gidToKey where gid=#{gid}") + void deleteGidToKey(String gid); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java b/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java index 7a65210..0ee524f 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java @@ -82,6 +82,10 @@ public interface SubMapper { @Select("select min(id) from sub_update_record where user=#{user}") Integer selectMinUpdateRecordId(String user); + /** 后台补齐归属地:只更新该用户最近一条记录,避免覆盖更早的历史记录。 */ + @Update("update sub_update_record set location=#{location} where id=(select max(id) from sub_update_record where user=#{user})") + void updateLatestSubUpdateRecordLocation(@Param("user") String user, @Param("location") String location); + @Delete("delete from sub_update_record where id=#{id}") void deleteSubUpdateRecordById(int id); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java b/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java index 16dae8f..2d90c45 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java @@ -20,7 +20,14 @@ public interface UserMapper { @Select("select AuthCode from User") String[] selectAllAuthCode(); - @Select("select AuthCode from User") + /** + * 仅返回仍启用的授权码。 + * + *

此前这条 SQL 与 {@link #selectAllAuthCode()} 完全相同(都没有 isEnable 条件), + * 于是「停用用户」后刷新授权码集合依旧把该码放行,isEnable 形同虚设。 + * isEnable 为空按 DDL 默认值 true 处理,避免历史行被锁死。 + */ + @Select("select AuthCode from User where isEnable is null or isEnable = 1") String[] selectEnableAuthCode(); @Select("select * from User") diff --git a/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java b/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java index 24f7364..81660d5 100644 --- a/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java +++ b/src/main/java/com/lion/lionwebsite/Filter/AdaptorFilter.java @@ -7,11 +7,15 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import java.io.IOException; -import java.util.Calendar; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; @WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"}) @Slf4j public class AdaptorFilter implements Filter { + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; @@ -22,14 +26,15 @@ public class AdaptorFilter implements Filter { if(UserAgent == null) return; - String AuthCode = request.getParameter("AuthCode") == null ? "null" : request.getParameter("AuthCode"); + // 这里是全站请求日志,AuthCode 是真正的凭据;只记录是否携带,绝不落明文。 + boolean hasAuthCode = request.getParameter("AuthCode") != null; String ServletPath = request.getServletPath(); String ip = request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr(): request.getHeader("X-Forwarded-For"); - Calendar calendar = Calendar.getInstance(); - String now = String.format("%s:%s:%s", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND)); + String now = LocalTime.now().format(TIME_FORMATTER); //日志 - log.info("{} ip:{} \tpath:{} \tAuthCode:{} ua:{}", now, ip, ServletPath, AuthCode, UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent); + log.info("{} ip:{} \tpath:{} \tAuthCode:{} ua:{}", now, ip, ServletPath, + hasAuthCode ? "present" : "absent", UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent); //如果是验证,则直接跳转 if(ServletPath.equals("/validate")) @@ -37,7 +42,7 @@ public class AdaptorFilter implements Filter { //如果不是,则根据UA判断是否跳转 else if ((UserAgent.contains("Android") || UserAgent.contains("iPhone"))) - if (ServletPath.equals("/personal/") && AuthCode.equals("alone")) + if (ServletPath.equals("/personal/") && "alone".equals(request.getParameter("AuthCode"))) response.sendRedirect("/mobile?AuthCode=alone"); else response.sendRedirect("/mobile"); diff --git a/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java b/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java index 9c321f3..073e838 100644 --- a/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java +++ b/src/main/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptor.java @@ -16,7 +16,9 @@ public class TaskHandlerInterceptor implements HandlerInterceptor { final UserMapper userMapper; - String[] AuthCodes; + // 由 PostConstruct 加载后仅被读(发布式更新)与被测试线程读取; + // volatile 保证 refresh 后其它线程立即看到新数组,避免停用的授权码短暂仍可用。 + volatile String[] AuthCodes; @PostConstruct void init() { diff --git a/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java b/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java index 98b329d..bbece46 100644 --- a/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java +++ b/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java @@ -11,8 +11,6 @@ import com.lion.lionwebsite.Util.ImageFileCache; import java.nio.file.Path; import com.lion.lionwebsite.Util.GalleryUtil; import com.lion.lionwebsite.Util.Response; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.node.ObjectNode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -186,7 +184,7 @@ public class GalleryManageService { try { gallery = GalleryUtil.parse(link, false, null); if (gallery != null) - response.success(new ObjectMapper().valueToTree(gallery).toString()); + response.success(objectMapper.valueToTree(gallery).toString()); else response.failure("查询失败"); } catch (Exception e) { @@ -195,7 +193,7 @@ public class GalleryManageService { } else - response.success(new ObjectMapper().valueToTree(gallery).toString()); + response.success(objectMapper.valueToTree(gallery).toString()); return response.toJSONString(); } @@ -234,30 +232,20 @@ public class GalleryManageService { } ArrayList galleryIds = collectMapper.selectGidByCollector(userId); - Iterator idIterator; - - if (!galleryIds.isEmpty()) //如果该用户收藏了图片 - galleryLoop:for (Gallery gallery : galleries) { //遍历图片 - idIterator = galleryIds.iterator(); - while (idIterator.hasNext()) { //遍历收藏的gid - Integer id = idIterator.next(); - if (id.equals(gallery.getGid())) { //如果找到对应的gid,修改对应图片的属性,删除当前gid,判断是否需要跳出或者结束循环 - gallery.setCollect(true); - idIterator.remove(); - - if (galleryIds.isEmpty()) - break galleryLoop; - else - continue galleryLoop; - } - } - } + // 原先对每个画廊线性扫描收藏列表(O(n*m))并就地删除迭代器元素; + // 改成集合查找后同样是「命中即标记」,复杂度降到 O(n+m)。 + if (!galleryIds.isEmpty()) { + Set collected = new HashSet<>(galleryIds); + for (Gallery gallery : galleries) + if (collected.contains(gallery.getGid())) + gallery.setCollect(true); + } // 下载人昵称只对管理员下发;一次建表避免逐条查询。 if (userId == UserService.ADMIN_USER_ID) fillDownloaderNames(galleries); - response.success(new ObjectMapper().valueToTree(galleries).toString()); + response.success(objectMapper.valueToTree(galleries).toString()); return response.toJSONString(); } @@ -314,7 +302,7 @@ public class GalleryManageService { Gallery gallery = galleryMapper.selectGalleryByName("%" + name + "%"); if (gallery != null) - response.success(new ObjectMapper().valueToTree(gallery).toString()); + response.success(objectMapper.valueToTree(gallery).toString()); else response.failure("没有找到该名字的图片"); @@ -332,7 +320,7 @@ public class GalleryManageService { Gallery[] galleries = galleryMapper.selectGalleryByDownloader(userMapper.selectUserByAuthCode(AuthCode).getId()); if (galleries.length > 0) - response.success(new ObjectMapper().valueToTree(galleries).toString()); + response.success(objectMapper.valueToTree(galleries).toString()); else response.failure("您未下载图片"); @@ -411,7 +399,7 @@ public class GalleryManageService { lastResetAmountTime == null || lastResetAmountTime.getValue() == null ? "" : lastResetAmountTime.getValue()); - response.success(new ObjectMapper().valueToTree(data).toString()); + response.success(objectMapper.valueToTree(data).toString()); return response.toJSONString(); } @@ -429,11 +417,17 @@ public class GalleryManageService { String gid = String.valueOf(parsedGid); GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid); - //已缓存过,直接返回 - if(gidToKey != null) { + //已缓存过且页 key 完整,直接返回 + if(gidToKey != null && isIndexComplete(gidToKey)) { return response.success(objectMapper.valueToTree(gidToKey)).toJSONString(); } + // 半截缓存(历史崩溃或旧版本写入中断)先清干净再整体重建,否则残留的页 key 会重复累积。 + if (gidToKey != null) { + log.warn("图片索引缓存不完整,重建 gid={} 记录页数={}", gid, gidToKey.getPages()); + rollbackImageIndex(gid); + } + boolean inserted = false; try { gidToKey = new GidToKey(); gidToKey.setGid(gid); @@ -443,18 +437,41 @@ public class GalleryManageService { return response.failure("该图片已下架或已被删除").toJSONString(); gidToKey.setPages(imageKeyCaches.size()); imageCacheMapper.insertGidToKey(gidToKey); + inserted = true; for (ImageKeyCache imageKeyCache : imageKeyCaches) imageCacheMapper.insertImageKeyCache(imageKeyCache); response.success(objectMapper.valueToTree(gidToKey)); - }catch (IOException | JacksonException e){ - // Jackson 3 的解析异常继承 RuntimeException 而非 IOException, - // 只捕 IOException 会漏掉第三方页面格式变化,穿透成 500。 + }catch (Exception e){ + // 索引必须整体生效:先写 gidToKey 再逐页写 key,中途失败会留下「gidToKey 命中、 + // 页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。 + // 因此任何异常都回滚已写入的部分再回业务失败。Jackson 3 的解析异常继承 + // RuntimeException 而非 IOException,只用 IOException 会漏掉,这里统一兜住。 + if (inserted) + rollbackImageIndex(gid); log.warn("缓存图片索引失败 gid={} errorType={}", gid, e.getClass().getSimpleName()); response.failure("网络波动或其他异常"); } return response.toJSONString(); } + /** 页 key 行数与 gidToKey 记录不一致时视为半截缓存,需要重建。 */ + private boolean isIndexComplete(GidToKey gidToKey) { + Integer pages = gidToKey.getPages(); + if (pages == null || pages <= 0) + return false; + return imageCacheMapper.countImageKeyCacheByGid(gidToKey.getGid()) == pages; + } + + /** 删除某个 gid 已写入的索引(页 key 与 gidToKey),用于回滚或重建。 */ + private void rollbackImageIndex(String gid) { + try { + imageCacheMapper.deleteImageKeyCacheByGid(gid); + imageCacheMapper.deleteGidToKey(gid); + } catch (Exception e) { + log.warn("回滚图片索引缓存失败 gid={}", gid, e); + } + } + public Callable getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) { Path directory = Path.of(cachePath, gid); String name = String.valueOf(page); diff --git a/src/main/java/com/lion/lionwebsite/Service/LocalService.java b/src/main/java/com/lion/lionwebsite/Service/LocalService.java index 0c00b9d..2dac82f 100644 --- a/src/main/java/com/lion/lionwebsite/Service/LocalService.java +++ b/src/main/java/com/lion/lionwebsite/Service/LocalService.java @@ -21,6 +21,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -153,13 +154,15 @@ public class LocalService{ public void clearThumbnailCache(){ String cachePath = "/storage/hentaiCache/"; File directory = new File(cachePath); - List files = new ArrayList<>(); + // 排序需要文件的最后访问时间。原先在比较器里每次重新读属性(n log n 次系统调用), + // 且属性读取失败会让整个排序抛 RuntimeException;这里在遍历时一次性带出属性。 + List> files = new ArrayList<>(); try { Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { if (attrs.isRegularFile()) { - files.add(file); + files.add(Map.entry(file, attrs.lastAccessTime())); } return CONTINUE; } @@ -168,22 +171,15 @@ public class LocalService{ throw new RuntimeException(e); } - files.sort((f1, f2) -> { - try { - return Files.readAttributes(f1, BasicFileAttributes.class).lastAccessTime() - .compareTo(Files.readAttributes(f2, BasicFileAttributes.class).lastAccessTime()); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + files.sort(Map.Entry.comparingByValue()); if (files.size() > 10000) { - List toDelete = files.subList(0, files.size() - 10000); - for (Path file : toDelete) { + List> toDelete = files.subList(0, files.size() - 10000); + for (Map.Entry entry : toDelete) { try { - Files.delete(file); + Files.delete(entry.getKey()); }catch (IOException e){ - log.warn("删除缩略图缓存文件失败: {}", file, e); + log.warn("删除缩略图缓存文件失败: {}", entry.getKey(), e); } } log.info("Deleted {} files", toDelete.size()); diff --git a/src/main/java/com/lion/lionwebsite/Service/PersonalService.java b/src/main/java/com/lion/lionwebsite/Service/PersonalService.java index 9690360..ad5880d 100644 --- a/src/main/java/com/lion/lionwebsite/Service/PersonalService.java +++ b/src/main/java/com/lion/lionwebsite/Service/PersonalService.java @@ -4,7 +4,6 @@ package com.lion.lionwebsite.Service; import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper; import com.lion.lionwebsite.Domain.CustomConfiguration; import com.lion.lionwebsite.Util.Response; -import tools.jackson.databind.ObjectMapper; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -13,6 +12,8 @@ import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; +import static com.lion.lionwebsite.Util.CustomUtil.objectMapper; + @Service @Data @@ -45,7 +46,7 @@ public class PersonalService{ jsonObject.put("ip", ip); jsonObject.put("lastUpdateTime", updateTime); - response.success(new ObjectMapper().valueToTree(jsonObject).toString().replace("\"", " ").replace("\\", " ")); + response.success(objectMapper.valueToTree(jsonObject).toString().replace("\"", " ").replace("\\", " ")); return response.toJSONString(); } diff --git a/src/main/java/com/lion/lionwebsite/Service/QueryService.java b/src/main/java/com/lion/lionwebsite/Service/QueryService.java index 40f6ec7..a71f4ae 100644 --- a/src/main/java/com/lion/lionwebsite/Service/QueryService.java +++ b/src/main/java/com/lion/lionwebsite/Service/QueryService.java @@ -6,7 +6,6 @@ import com.lion.lionwebsite.Util.GalleryUtil; import com.lion.lionwebsite.Util.ImageFileCache; import java.nio.file.Path; import com.lion.lionwebsite.Util.Response; -import tools.jackson.databind.ObjectMapper; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -20,9 +19,12 @@ import org.springframework.stereotype.Service; import java.io.*; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.*; import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour; +import static com.lion.lionwebsite.Util.CustomUtil.objectMapper; @Service @Slf4j @@ -33,12 +35,15 @@ public class QueryService { public String query(String keyword, String prev, String next) { Response response = Response.generateResponse(); String result; - String param = "?f_search=" + keyword.replace(" ", "+") + "&f_sft=on&f_sfu=on&f_sfl=on"; + // 关键词来自用户输入,必须编码后再拼进查询串:未编码的 &、#、中文、空格 + // 会被上游当成额外的查询参数或截断,搜索行为不可预期。 + String param = "?f_search=" + URLEncoder.encode(keyword == null ? "" : keyword, StandardCharsets.UTF_8) + + "&f_sft=on&f_sfu=on&f_sfl=on"; if(prev != null) - param += "&prev=" + prev; + param += "&prev=" + URLEncoder.encode(prev, StandardCharsets.UTF_8); else if(next != null) - param += "&next=" + next; + param += "&next=" + URLEncoder.encode(next, StandardCharsets.UTF_8); try{ result = GalleryUtil.requests("https://exhentai.org/" + param, "get", null, null); @@ -77,7 +82,7 @@ public class QueryService { galleries.add(gallery); } - response.success(new ObjectMapper().valueToTree(galleries).toString()); + response.success(objectMapper.valueToTree(galleries).toString()); Elements nextLink = parse.select("#unext"); if(nextLink.hasAttr("href")) response.set("next", nextLink.attr("href")); diff --git a/src/main/java/com/lion/lionwebsite/Service/RemoteService.java b/src/main/java/com/lion/lionwebsite/Service/RemoteService.java index ebe851e..98c0543 100644 --- a/src/main/java/com/lion/lionwebsite/Service/RemoteService.java +++ b/src/main/java/com/lion/lionwebsite/Service/RemoteService.java @@ -69,8 +69,6 @@ public class RemoteService { volatile boolean stopping; volatile ServerSocket monitorSocket; - ExecutorService downloadThread = Executors.newCachedThreadPool(); - Thread monitor; AtomicInteger atomicInteger = new AtomicInteger(0); @@ -85,6 +83,37 @@ public class RemoteService { return thread; }); + /** + * 处理节点上报的任务状态。原先这些 JDBC 读写直接跑在 Netty 的 IO 线程上, + * N 条上报会阻塞该 IO 线程,连带拖慢心跳与请求响应;改为单线程顺序执行, + * 既不打乱「按上报顺序覆盖状态」的语义,也避免并发写库。 + */ + ExecutorService statusApplyExecutor = newStatusApplyExecutor(); + + private static ExecutorService newStatusApplyExecutor() { + return Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "gallery-status-apply"); + thread.setDaemon(true); + return thread; + }); + } + + /** + * 测试钩子:等待已提交的状态处理任务全部执行完毕。 + * + *

单线程执行器天然有序,投递一个空任务并等它跑完,即说明此前提交的 + * 任务都已处理完,从而让「上报后落库」的断言保持确定性。 + */ + void awaitStatusApplied() { + try { + statusApplyExecutor.submit(() -> { }).get(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.debug("等待状态处理完成失败", e); + } + } + final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean(); final AtomicBoolean subscriptionSyncRunning = new AtomicBoolean(); @@ -266,8 +295,8 @@ public class RemoteService { if (monitor != null) monitor.interrupt(); failPendingRequests(); if (channel != null) channel.close(); + statusApplyExecutor.shutdownNow(); subscriptionSyncExecutor.shutdownNow(); - downloadThread.shutdownNow(); networkGroup.shutdownGracefully(); eventLoopGroup.shutdownGracefully(); } @@ -407,36 +436,15 @@ public class RemoteService { //下载状态 if(msg instanceof DownloadStatusMessage dsm){ GalleryTask[] galleryTasks = dsm.getGalleryTasks(); - for (GalleryTask galleryTask : galleryTasks) { - Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid()); - if (gallery == null) { - log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}", - galleryTask.getGid(), galleryTask.getName()); - continue; + // 落库与通知可能较慢(含 Telegram 推送),交给单线程顺序执行, + // 不再占用 Netty IO 线程;异常在此兜住,避免打进 IO 线程。 + statusApplyExecutor.execute(() -> { + try { + applyReportedStatus(galleryTasks); + } catch (Exception e) { + log.warn("处理节点上报的任务状态失败", e); } - gallery.setProceeding(galleryTask.getProceeding()); - - if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null) - gallery.setName(galleryTask.getName()); - - if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) { - boolean justCompleted = !"下载完成".equals(gallery.getStatus()); - gallery.setStatus("下载完成"); - if (justCompleted) - pushService.downloadComplete(gallery); - } - else if(galleryTask.getStatus() == GalleryTask.COMPRESSING) - gallery.setStatus("压缩中"); - else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE) - gallery.setStatus("等待压缩"); - else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING) - gallery.setStatus("下载中"); - - log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages()); - galleryMapper.updateGallery(gallery); - completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus()); - } - webSocketService.updateTaskProcessing(galleryTasks); + }); } else if(msg instanceof ResponseMessage rsm) { Promise promise = promiseHashMap.remove(rsm.messageId); @@ -447,6 +455,40 @@ public class RemoteService { } } + /** 把节点上报的任务状态写回库里并推送给前端,按上报顺序逐个处理。 */ + private void applyReportedStatus(GalleryTask[] galleryTasks) { + for (GalleryTask galleryTask : galleryTasks) { + Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid()); + if (gallery == null) { + log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}", + galleryTask.getGid(), galleryTask.getName()); + continue; + } + gallery.setProceeding(galleryTask.getProceeding()); + + if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null) + gallery.setName(galleryTask.getName()); + + if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) { + boolean justCompleted = !"下载完成".equals(gallery.getStatus()); + gallery.setStatus("下载完成"); + if (justCompleted) + pushService.downloadComplete(gallery); + } + else if(galleryTask.getStatus() == GalleryTask.COMPRESSING) + gallery.setStatus("压缩中"); + else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE) + gallery.setStatus("等待压缩"); + else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING) + gallery.setStatus("下载中"); + + log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages()); + galleryMapper.updateGallery(gallery); + completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus()); + } + webSocketService.updateTaskProcessing(galleryTasks); + } + @Override public void channelUnregistered(ChannelHandlerContext ctx) { if (ctx.channel() == channel) { diff --git a/src/main/java/com/lion/lionwebsite/Service/SubService.java b/src/main/java/com/lion/lionwebsite/Service/SubService.java index 9ffa833..aaefcfd 100644 --- a/src/main/java/com/lion/lionwebsite/Service/SubService.java +++ b/src/main/java/com/lion/lionwebsite/Service/SubService.java @@ -23,18 +23,37 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; import java.util.Date; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.locks.Lock; import java.util.function.Supplier; -@Service -@Slf4j -@RequiredArgsConstructor -public class SubService { - final SubMapper subMapper; - final UserMapper userMapper; - final SubscriptionRefreshService refreshService; - final RemoteService remoteService; - final SubscriptionStateCoordinator stateCoordinator; + @Service + @Slf4j + @RequiredArgsConstructor + public class SubService { + final SubMapper subMapper; + final UserMapper userMapper; + final SubscriptionRefreshService refreshService; + final RemoteService remoteService; + final SubscriptionStateCoordinator stateCoordinator; + + /** + * 归属地查询是同步外呼(最长十几秒),且失败只影响记录里的一列展示。 + * 若放在订阅分发路径上,用户取一次订阅就得先等它完成,因此改为后台补齐。 + * + *

非 final:测试可替换为受控执行器,避免在单测里触发真实外呼。 + */ + Executor locationExecutor = newFixedLocationExecutor(); + + private static ExecutorService newFixedLocationExecutor() { + return Executors.newFixedThreadPool(2, r -> { + Thread thread = new Thread(r, "sub-location-resolver"); + thread.setDaemon(true); + return thread; + }); + } public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) { Response response = Response.generateResponse(); @@ -241,17 +260,39 @@ public class SubService { } private void recordUpdate(String user, String ip, String ua) { - String location; + // 先落一条 unknown 记录并立即返回,归属地随后在后台补齐; + // 这样取订阅的延迟不再取决于第三方定位站点的响应时间。 + subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), "unknown")); + if (subMapper.selectUpdateRecordCount(user) > 10) + subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user)); + locationExecutor.execute(() -> enrichLocation(user, ip)); + } + + /** 后台补齐最近一条记录的归属地;任何失败都只记日志,不影响已落库的记录。 */ + private void enrichLocation(String user, String ip) { + try { + String location = resolveLocation(ip); + if (location == null || location.isBlank() || "unknown".equals(location)) + return; + subMapper.updateLatestSubUpdateRecordLocation(user, location); + } catch (Exception e) { + log.debug("补齐订阅更新记录归属地失败 user={}", user, e); + } + } + + /** + * 查询 IP 归属地。第三方站点结构变化或网络异常都只降级为 unknown, + * 绝不让定位失败影响订阅分发本身。 + */ + static String resolveLocation(String ip) { try { String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null); Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td"); - location = tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim() : (tds.isEmpty() ? "unknown" : tds.get(1).text()); + return tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim() + : (tds.isEmpty() ? "unknown" : tds.get(1).text()); } catch (Exception e) { - location = "unknown"; + return "unknown"; } - subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), location)); - if (subMapper.selectUpdateRecordCount(user) > 10) - subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user)); } public String deleteSubBind(String user) { diff --git a/src/main/java/com/lion/lionwebsite/Service/UserService.java b/src/main/java/com/lion/lionwebsite/Service/UserService.java index bed1cde..f403b2a 100644 --- a/src/main/java/com/lion/lionwebsite/Service/UserService.java +++ b/src/main/java/com/lion/lionwebsite/Service/UserService.java @@ -7,13 +7,14 @@ import com.lion.lionwebsite.Domain.User; import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.Response; -import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.ArrayList; +import static com.lion.lionwebsite.Util.CustomUtil.objectMapper; + @Service @Slf4j @RequiredArgsConstructor @@ -127,7 +128,7 @@ public class UserService{ Response response = Response.generateResponse(); User[] users = userMapper.selectAllUser(); - response.success(new ObjectMapper().valueToTree(users).toString()); + response.success(objectMapper.valueToTree(users).toString()); return response.toJSONString(); } diff --git a/src/main/java/com/lion/lionwebsite/Util/FileDownload.java b/src/main/java/com/lion/lionwebsite/Util/FileDownload.java index 7d61603..78b4ea1 100644 --- a/src/main/java/com/lion/lionwebsite/Util/FileDownload.java +++ b/src/main/java/com/lion/lionwebsite/Util/FileDownload.java @@ -59,17 +59,20 @@ public class FileDownload { if ("HEAD".equalsIgnoreCase(request.getMethod())) return; input.seek(start); - BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream()); - byte[] buffer = new byte[8192]; - while (remaining > 0) { - int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); - if (count == -1) - throw new EOFException("File changed during download"); - output.write(buffer, 0, count); - remaining -= count; + // 关闭响应流:Tomcat 为大响应体创建的临时文件会在流关闭时删除, + // 不关闭会一直堆积到 Full GC。flushBuffer 只能把内容送出去,不能触发清理。 + try (BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream())) { + byte[] buffer = new byte[8192]; + while (remaining > 0) { + int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); + if (count == -1) + throw new EOFException("File changed during download"); + output.write(buffer, 0, count); + remaining -= count; + } + output.flush(); + response.flushBuffer(); } - output.flush(); - response.flushBuffer(); } catch (ClientAbortException e) { // The client cancelled its download. } catch (IOException e) { diff --git a/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java b/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java index 1c48d44..34564e2 100644 --- a/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java +++ b/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java @@ -29,7 +29,6 @@ import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import static com.lion.lionwebsite.Util.CustomUtil.objectMapper; @@ -45,7 +44,17 @@ public class GalleryUtil { static String JSON = "json"; - static ConcurrentHashMap gid2MpvKey = new ConcurrentHashMap<>(); + /** + * gid → mpvkey 的内存缓存。key 会轮换,代码里对失配也做了刷新兜底, + * 因此这里只需限制为有界 LRU,避免长期运行无上限增长。 + */ + static final Map gid2MpvKey = Collections.synchronizedMap( + new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > 2048; + } + }); /** Reusable HTTP client —不要每次请求新建 */ private static final CloseableHttpClient httpClient = HttpClients.custom() @@ -80,10 +89,15 @@ public class GalleryUtil { } //初始化图片 + Integer gid = parseGid(url); + if (gid == null) { + log.warn("链接无法解析出 gid,按无效链接处理"); + return null; + } Gallery gallery = new Gallery(); gallery.setLink(url); gallery.setCreateTime(System.currentTimeMillis()/1000); - gallery.setGid(Integer.parseInt(url.split("/")[4])); + gallery.setGid(gid); gallery.setProceeding(0); //访问图片页面 @@ -380,10 +394,27 @@ public class GalleryUtil { } } + /** + * 从链接里安全提取 gid。 + * + *

历史实现只捕 {@code IndexOutOfBoundsException}:非数字段会抛 + * {@code NumberFormatException},{@code link == null} 会抛 NPE,两者都会在 + * 「无 @ControllerAdvice」的项目里穿透成 500。这里与 + * {@code GalleryManageService.parseGidFromLink} 保持一致的宽松语义: + * 任何畸形输入都返回 null,由调用方转成业务失败。 + */ public static Integer parseGid(String link){ + if (link == null) + return null; + String[] parts = link.split("/g/"); + if (parts.length < 2) + return null; + String[] segments = parts[1].split("/"); + if (segments.length == 0) + return null; try { - return Integer.parseInt(link.split("/g/")[1].split("/")[0]); - }catch (IndexOutOfBoundsException e){ + return Integer.parseInt(segments[0]); + } catch (NumberFormatException e) { return null; } } diff --git a/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java index 7df503a..3464ebe 100644 --- a/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java +++ b/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java @@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -84,6 +85,10 @@ class PublicControllerTest { /** * 返回体形如 {"result":"success","data":"{\"userId\": 7, ...}"}—— * data 是「JSON 文本的字符串」(历史契约,前端按字符串解析后再反序列化)。 + * + *

内层 JSON 改由 ObjectMapper 生成(原先手工 String.format 拼接,用户名带引号 + * 会产出非法 JSON),因此不再有冒号后的空格;这里断言的是字段与取值, + * 断言的字符串形式随之调整。 */ @Test void validateReturnsIdentityAndNodeAvailability() throws Exception { @@ -92,11 +97,11 @@ class PublicControllerTest { mockMvc.perform(post("/validate").param("AuthCode", "code")) .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"userId\\\": 7"))) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"username\\\": \\\"alice\\\""))) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\": true"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"userId\\\":7"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"username\\\":\\\"alice\\\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\":true"))) // 普通用户 isAdmin=false,前端据此隐藏下载人信息。 - .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\": false"))); + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\":false"))); } /** 管理员(id=3)必须带上 isAdmin=true,前端据此显示下载人信息与筛选。 */ @@ -107,7 +112,29 @@ class PublicControllerTest { mockMvc.perform(post("/validate").param("AuthCode", "admin")) .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\": true"))); + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\":true"))); + } + + /** + * 用户名含引号/反斜杠时,内层 data 仍必须是合法 JSON。 + * 回归:原先手工 String.format 拼接会在这种输入下产出非法 JSON, + * 前端 JSON.parse(res.data.data) 会直接失败,用户卡在登录态。 + */ + @Test + void validateEscapesSpecialCharactersInUsername() throws Exception { + when(publicService.getUserId("code")).thenReturn(new User(7, "code", "a\"b\\c", null, true)); + when(remoteService.isDead()).thenReturn(false); + + var result = mockMvc.perform(post("/validate").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andReturn(); + + // 取出外层 data(是内层 JSON 的字符串形式),必须能被 JSON 解析回原名。 + var outer = com.lion.lionwebsite.Util.CustomUtil.objectMapper + .readTree(result.getResponse().getContentAsString()); + var inner = com.lion.lionwebsite.Util.CustomUtil.objectMapper + .readTree(outer.get("data").asText()); + assertEquals("a\"b\\c", inner.get("username").asText(), "特殊字符必须原样保留且 JSON 合法"); } /** 存储节点掉线时 isAvailable 必须为 false,前端据此提示。 */ @@ -118,7 +145,7 @@ class PublicControllerTest { mockMvc.perform(post("/validate").param("AuthCode", "code")) .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\": false"))); + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\":false"))); } @Test diff --git a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java index ad8321d..52b3b79 100644 --- a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java +++ b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java @@ -137,6 +137,79 @@ class GalleryManageServiceTest { verify(imageCacheMapper, never()).insertGidToKey(any()); } + /** + * 回归:索引写入不是原子的。若 gidToKey 写入成功、逐页写 ImageKeyCache 时失败, + * 会留下「gidToKey 命中但页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。 + * 现在任何异常都要把已写入的部分清掉再回业务失败。 + */ + @Test + void cacheImagesKeyRollsBackWhenPageInsertFails() throws Exception { + ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class); + when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null); + doThrow(new RuntimeException("db down")).when(imageCacheMapper).insertImageKeyCache(any()); + GalleryManageService svc = new GalleryManageService(galleries, collectMapper, + configurationMapper, users, + imageCacheMapper, remote, push); + + String realMpvPage = ""; + + try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString())) + .thenCallRealMethod(); + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil + .requests(anyString(), anyString(), any(), any())) + .thenReturn(realMpvPage); + + String json = assertDoesNotThrow(() -> + svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/")); + assertFalse(json.contains("\"result\":\"success\""), "写入失败不应报成功: " + json); + } + + verify(imageCacheMapper).insertGidToKey(any()); + verify(imageCacheMapper).deleteImageKeyCacheByGid(anyString()); + verify(imageCacheMapper).deleteGidToKey(anyString()); + } + + /** 已缓存的索引若页 key 数对不上(半截缓存),必须重建而不是直接返回。 */ + @Test + void cacheImagesKeyRebuildsIncompleteCache() throws Exception { + ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class); + var stale = new com.lion.lionwebsite.Domain.GidToKey(); + stale.setGid("1596929"); + stale.setKey("f08534d87d"); + stale.setPages(3); + when(imageCacheMapper.selectKeyByGid("1596929")).thenReturn(stale); + when(imageCacheMapper.countImageKeyCacheByGid("1596929")).thenReturn(1); // 只剩 1 页 + GalleryManageService svc = new GalleryManageService(galleries, collectMapper, + configurationMapper, users, + imageCacheMapper, remote, push); + + String realMpvPage = ""; + + try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString())) + .thenCallRealMethod(); + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil + .requests(anyString(), anyString(), any(), any())) + .thenReturn(realMpvPage); + + String json = svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/"); + assertTrue(json.contains("\"result\":\"success\""), "应重建成功: " + json); + } + + verify(imageCacheMapper).deleteImageKeyCacheByGid("1596929"); + verify(imageCacheMapper).deleteGidToKey("1596929"); + verify(imageCacheMapper).insertGidToKey(any()); + } + // ---------- createTask 输入校验 ---------- /** 链接第 5 段非数字时应返回「链接错误」且不落库、不下发节点。 */ @@ -243,6 +316,21 @@ class GalleryManageServiceTest { } } + /** + * 回归:GalleryUtil.parseGid 以前只捕 IndexOutOfBoundsException, + * 「非数字 gid」与「null」都会穿透成 500。按链接查询会直接把用户输入喂进来, + * 这里锁住真实实现(不 mock)在这些输入下都安全返回失败。 + */ + @Test + void selectTaskByLinkHandlesMalformedGidWithoutThrowing() { + for (String bad : new String[]{"https://exhentai.org/g/not-a-number/key/", "garbage", null}) { + String response = assertDoesNotThrow(() -> service.selectTaskByLink(bad), + "畸形链接不应抛异常,实际输入: " + bad); + assertFalse(response.contains("\"result\":\"success\""), "实际输出: " + response); + } + verify(galleries, never()).selectGalleryByGid(anyInt()); + } + @Test void selectTaskByGidReturnsFailureWhenAbsent() { when(galleries.selectGalleryByGid(404)).thenReturn(null); diff --git a/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java b/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java index aafbc8f..27cc6fa 100644 --- a/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java +++ b/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java @@ -96,6 +96,28 @@ class GalleryQueryTest { assertFalse(all[0].isCollect()); } + /** + * 收藏标记改为集合查找后的批量正确性:每个画廊只依据自己的 gid 是否在收藏集合中。 + * 用「任务远多于收藏」的规模锁住「不漏标、不误标」两点。 + */ + @Test + void selectAllGalleryMarksCollectionsCorrectlyAtScale() { + int total = 500; + Gallery[] all = new Gallery[total]; + for (int i = 0; i < total; i++) + all[i] = gallery(i, "G" + i, "下载中"); + when(galleries.selectAllGallery()).thenReturn(all); + // 收藏偶数 gid,构造一半命中、一半不命中 + ArrayList collected = new ArrayList<>(); + for (int i = 0; i < total; i += 2) + collected.add(i); + when(collectMapper.selectGidByCollector(7)).thenReturn(collected); + + assertTrue(ok(service.selectAllGallery(7))); + for (int i = 0; i < total; i++) + assertEquals(i % 2 == 0, all[i].isCollect(), "gid=" + i + " 的收藏标记不正确"); + } + /** 查询结果为 null 时应回业务失败而不是 NPE。 */ @Test void selectAllGalleryReportsFailureWhenNull() { diff --git a/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java index 053f8fd..c496024 100644 --- a/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java +++ b/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java @@ -121,6 +121,31 @@ class QueryServiceTest { } } + /** + * 含保留字符的关键词必须编码后再拼进上游 URL。 + * 回归:未编码时 `&` 会被上游当作参数分隔符、中文会被当成非法字节, + * 搜索结果不可预期。 + */ + @Test + void queryUrlEncodesKeywordAndPaginationParams() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + requests.when(() -> GalleryUtil.requests(captor.capture(), anyString(), any(), any())) + .thenReturn(resultPage()); + + service.query("a&b=c", "P&1", null); + String url = captor.getValue(); + assertTrue(url.contains("f_search=a%26b%3Dc"), "关键词必须编码: " + url); + assertTrue(url.contains("prev=P%261"), "分页参数必须编码: " + url); + // 编码后不应凭空多出上游会解析的裸参数 + assertFalse(url.contains("b=c"), "未编码的 & 会分裂参数: " + url); + + service.query("中文", null, null); + assertTrue(captor.getValue().contains("f_search=%E4%B8%AD%E6%96%87"), + "中文必须按 UTF-8 百分号编码: " + captor.getValue()); + } + } + /** 搜索页无结果行时必须返回业务失败,而不是抛异常。 */ @Test void queryReturnsFailureWhenNoResultRows() throws Exception { diff --git a/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java b/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java index d022395..d99c3e4 100644 --- a/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java +++ b/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java @@ -76,6 +76,7 @@ class RemoteServiceStatusTest { when(galleryMapper.selectGalleryByGid(100)).thenReturn(existing); channel.writeInbound(status(task(100, "G", GalleryTask.DOWNLOADING, 7))); + service.awaitStatusApplied(); assertEquals(7, existing.getProceeding()); assertEquals("下载中", existing.getStatus()); @@ -91,11 +92,13 @@ class RemoteServiceStatusTest { when(galleryMapper.selectGalleryByGid(101)).thenReturn(existing); channel.writeInbound(status(task(101, "G", GalleryTask.COMPRESS_COMPLETE, 40))); + service.awaitStatusApplied(); assertEquals("下载完成", existing.getStatus()); verify(pushService).downloadComplete(existing); // 再次上报同一完成状态:不应重复通知 channel.writeInbound(status(task(101, "G", GalleryTask.COMPRESS_COMPLETE, 40))); + service.awaitStatusApplied(); verify(pushService, times(1)).downloadComplete(any()); } @@ -106,12 +109,15 @@ class RemoteServiceStatusTest { when(galleryMapper.selectGalleryByGid(102)).thenReturn(existing); channel.writeInbound(status(task(102, "G", GalleryTask.COMPRESSING, 10))); + service.awaitStatusApplied(); assertEquals("压缩中", existing.getStatus()); channel.writeInbound(status(task(102, "G", GalleryTask.DOWNLOAD_COMPLETE, 10))); + service.awaitStatusApplied(); assertEquals("等待压缩", existing.getStatus()); channel.writeInbound(status(task(102, "G", GalleryTask.DOWNLOADING, 5))); + service.awaitStatusApplied(); assertEquals("下载中", existing.getStatus()); assertEquals(5, existing.getProceeding()); } @@ -122,6 +128,7 @@ class RemoteServiceStatusTest { when(galleryMapper.selectGalleryByGid(999)).thenReturn(null); channel.writeInbound(status(task(999, "ghost", GalleryTask.DOWNLOADING, 3))); + service.awaitStatusApplied(); verify(galleryMapper, never()).updateGallery(any()); } @@ -133,6 +140,7 @@ class RemoteServiceStatusTest { when(galleryMapper.selectGalleryByGid(103)).thenReturn(existing); channel.writeInbound(status(task(103, "new-name", GalleryTask.DOWNLOADING, 1))); + service.awaitStatusApplied(); assertEquals("new-name", existing.getName()); } @@ -146,6 +154,7 @@ class RemoteServiceStatusTest { channel.writeInbound(status( task(201, "A", GalleryTask.DOWNLOADING, 1), task(202, "B", GalleryTask.COMPRESSING, 2))); + service.awaitStatusApplied(); verify(galleryMapper, times(2)).updateGallery(any()); } @@ -172,6 +181,7 @@ class RemoteServiceStatusTest { assertNotNull(result); channel.writeInbound(status(task(300, "G", GalleryTask.COMPRESS_COMPLETE, 10))); + service.awaitStatusApplied(); assertEquals("下载完成", existing.getStatus()); } } diff --git a/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java index f29eb80..25664fa 100644 --- a/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java +++ b/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java @@ -345,4 +345,48 @@ class SubServiceTest { service.updateSub(response, request, "v2", null); verifyNoInteractions(response); } + + /** + * 取订阅时不能等待第三方归属地查询:记录先以 unknown 落库并立即返回订阅内容。 + * 回归:原先同步请求 ip138,用户取一次订阅要先等最长十几秒的外呼。 + */ + @Test + void publicSubRecordsUnknownLocationWithoutWaitingForLookup() throws Exception { + SubBind bind = new SubBind("key1", "alice", 1, "name", true, false); + when(subMapper.selectSubBind("key1")).thenReturn(bind); + // 缓存文件不存在即可(本用例只关心分发不等待定位查询),与既有 503 用例保持一致。 + when(refreshService.cachedPath(eq(1), anyString())) + .thenReturn(java.nio.file.Path.of("/nonexistent/cache/v2.txt")); + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0"); + when(request.getRemoteAddr()).thenReturn("203.0.113.5"); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + + // 用受控执行器换取「后台任务被投递」这一事实,避免静态 mock 到不了后台线程、 + // 也避免单测真的去请求 ip138。 + var recorded = new java.util.concurrent.atomic.AtomicReference(); + service.locationExecutor = command -> recorded.set(command); + + long start = System.nanoTime(); + service.updateSub(response, request, "v2", "key1"); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + assertTrue(elapsedMs < 2_000, "分发不应等待归属地查询,实际耗时 " + elapsedMs + "ms"); + assertNotNull(recorded.get(), "归属地查询应被投递到后台执行,而不是同步执行"); + + var captor = org.mockito.ArgumentCaptor.forClass(com.lion.lionwebsite.Domain.SubUpdateRecord.class); + verify(subMapper).insertSubUpdateRecord(captor.capture()); + assertEquals("unknown", captor.getValue().getLocation(), "先落库 unknown,随后由后台补齐"); + } + + /** 后台补齐只更新最近一条记录,且定位失败不影响分发。 */ + @Test + void locationLookupFallsBackToUnknownOnFailure() { + try (var util = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + util.when(() -> com.lion.lionwebsite.Util.GalleryUtil + .requests(anyString(), anyString(), any(), any())) + .thenThrow(new RuntimeException("network down")); + + assertEquals("unknown", SubService.resolveLocation("203.0.113.5")); + } + } } diff --git a/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java b/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java index f2d4edd..1ca4700 100644 --- a/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java +++ b/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java @@ -66,6 +66,9 @@ class GalleryParsingTest { return downloadPage(); }); ms.when(() -> GalleryUtil.verifyLink(anyString())).thenCallRealMethod(); + // parse() 现在通过 parseGid 取 gid(不再直接切分第 5 段),静态 mock 必须一并放行, + // 否则未打桩的 parseGid 会返回 null,parse 直接被当成无效链接。 + ms.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod(); ms.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenCallRealMethod(); } @@ -133,6 +136,7 @@ class GalleryParsingTest { return downloadPage(); }); ms.when(() -> GalleryUtil.verifyLink(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod(); ms.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenCallRealMethod(); Gallery g = GalleryUtil.parse(URL, true, "Original");