Files
lionwebsite-backend/src/main/java/com/lion/lionwebsite/Service/LocalService.java
T
root 42921ba97b 修复多处隐患并做低风险性能优化
缺陷修复:
- 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
屏障保持断言确定性)。
2026-09-21 16:17:17 +08:00

191 lines
7.2 KiB
Java

package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.GalleryUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.HttpEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.*;
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.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.nio.file.FileVisitResult.CONTINUE;
@Service
@Data
@Slf4j
public class LocalService{
@Value("${local.dou-nai-clash:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta}")
String DouNaiClash;
@Value("${local.dou-nai-v2ray:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2}")
String DouNaiV2ray;
private static final CloseableHttpClient httpClient = HttpClients.createDefault();
final CustomConfigurationMapper configurationMapper;
final GalleryMapper galleryMapper;
final PushService pushService;
final RemoteService remoteService;
final SubscriptionRefreshScheduler subscriptionRefreshScheduler;
/**
* 检查连接是否有效,如果无效自动重连
*/
@Scheduled(cron = "0 0/30 * * * *")
public void CheckConnectionAvailability(){
if (remoteService.isDead()){
remoteService.initChannel();
pushService.sendToMe("主动检测连接已断开,自动进行重连");
log.warn("主动检测连接已断开,自动进行重连");
return;
}
// -1 为对方没有返回
if (remoteService.checkAvailability() == -1) {
String result = switch (remoteService.reconnect()){
case 0 -> "重连成功";
case -1 -> "重连失败";
case -2 -> "当前未连接,不进行重连";
default -> "未知错误";
};
pushService.sendToMe("主动检测连接无数据返回,自动进行重连:" + result);
log.warn("主动检测连接无数据返回,自动进行重连:{}", result);
}
}
/**
* 每周周一四点重置额度
*/
@Scheduled(cron = "0 0 4 * * MON")
public void reset() {
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, "0");
configurationMapper.updateConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME, CustomUtil.now());
}
/**
* 每天凌晨测试e-hentai cookie是否过期
*/
@Scheduled(cron = "0 0 0 * * *")
public void verifyCookie(){
try {
String content = GalleryUtil.requests("https://exhentai.org", "GET", null, null);
if(content.trim().isEmpty())
pushService.sendToMe("cookie过期");
} catch (IOException e) {
pushService.sendToMe("检测cookie异常:" + e.getMessage());
}
}
/**
* 手动「更新订阅」:立即刷新全部启用账号,供管理页按钮调用。
*
* <p>定时刷新已移交 {@link SubscriptionRefreshScheduler}。原先这里还有一个
* {@code @Scheduled(fixedRate = 86400000)} 的定时入口,它会一次性刷新全部账号
* (十几个账号在十几秒内打满),且因为没有 initialDelay,每次重启都会立刻重刷一遍,
* 使上游看到的请求密度取决于部署频率。该入口已移除。
*
* <p>不再保留 {@code isManual} 参数:定时路径已不存在,留着它只会让人以为
* 「传 false 就是不刷新」,而实际语义是「什么都不做却报成功」。
*/
public boolean updateSub() {
boolean success = subscriptionRefreshScheduler.refreshAllNow();
remoteService.requestSubscriptionSync();
if (success)
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME,
CustomUtil.dateTimeFormatter().format(LocalDateTime.now()));
return success;
}
/**
* 封装好的get方法
* @param url url
* @return 请求结果
* @throws IOException 网络异常
*/
public static ArrayList<String> Get(String url) throws IOException {
CloseableHttpResponse httpResponse;
HttpGet httpGet = new HttpGet(url);
httpResponse = httpClient.execute(httpGet);
HttpEntity responseEntity = httpResponse.getEntity();
int statusCode = httpResponse.getCode();
ArrayList<String> temp = new ArrayList<>();
if (statusCode == 200) {
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
String str;
while ((str = reader.readLine()) != null)
temp.add(str);
}
httpResponse.close();
return temp;
}
/**
* 每周,查看缩略图数量,按照访问时间排序,删除超出1w的部分
*/
@Scheduled(cron = "0 0 4 1 * *")
public void clearThumbnailCache(){
String cachePath = "/storage/hentaiCache/";
File directory = new File(cachePath);
// 排序需要文件的最后访问时间。原先在比较器里每次重新读属性(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(Map.entry(file, attrs.lastAccessTime()));
}
return CONTINUE;
}
});
} catch (IOException e) {
throw new RuntimeException(e);
}
files.sort(Map.Entry.comparingByValue());
if (files.size() > 10000) {
List<Map.Entry<Path, FileTime>> toDelete = files.subList(0, files.size() - 10000);
for (Map.Entry<Path, FileTime> entry : toDelete) {
try {
Files.delete(entry.getKey());
}catch (IOException e){
log.warn("删除缩略图缓存文件失败: {}", entry.getKey(), e);
}
}
log.info("Deleted {} files", toDelete.size());
} else {
log.info("No files to delete");
}
}
}