修复多处隐患并做低风险性能优化
缺陷修复: - 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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user