修复多处隐患并做低风险性能优化

缺陷修复:
- 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
屏障保持断言确定性)。
This commit is contained in:
root
2026-09-21 16:17:17 +08:00
parent 584db277a6
commit 42921ba97b
22 changed files with 526 additions and 136 deletions
@@ -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();
}
@@ -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);
}
@@ -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);
}
@@ -20,7 +20,14 @@ public interface UserMapper {
@Select("select AuthCode from User")
String[] selectAllAuthCode();
@Select("select AuthCode from User")
/**
* 仅返回仍启用的授权码。
*
* <p>此前这条 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")
@@ -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");
@@ -16,7 +16,9 @@ public class TaskHandlerInterceptor implements HandlerInterceptor {
final UserMapper userMapper;
String[] AuthCodes;
// 由 PostConstruct 加载后仅被读(发布式更新)与被测试线程读取;
// volatile 保证 refresh 后其它线程立即看到新数组,避免停用的授权码短暂仍可用。
volatile String[] AuthCodes;
@PostConstruct
void init() {
@@ -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<Integer> galleryIds = collectMapper.selectGidByCollector(userId);
Iterator<Integer> 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<Integer> 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);
@@ -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<Path> files = new ArrayList<>();
// 排序需要文件的最后访问时间。原先在比较器里每次重新读属性(n log n 次系统调用),
// 且属性读取失败会让整个排序抛 RuntimeException;这里在遍历时一次性带出属性。
List<Map.Entry<Path, FileTime>> 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<Path> toDelete = files.subList(0, files.size() - 10000);
for (Path file : toDelete) {
List<Map.Entry<Path, FileTime>> toDelete = files.subList(0, files.size() - 10000);
for (Map.Entry<Path, FileTime> 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());
@@ -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();
}
@@ -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"));
@@ -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;
});
}
/**
* 测试钩子:等待已提交的状态处理任务全部执行完毕。
*
* <p>单线程执行器天然有序,投递一个空任务并等它跑完,即说明此前提交的
* 任务都已处理完,从而让「上报后落库」的断言保持确定性。
*/
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<AbstractMessage> 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) {
@@ -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;
/**
* 归属地查询是同步外呼(最长十几秒),且失败只影响记录里的一列展示。
* 若放在订阅分发路径上,用户取一次订阅就得先等它完成,因此改为后台补齐。
*
* <p>非 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) {
@@ -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();
}
@@ -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) {
@@ -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<String, String> gid2MpvKey = new ConcurrentHashMap<>();
/**
* gid → mpvkey 的内存缓存。key 会轮换,代码里对失配也做了刷新兜底,
* 因此这里只需限制为有界 LRU,避免长期运行无上限增长。
*/
static final Map<String, String> gid2MpvKey = Collections.synchronizedMap(
new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> 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。
*
* <p>历史实现只捕 {@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;
}
}
@@ -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 文本的字符串」(历史契约,前端按字符串解析后再反序列化)。
*
* <p>内层 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
@@ -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 = "<html><body><script>x</script><script>\n"
+ "var gid=1596929;\n"
+ "var mpvkey = \"nfa9l8ianjg\";\n"
+ "var imagelist = [{\"n\":\"a.png\",\"k\":\"bd9015\",\"t\":\"(x) -0px 0\"}];\n"
+ "</script></body></html>";
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 = "<html><body><script>x</script><script>\n"
+ "var gid=1596929;\n"
+ "var mpvkey = \"nfa9l8ianjg\";\n"
+ "var imagelist = [{\"n\":\"a.png\",\"k\":\"bd9015\",\"t\":\"(x) -0px 0\"}];\n"
+ "</script></body></html>";
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);
@@ -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<Integer> 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() {
@@ -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 {
@@ -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());
}
}
@@ -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<Runnable>();
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"));
}
}
}
@@ -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");