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

缺陷修复:
- 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;
}
}