Compare commits
4
Commits
f5ef8ca487
...
576092578d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576092578d | ||
|
|
213ed4e7f6 | ||
|
|
d5b97b82a0 | ||
|
|
4623fca8b0 |
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.3.2</version>
|
<version>4.1.1</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>com.lion</groupId>
|
<groupId>com.lion</groupId>
|
||||||
@@ -19,18 +19,18 @@
|
|||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mybatis.spring.boot</groupId>
|
<groupId>org.mybatis.spring.boot</groupId>
|
||||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||||
<version>3.0.3</version>
|
<version>4.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.40</version>
|
<version>1.18.46</version>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -41,20 +41,20 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mybatis.spring.boot</groupId>
|
<groupId>org.mybatis.spring.boot</groupId>
|
||||||
<artifactId>mybatis-spring-boot-starter-test</artifactId>
|
<artifactId>mybatis-spring-boot-starter-test</artifactId>
|
||||||
<version>3.0.3</version>
|
<version>4.1.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.15.3</version>
|
<version>1.23.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-all</artifactId>
|
<artifactId>hutool-all</artifactId>
|
||||||
<version>5.8.26</version>
|
<version>5.8.47</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -63,15 +63,8 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||||
<artifactId>httpclient</artifactId>
|
<artifactId>httpclient5</artifactId>
|
||||||
<version>4.5.14</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
|
||||||
<artifactId>httpmime</artifactId>
|
|
||||||
<version>4.5.14</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -82,13 +75,13 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.commons</groupId>
|
<groupId>org.apache.commons</groupId>
|
||||||
<artifactId>commons-compress</artifactId>
|
<artifactId>commons-compress</artifactId>
|
||||||
<version>1.26.1</version>
|
<version>1.28.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>commons-io</groupId>
|
<groupId>commons-io</groupId>
|
||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
<version>2.15.1</version>
|
<version>2.22.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -99,7 +92,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.pengrad</groupId>
|
<groupId>com.github.pengrad</groupId>
|
||||||
<artifactId>java-telegram-bot-api</artifactId>
|
<artifactId>java-telegram-bot-api</artifactId>
|
||||||
<version>7.9.1</version>
|
<version>10.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -118,15 +111,35 @@
|
|||||||
<path>
|
<path>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.40</version>
|
<version>1.18.46</version>
|
||||||
</path>
|
</path>
|
||||||
</annotationProcessorPaths>
|
</annotationProcessorPaths>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>0.8.13</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>prepare-agent</id>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>report</id>
|
||||||
|
<phase>test</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>report</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.graalvm.buildtools</groupId>
|
<groupId>org.graalvm.buildtools</groupId>
|
||||||
<artifactId>native-maven-plugin</artifactId>
|
<artifactId>native-maven-plugin</artifactId>
|
||||||
<version>0.10.3</version>
|
<version>1.1.8</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<imageName>lionwebsite</imageName>
|
<imageName>lionwebsite</imageName>
|
||||||
<buildArgs>
|
<buildArgs>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.lion.lionwebsite.Controller;
|
|||||||
import com.lion.lionwebsite.Service.LocalService;
|
import com.lion.lionwebsite.Service.LocalService;
|
||||||
import com.lion.lionwebsite.Service.PersonalService;
|
import com.lion.lionwebsite.Service.PersonalService;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -88,7 +87,7 @@ public class PersonalController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/ip")
|
@GetMapping("/ip")
|
||||||
public String ip() throws JsonProcessingException {
|
public String ip() {
|
||||||
return personalService.getIp();
|
return personalService.getIp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import org.mybatis.spring.annotation.MapperScan;
|
|||||||
import org.mybatis.spring.annotation.MapperScans;
|
import org.mybatis.spring.annotation.MapperScans;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
import org.springframework.boot.web.server.servlet.context.ServletComponentScan;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.lion.lionwebsite.Message;
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import io.netty.buffer.ByteBuf;
|
import io.netty.buffer.ByteBuf;
|
||||||
import io.netty.channel.ChannelHandlerContext;
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
import io.netty.handler.codec.ByteToMessageCodec;
|
import io.netty.handler.codec.ByteToMessageCodec;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import com.lion.lionwebsite.Util.ImageFileCache;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -60,14 +60,20 @@ public class GalleryManageService {
|
|||||||
User user = userMapper.selectUserByAuthCode(AuthCode);
|
User user = userMapper.selectUserByAuthCode(AuthCode);
|
||||||
// return Response._failure("调试中,请勿提交任务");
|
// return Response._failure("调试中,请勿提交任务");
|
||||||
|
|
||||||
int gid;
|
if (user == null) {
|
||||||
try {
|
response.failure("授权码无效");
|
||||||
gid = Integer.parseInt(link.split("/")[4]);
|
return response.toJSONString();
|
||||||
} catch (NumberFormatException e) {
|
}
|
||||||
|
|
||||||
|
// 段数不足会先抛 ArrayIndexOutOfBoundsException,非数字段抛 NumberFormatException;
|
||||||
|
// 只捕后者会让畸形链接穿透为 500(本项目无 @ControllerAdvice)。
|
||||||
|
Integer parsedGid = parseGidFromLink(link);
|
||||||
|
if (parsedGid == null) {
|
||||||
response.failure("链接错误");
|
response.failure("链接错误");
|
||||||
pushService.taskCreateReport(user.getUsername(), "未知任务", response);
|
pushService.taskCreateReport(user.getUsername(), "未知任务", response);
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
int gid = parsedGid;
|
||||||
String taskName = "任务 [" + gid + "]";
|
String taskName = "任务 [" + gid + "]";
|
||||||
|
|
||||||
if (remoteService.isDead()) {
|
if (remoteService.isDead()) {
|
||||||
@@ -126,6 +132,23 @@ public class GalleryManageService {
|
|||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从任务链接中安全提取 gid。任何畸形输入(null、段数不足、非数字)都返回 null,
|
||||||
|
* 由调用方转成业务失败,避免异常穿透为 500。
|
||||||
|
*/
|
||||||
|
static Integer parseGidFromLink(String link) {
|
||||||
|
if (link == null)
|
||||||
|
return null;
|
||||||
|
String[] segments = link.split("/");
|
||||||
|
if (segments.length <= 4)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(segments[4]);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试重新连接
|
* 尝试重新连接
|
||||||
* @return 重连结果
|
* @return 重连结果
|
||||||
@@ -284,21 +307,35 @@ public class GalleryManageService {
|
|||||||
response.failure("删除失败,该图片不存在");
|
response.failure("删除失败,该图片不存在");
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
if (user == null) {
|
||||||
|
response.failure("删除失败,授权码无效");
|
||||||
|
return response.toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
|
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
|
||||||
if (!(collector.isEmpty() || collector.size() == 1 && collector.getFirst().equals(user.getId()) //判断收藏
|
// 拒绝条件与提示文案一致:被别人收藏,或者请求者不是下载人。
|
||||||
&& gallery.getDownloader() == user.getId())) //判断下载
|
// 注意不能写成 collector.isEmpty() || ...:那样在「无任何收藏」时会短路放行,
|
||||||
|
// 从而完全跳过下载者校验,导致任何有效授权码都能删除他人任务。
|
||||||
|
boolean collectedByOthers = collector.stream().anyMatch(id -> id != user.getId());
|
||||||
|
boolean isDownloader = gallery.getDownloader() == user.getId();
|
||||||
|
if (collectedByOthers || !isDownloader) {
|
||||||
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
|
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
|
||||||
else {
|
log.info("拒绝删除 gid={}:collectedByOthers={} isDownloader={}", gid, collectedByOthers, isDownloader);
|
||||||
log.info("删除图片{}", gallery.getName());
|
return response.toJSONString();
|
||||||
galleryMapper.deleteGalleryByGid(gallery.getGid()); //删除图片记录
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 通过授权后才落库并通知节点,避免被拒请求仍删除节点文件。
|
||||||
|
log.info("删除图片{}", gallery.getName());
|
||||||
|
galleryMapper.deleteGalleryByGid(gallery.getGid());
|
||||||
switch (remoteService.deleteGallery(gallery)) {
|
switch (remoteService.deleteGallery(gallery)) {
|
||||||
case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
|
case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
|
||||||
case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
|
case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
|
||||||
case 0 -> response.success();
|
case 0 -> response.success();
|
||||||
|
// 节点无响应/超时会返回 -1 等非枚举值;必须显式判失败,
|
||||||
|
// 否则 result 保持未设置,末尾的 response.get("result") 会抛 NPE。
|
||||||
|
default -> response.failure("图片:" + gallery.getName() + "删除失败,节点无响应");
|
||||||
}
|
}
|
||||||
if (response.get("result").equals("failure"))
|
if (!response.isSuccess())
|
||||||
log.info(response.getData());
|
log.info(response.getData());
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
@@ -313,8 +350,20 @@ public class GalleryManageService {
|
|||||||
CustomConfiguration lastResetAmountTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME);
|
CustomConfiguration lastResetAmountTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME);
|
||||||
Map<String, String> data = new HashMap<>();
|
Map<String, String> data = new HashMap<>();
|
||||||
|
|
||||||
data.put("weekUsedAmount", CustomUtil.fileSizeToString(Long.parseLong(weekUsedAmount.getValue())));
|
// 配置行缺失时给出默认值,避免 NPE 让用量接口整体不可用。
|
||||||
data.put("lastResetAmountTime", lastResetAmountTime.getValue());
|
String usedValue = weekUsedAmount == null || weekUsedAmount.getValue() == null
|
||||||
|
? "0" : weekUsedAmount.getValue();
|
||||||
|
long used;
|
||||||
|
try {
|
||||||
|
used = Long.parseLong(usedValue);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("每周用量配置值非法,按 0 处理: {}", usedValue);
|
||||||
|
used = 0L;
|
||||||
|
}
|
||||||
|
data.put("weekUsedAmount", CustomUtil.fileSizeToString(used));
|
||||||
|
data.put("lastResetAmountTime",
|
||||||
|
lastResetAmountTime == null || lastResetAmountTime.getValue() == null
|
||||||
|
? "" : lastResetAmountTime.getValue());
|
||||||
|
|
||||||
response.success(new ObjectMapper().valueToTree(data).toString());
|
response.success(new ObjectMapper().valueToTree(data).toString());
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import com.lion.lionwebsite.Util.CustomUtil;
|
|||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.http.HttpEntity;
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
import org.apache.http.client.methods.HttpGet;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
import org.apache.http.impl.client.CloseableHttpClient;
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
import org.apache.http.impl.client.HttpClients;
|
import org.apache.hc.core5.http.HttpEntity;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -228,7 +228,7 @@ public class LocalService{
|
|||||||
httpResponse = httpClient.execute(httpGet);
|
httpResponse = httpClient.execute(httpGet);
|
||||||
|
|
||||||
HttpEntity responseEntity = httpResponse.getEntity();
|
HttpEntity responseEntity = httpResponse.getEntity();
|
||||||
int statusCode = httpResponse.getStatusLine().getStatusCode();
|
int statusCode = httpResponse.getCode();
|
||||||
ArrayList<String> temp = new ArrayList<>();
|
ArrayList<String> temp = new ArrayList<>();
|
||||||
|
|
||||||
if (statusCode == 200) {
|
if (statusCode == 200) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
|||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.FileDownload;
|
import com.lion.lionwebsite.Util.FileDownload;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import com.lion.lionwebsite.Util.GalleryUtil;
|
|||||||
import com.lion.lionwebsite.Util.ImageFileCache;
|
import com.lion.lionwebsite.Util.ImageFileCache;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.ServletOutputStream;
|
import jakarta.servlet.ServletOutputStream;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import com.lion.lionwebsite.Dao.normal.SubMapper;
|
|||||||
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.http.HttpEntity;
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
import org.apache.http.client.config.RequestConfig;
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
import org.apache.http.client.methods.HttpGet;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
import org.apache.http.impl.client.CloseableHttpClient;
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
import org.apache.http.impl.client.HttpClients;
|
import org.apache.hc.core5.http.HttpEntity;
|
||||||
|
import org.apache.hc.core5.util.Timeout;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -27,8 +28,11 @@ import java.util.concurrent.locks.Lock;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SubscriptionRefreshService {
|
public class SubscriptionRefreshService {
|
||||||
private static final CloseableHttpClient HTTP_CLIENT = HttpClients.custom()
|
private static final CloseableHttpClient HTTP_CLIENT = HttpClients.custom()
|
||||||
.setDefaultRequestConfig(RequestConfig.custom().setConnectTimeout(5_000)
|
.setDefaultRequestConfig(RequestConfig.custom()
|
||||||
.setConnectionRequestTimeout(5_000).setSocketTimeout(15_000).build())
|
.setConnectTimeout(Timeout.ofMilliseconds(5_000))
|
||||||
|
.setConnectionRequestTimeout(Timeout.ofMilliseconds(5_000))
|
||||||
|
// HttpClient 5 将 socket 读超时改名为 responseTimeout。
|
||||||
|
.setResponseTimeout(Timeout.ofMilliseconds(15_000)).build())
|
||||||
.build();
|
.build();
|
||||||
private static final Pattern MULTIPLIER = Pattern.compile("(\\d+(?:\\.\\d+)?)x\\s*$", Pattern.CASE_INSENSITIVE);
|
private static final Pattern MULTIPLIER = Pattern.compile("(\\d+(?:\\.\\d+)?)x\\s*$", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
@@ -218,8 +222,8 @@ public class SubscriptionRefreshService {
|
|||||||
List<String> download(String url) throws IOException {
|
List<String> download(String url) throws IOException {
|
||||||
HttpGet get = new HttpGet(url);
|
HttpGet get = new HttpGet(url);
|
||||||
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
|
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
|
||||||
if (response.getStatusLine().getStatusCode() != 200)
|
if (response.getCode() != 200)
|
||||||
throw new IOException("上游 HTTP 状态码 " + response.getStatusLine().getStatusCode());
|
throw new IOException("上游 HTTP 状态码 " + response.getCode());
|
||||||
HttpEntity entity = response.getEntity();
|
HttpEntity entity = response.getEntity();
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
throw new IOException("上游返回为空");
|
throw new IOException("上游返回为空");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
import com.lion.lionwebsite.Domain.SubBind;
|
import com.lion.lionwebsite.Domain.SubBind;
|
||||||
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import com.lion.lionwebsite.Domain.User;
|
|||||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import tools.jackson.databind.node.ObjectNode;
|
||||||
import com.lion.lionwebsite.Domain.GalleryTask;
|
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.lion.lionwebsite.Util;
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
package com.lion.lionwebsite.Util;
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import com.lion.lionwebsite.Domain.Gallery;
|
import com.lion.lionwebsite.Domain.Gallery;
|
||||||
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
||||||
import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
||||||
import org.apache.http.HttpEntity;
|
|
||||||
import org.apache.http.client.config.RequestConfig;
|
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import org.apache.http.client.entity.EntityBuilder;
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||||
import org.apache.http.client.methods.HttpGet;
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
import org.apache.http.client.methods.HttpPost;
|
import org.apache.hc.client5.http.entity.EntityBuilder;
|
||||||
import org.apache.http.entity.ContentType;
|
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
|
||||||
import org.apache.http.entity.mime.MultipartEntityBuilder;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
import org.apache.http.impl.client.CloseableHttpClient;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
import org.apache.http.impl.client.HttpClients;
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.core5.http.ContentType;
|
||||||
|
import org.apache.hc.core5.http.HttpEntity;
|
||||||
|
import org.apache.hc.core5.util.Timeout;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
import org.jsoup.nodes.Document;
|
import org.jsoup.nodes.Document;
|
||||||
import org.jsoup.nodes.Element;
|
import org.jsoup.nodes.Element;
|
||||||
@@ -48,8 +49,11 @@ public class GalleryUtil {
|
|||||||
|
|
||||||
/** Reusable HTTP client —不要每次请求新建 */
|
/** Reusable HTTP client —不要每次请求新建 */
|
||||||
private static final CloseableHttpClient httpClient = HttpClients.custom()
|
private static final CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
.setDefaultRequestConfig(RequestConfig.custom().setConnectTimeout(5_000)
|
.setDefaultRequestConfig(RequestConfig.custom()
|
||||||
.setConnectionRequestTimeout(5_000).setSocketTimeout(15_000).build()).build();
|
.setConnectTimeout(Timeout.ofMilliseconds(5_000))
|
||||||
|
.setConnectionRequestTimeout(Timeout.ofMilliseconds(5_000))
|
||||||
|
// HttpClient 5 将 socket 读超时改名为 responseTimeout。
|
||||||
|
.setResponseTimeout(Timeout.ofMilliseconds(15_000)).build()).build();
|
||||||
|
|
||||||
/** E-Hentai Cookie, injected from application.yaml via CustomBean */
|
/** E-Hentai Cookie, injected from application.yaml via CustomBean */
|
||||||
private static String ehentaiCookie = "";
|
private static String ehentaiCookie = "";
|
||||||
@@ -348,7 +352,7 @@ public class GalleryUtil {
|
|||||||
}
|
}
|
||||||
try (httpResponse) {
|
try (httpResponse) {
|
||||||
HttpEntity responseEntity = httpResponse.getEntity();
|
HttpEntity responseEntity = httpResponse.getEntity();
|
||||||
int statusCode = httpResponse.getStatusLine().getStatusCode();
|
int statusCode = httpResponse.getCode();
|
||||||
StringBuilder stringBuilder = new StringBuilder();
|
StringBuilder stringBuilder = new StringBuilder();
|
||||||
if (statusCode == 200 && responseEntity != null) {
|
if (statusCode == 200 && responseEntity != null) {
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.lion.lionwebsite.Util;
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import tools.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
|
||||||
public class Response {
|
public class Response {
|
||||||
@@ -19,7 +19,8 @@ public class Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String get(String key){
|
public String get(String key){
|
||||||
return result.get(key).asText();
|
JsonNode node = result.get(key);
|
||||||
|
return node == null ? null : node.asText();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setData(String data){
|
public void setData(String data){
|
||||||
@@ -57,7 +58,8 @@ public class Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String getData(){
|
public String getData(){
|
||||||
return result.get("data").asText();
|
JsonNode node = result.get("data");
|
||||||
|
return node == null ? null : node.asText();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,8 +71,10 @@ public class Response {
|
|||||||
return getData();
|
return getData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未设置 result 键时视为失败,而不是抛 NPE。 */
|
||||||
public boolean isSuccess(){
|
public boolean isSuccess(){
|
||||||
return result.get("result").asText().equals("success");
|
JsonNode node = result.get("result");
|
||||||
|
return node != null && "success".equals(node.asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.lion.lionwebsite.Filter;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主站入口的 UA 分流过滤器:把移动端访客导向 /mobile,其余放行。
|
||||||
|
* 它挂在 "/" 与 "/personal/" 上,判定错误会让桌面端用户被错误重定向,故两侧都要锁住。
|
||||||
|
*/
|
||||||
|
class AdaptorFilterTest {
|
||||||
|
|
||||||
|
private final AdaptorFilter filter = new AdaptorFilter();
|
||||||
|
|
||||||
|
private record Result(boolean chainCalled, String redirectedTo) {}
|
||||||
|
|
||||||
|
private Result run(String userAgent, String servletPath, String authCode) throws Exception {
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||||
|
FilterChain chain = mock(FilterChain.class);
|
||||||
|
|
||||||
|
when(request.getHeader("User-Agent")).thenReturn(userAgent);
|
||||||
|
when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.9");
|
||||||
|
when(request.getParameter("AuthCode")).thenReturn(authCode);
|
||||||
|
when(request.getServletPath()).thenReturn(servletPath);
|
||||||
|
|
||||||
|
filter.doFilter(request, response, chain);
|
||||||
|
|
||||||
|
String redirect = null;
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(response, atMost(1)).sendRedirect(captor.capture());
|
||||||
|
if (!captor.getAllValues().isEmpty()) redirect = captor.getValue();
|
||||||
|
return new Result(org.mockito.Mockito.mockingDetails(chain).getInvocations().size() > 0, redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void desktopUserAgentPassesThrough() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0", "/", null);
|
||||||
|
assertTrue(r.chainCalled(), "桌面 UA 必须放行到后续处理");
|
||||||
|
assertNull(r.redirectedTo(), "桌面 UA 不应被重定向");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void androidUserAgentIsRedirectedToMobile() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (Linux; Android 13) Chrome/120.0", "/", null);
|
||||||
|
assertFalse(r.chainCalled(), "移动 UA 不应继续走桌面链路");
|
||||||
|
assertEquals("/mobile", r.redirectedTo());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iPhoneUserAgentIsRedirectedToMobile() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari/604.1", "/", null);
|
||||||
|
assertEquals("/mobile", r.redirectedTo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带 AuthCode=alone 的个人页访问应把授权码透传到移动端,否则移动端要重新输入。 */
|
||||||
|
@Test
|
||||||
|
void personalPageOnMobilePreservesAloneAuthCode() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (Linux; Android 13)", "/personal/", "alone");
|
||||||
|
assertEquals("/mobile?AuthCode=alone", r.redirectedTo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 个人页 + 移动端 + 非 alone 的授权码:不透传,仅跳转基础路径。 */
|
||||||
|
@Test
|
||||||
|
void personalPageOnMobileWithOtherAuthCodeDoesNotLeakIt() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (Linux; Android 13)", "/personal/", "secret-code");
|
||||||
|
assertEquals("/mobile", r.redirectedTo());
|
||||||
|
assertFalse(r.redirectedTo().contains("secret-code"), "非 alone 的授权码不得出现在跳转 URL 中");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 桌面 UA 访问个人页时不得因 AuthCode=alone 被误跳转到移动端。 */
|
||||||
|
@Test
|
||||||
|
void desktopPersonalPageWithAloneIsNotRedirected() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "/personal/", "alone");
|
||||||
|
assertTrue(r.chainCalled());
|
||||||
|
assertNull(r.redirectedTo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /validate 是验证入口,必须放行,且移动 UA 也不应被重定向。 */
|
||||||
|
@Test
|
||||||
|
void validatePathAlwaysPassesThrough() throws Exception {
|
||||||
|
Result desktop = run("Mozilla/5.0 (Windows NT 10.0)", "/validate", null);
|
||||||
|
assertTrue(desktop.chainCalled());
|
||||||
|
assertNull(desktop.redirectedTo());
|
||||||
|
|
||||||
|
Result mobile = run("Mozilla/5.0 (Linux; Android 13)", "/validate", null);
|
||||||
|
assertTrue(mobile.chainCalled(), "/validate 在移动 UA 下也必须放行");
|
||||||
|
assertNull(mobile.redirectedTo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UA 缺失时直接返回(不重定向、不放行),避免无 UA 客户端进入业务链路。 */
|
||||||
|
@Test
|
||||||
|
void missingUserAgentNeitherRedirectsNorContinues() throws Exception {
|
||||||
|
Result r = run(null, "/", null);
|
||||||
|
assertFalse(r.chainCalled(), "无 UA 的请求不应继续");
|
||||||
|
assertNull(r.redirectedTo(), "无 UA 的请求也不应被重定向");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iphonePersonalPagePreservesAloneAuthCode() throws Exception {
|
||||||
|
Result r = run("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "/personal/", "alone");
|
||||||
|
assertEquals("/mobile?AuthCode=alone", r.redirectedTo());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.lion.lionwebsite.Interceptor;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务接口的鉴权拦截器。它决定谁能操作下载任务,是应用内唯一的授权判定点,
|
||||||
|
* 因此这里覆盖「放行」与「拒绝」两侧,并锁死若干必须拒绝的输入形态。
|
||||||
|
*/
|
||||||
|
class TaskHandlerInterceptorTest {
|
||||||
|
|
||||||
|
private UserMapper mapper;
|
||||||
|
private TaskHandlerInterceptor interceptor;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mapper = mock(UserMapper.class);
|
||||||
|
interceptor = new TaskHandlerInterceptor(mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以给定 AuthCodes 初始化,并针对某次请求参数返回放行与否。 */
|
||||||
|
private boolean handle(String[] codes, String requestAuthCode) {
|
||||||
|
when(mapper.selectAllAuthCode()).thenReturn(codes);
|
||||||
|
interceptor.init();
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
when(request.getParameter("AuthCode")).thenReturn(requestAuthCode);
|
||||||
|
return interceptor.preHandle(request, mock(HttpServletResponse.class), new Object());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validAuthCodeIsAllowed() {
|
||||||
|
assertTrue(handle(new String[]{"aaaa-bbbb", "cccc-dddd"}, "cccc-dddd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstConfiguredAuthCodeIsAllowed() {
|
||||||
|
assertTrue(handle(new String[]{"first", "second"}, "first"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownAuthCodeIsRejected() {
|
||||||
|
assertFalse(handle(new String[]{"aaaa-bbbb"}, "not-a-real-code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingAuthCodeIsRejected() {
|
||||||
|
assertFalse(handle(new String[]{"aaaa-bbbb"}, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyAuthCodeIsRejected() {
|
||||||
|
assertFalse(handle(new String[]{"aaaa-bbbb"}, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankLookalikeIsRejected() {
|
||||||
|
assertFalse(handle(new String[]{"aaaa-bbbb"}, " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前缀/后缀匹配不得被当作通过,避免宽松比较导致的越权。 */
|
||||||
|
@Test
|
||||||
|
void prefixAndSuffixVariantsAreRejected() {
|
||||||
|
assertFalse(handle(new String[]{"secret-code"}, "secret"), "前缀不得放行");
|
||||||
|
assertFalse(handle(new String[]{"secret-code"}, "secret-code-extra"), "多余后缀不得放行");
|
||||||
|
assertFalse(handle(new String[]{"secret-code"}, "SECRET-CODE"), "大小写不同不得放行");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未配置任何 AuthCode 时,除 null 外的输入都必须拒绝。 */
|
||||||
|
@Test
|
||||||
|
void noConfiguredCodesRejectsEverything() {
|
||||||
|
assertFalse(handle(new String[]{}, "anything"));
|
||||||
|
assertFalse(handle(new String[]{}, ""));
|
||||||
|
assertFalse(handle(new String[]{}, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表中含 null 项时不得抛 NPE(历史数据可能产生 null AuthCode)。 */
|
||||||
|
@Test
|
||||||
|
void nullEntryInConfiguredCodesDoesNotThrow() {
|
||||||
|
assertFalse(handle(new String[]{"good", null}, "some-code"));
|
||||||
|
assertTrue(handle(new String[]{"good", null}, "good"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化后按数据库当前值判定,不缓存过期结果。 */
|
||||||
|
@Test
|
||||||
|
void initLoadsCodesFromMapper() {
|
||||||
|
when(mapper.selectAllAuthCode()).thenReturn(new String[]{"x"});
|
||||||
|
interceptor.init();
|
||||||
|
verify(mapper).selectAllAuthCode();
|
||||||
|
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
when(request.getParameter("AuthCode")).thenReturn("x");
|
||||||
|
assertTrue(interceptor.preHandle(request, mock(HttpServletResponse.class), new Object()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** updateAuthCodes 必须改用 selectEnableAuthCode,使被吊销的授权码立即失效。 */
|
||||||
|
@Test
|
||||||
|
void updateAuthCodesSwitchesToEnabledSet() {
|
||||||
|
when(mapper.selectAllAuthCode()).thenReturn(new String[]{"old-code"});
|
||||||
|
interceptor.init();
|
||||||
|
verify(mapper).selectAllAuthCode();
|
||||||
|
|
||||||
|
when(mapper.selectEnableAuthCode()).thenReturn(new String[]{"new-code"});
|
||||||
|
interceptor.updateAuthCodes();
|
||||||
|
verify(mapper).selectEnableAuthCode();
|
||||||
|
|
||||||
|
HttpServletRequest revoked = mock(HttpServletRequest.class);
|
||||||
|
when(revoked.getParameter("AuthCode")).thenReturn("old-code");
|
||||||
|
assertFalse(interceptor.preHandle(revoked, mock(HttpServletResponse.class), new Object()),
|
||||||
|
"刷新后旧的授权码必须失效");
|
||||||
|
|
||||||
|
HttpServletRequest current = mock(HttpServletRequest.class);
|
||||||
|
when(current.getParameter("AuthCode")).thenReturn("new-code");
|
||||||
|
assertTrue(interceptor.preHandle(current, mock(HttpServletResponse.class), new Object()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点通信的线协议编解码。该格式是主站与 storageNode 的契约:
|
||||||
|
* 帧 = [messageType(1B)][bodyLength(4B, 大端)][JSON body],
|
||||||
|
* 两侧各自使用 Jackson(主站 Jackson 3、节点 Jackson 2.x),故这里同时锁死编码结果与解码容错。
|
||||||
|
*/
|
||||||
|
class MessageCodecTest {
|
||||||
|
|
||||||
|
private EmbeddedChannel channel;
|
||||||
|
|
||||||
|
private EmbeddedChannel channel() {
|
||||||
|
channel = new EmbeddedChannel(new MessageCodec());
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (channel != null) channel.finishAndReleaseAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编码一条消息并取回其帧。 */
|
||||||
|
private static ByteBuf encode(EmbeddedChannel ch, AbstractMessage message) {
|
||||||
|
assertTrue(ch.writeOutbound(message), "消息应被编码并写入出站缓冲");
|
||||||
|
ByteBuf frame = ch.readOutbound();
|
||||||
|
assertNotNull(frame, "应能取出编好的帧");
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解码一个帧并返回产出的消息(无产出时返回 null)。 */
|
||||||
|
private static <T> T decode(EmbeddedChannel ch, ByteBuf frame) {
|
||||||
|
assertTrue(ch.writeInbound(frame), "帧应被解码器消费");
|
||||||
|
return ch.readInbound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读地取出帧头声明长度与正文,不移动读指针。 */
|
||||||
|
private static String frameBody(ByteBuf frame) {
|
||||||
|
int length = frame.getInt(1);
|
||||||
|
byte[] body = new byte[length];
|
||||||
|
frame.getBytes(5, body);
|
||||||
|
return new String(body, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帧头必须是 1 字节类型 + 4 字节长度,且长度等于正文实际字节数。 */
|
||||||
|
private static void assertWellFormedFrame(ByteBuf frame, byte expectedType, String expectedJsonFragment) {
|
||||||
|
assertEquals(expectedType, frame.getByte(0), "messageType 应为帧首字节");
|
||||||
|
String json = frameBody(frame);
|
||||||
|
assertEquals(frame.getInt(1), json.getBytes(StandardCharsets.UTF_8).length,
|
||||||
|
"帧头声明长度须等于正文实际字节数");
|
||||||
|
assertTrue(json.contains(expectedJsonFragment),
|
||||||
|
"正文应包含 " + expectedJsonFragment + ",实际为 " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadPostRoundTripsTaskAndKeepsFieldValues() {
|
||||||
|
GalleryTask task = new GalleryTask();
|
||||||
|
task.setGid(123456);
|
||||||
|
task.setName("sample gallery");
|
||||||
|
task.setStatus(GalleryTask.DOWNLOADING);
|
||||||
|
task.setProceeding(7);
|
||||||
|
task.setPath("/secret/path");
|
||||||
|
|
||||||
|
DownloadPostMessage out = new DownloadPostMessage();
|
||||||
|
out.setMessageId(42);
|
||||||
|
out.setGalleryTask(task);
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
ByteBuf frame = encode(ch, out);
|
||||||
|
assertWellFormedFrame(frame, AbstractMessage.DOWNLOAD_POST_MESSAGE, "123456");
|
||||||
|
assertFalse(frameBody(frame).contains("/secret/path"),
|
||||||
|
"path 标注了 @JsonIgnore,不应出现在帧内(会泄漏存储机本地路径)");
|
||||||
|
|
||||||
|
DownloadPostMessage in = decode(ch, frame);
|
||||||
|
assertNotNull(in);
|
||||||
|
assertEquals(42, in.getMessageId(), "messageId 必须原样保留,否则响应无法对号");
|
||||||
|
assertNotNull(in.getGalleryTask());
|
||||||
|
assertEquals(123456, in.getGalleryTask().getGid());
|
||||||
|
assertEquals("sample gallery", in.getGalleryTask().getName());
|
||||||
|
assertEquals(GalleryTask.DOWNLOADING, in.getGalleryTask().getStatus());
|
||||||
|
assertEquals(7, in.getGalleryTask().getProceeding());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadStatusRoundTripsArrayPreservingOrder() {
|
||||||
|
GalleryTask first = new GalleryTask();
|
||||||
|
first.setGid(1);
|
||||||
|
first.setName("a");
|
||||||
|
first.setStatus(GalleryTask.COMPRESS_COMPLETE);
|
||||||
|
GalleryTask second = new GalleryTask();
|
||||||
|
second.setGid(2);
|
||||||
|
second.setName("b");
|
||||||
|
second.setStatus(GalleryTask.COMPRESSING);
|
||||||
|
|
||||||
|
DownloadStatusMessage out = new DownloadStatusMessage();
|
||||||
|
out.setMessageId(7);
|
||||||
|
out.setGalleryTasks(new GalleryTask[]{first, second});
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
DownloadStatusMessage in = decode(ch, encode(ch, out));
|
||||||
|
|
||||||
|
assertNotNull(in);
|
||||||
|
assertEquals(2, in.getGalleryTasks().length);
|
||||||
|
assertEquals(1, in.getGalleryTasks()[0].getGid(), "数组顺序必须保持");
|
||||||
|
assertEquals(GalleryTask.COMPRESS_COMPLETE, in.getGalleryTasks()[0].getStatus());
|
||||||
|
assertEquals(GalleryTask.COMPRESSING, in.getGalleryTasks()[1].getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void responseMessageRoundTripsResultCode() {
|
||||||
|
ResponseMessage out = new ResponseMessage();
|
||||||
|
out.setMessageId(99);
|
||||||
|
out.setResult((byte) 3);
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
ResponseMessage in = decode(ch, encode(ch, out));
|
||||||
|
|
||||||
|
assertNotNull(in);
|
||||||
|
assertEquals(99, in.getMessageId());
|
||||||
|
assertEquals(3, in.getResult(), "result 码承载节点语义,不能丢");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityDeleteAndAvailableCheckRoundTrip() {
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
|
||||||
|
IdentityMessage identityOut = new IdentityMessage("lionwebsite");
|
||||||
|
identityOut.setMessageId(1);
|
||||||
|
IdentityMessage identity = decode(ch, encode(ch, identityOut));
|
||||||
|
assertNotNull(identity);
|
||||||
|
assertEquals("lionwebsite", identity.getIdentity(), "身份串决定节点是否登记为 server");
|
||||||
|
|
||||||
|
DeleteGalleryMessage deleteOut = new DeleteGalleryMessage();
|
||||||
|
deleteOut.setMessageId(5);
|
||||||
|
deleteOut.setGalleryName("gallery-name");
|
||||||
|
DeleteGalleryMessage delete = decode(ch, encode(ch, deleteOut));
|
||||||
|
assertNotNull(delete);
|
||||||
|
assertEquals("gallery-name", delete.getGalleryName());
|
||||||
|
|
||||||
|
AvailableCheckMessage checkOut = new AvailableCheckMessage();
|
||||||
|
checkOut.setMessageId(8);
|
||||||
|
assertNotNull(decode(ch, encode(ch, checkOut)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void maintainMessageEncodesWithItsOwnType() {
|
||||||
|
MaintainMessage out = new MaintainMessage();
|
||||||
|
out.setMessageId(11);
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
ByteBuf frame = encode(ch, out);
|
||||||
|
assertEquals(AbstractMessage.MAINTAIN_MESSAGE, frame.getByte(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void subscriptionSnapshotRoundTripsAllTopLevelFields() {
|
||||||
|
SubscriptionSnapshotMessage out = new SubscriptionSnapshotMessage();
|
||||||
|
out.setMessageId(77);
|
||||||
|
out.setSchemaVersion(1);
|
||||||
|
out.setRevision("rev-abc");
|
||||||
|
out.setGeneratedAt(1789364669466L);
|
||||||
|
out.setPayloadBase64("cGF5bG9hZA==");
|
||||||
|
out.setPayloadSha256("payload-hash");
|
||||||
|
out.setSignature("sig");
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
SubscriptionSnapshotMessage in = decode(ch, encode(ch, out));
|
||||||
|
|
||||||
|
assertNotNull(in);
|
||||||
|
assertEquals(77, in.getMessageId());
|
||||||
|
assertEquals(1, in.getSchemaVersion());
|
||||||
|
assertEquals("rev-abc", in.getRevision());
|
||||||
|
assertEquals(1789364669466L, in.getGeneratedAt());
|
||||||
|
assertEquals("cGF5bG9hZA==", in.getPayloadBase64());
|
||||||
|
assertEquals("payload-hash", in.getPayloadSha256());
|
||||||
|
assertEquals("sig", in.getSignature());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload 内嵌对象的往返:账号/绑定快照字段必须逐个保真,否则备机分发会串账号。 */
|
||||||
|
@Test
|
||||||
|
void snapshotPayloadSurvivesNestedJsonRoundTrip() throws Exception {
|
||||||
|
tools.jackson.databind.ObjectMapper mapper = new tools.jackson.databind.ObjectMapper();
|
||||||
|
|
||||||
|
SubscriptionAccountSnapshot account = new SubscriptionAccountSnapshot();
|
||||||
|
account.setAccountId(12);
|
||||||
|
account.setEnabled(false);
|
||||||
|
account.setFilterHighMultiplier(true);
|
||||||
|
account.setV2ContentBase64("YWJj");
|
||||||
|
account.setClashContentBase64("ZGVm");
|
||||||
|
account.setV2Sha256("h-v2");
|
||||||
|
account.setClashSha256("h-clash");
|
||||||
|
|
||||||
|
SubscriptionBindingSnapshot binding = new SubscriptionBindingSnapshot();
|
||||||
|
binding.setPublicKeySha256("pub");
|
||||||
|
binding.setAccountId(12);
|
||||||
|
|
||||||
|
SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload();
|
||||||
|
payload.setSchemaVersion(2);
|
||||||
|
payload.setAccounts(new java.util.ArrayList<>(java.util.List.of(account)));
|
||||||
|
payload.setBindings(new java.util.ArrayList<>(java.util.List.of(binding)));
|
||||||
|
|
||||||
|
SubscriptionSnapshotPayload back =
|
||||||
|
mapper.readValue(mapper.writeValueAsString(payload), SubscriptionSnapshotPayload.class);
|
||||||
|
|
||||||
|
assertEquals(2, back.getSchemaVersion());
|
||||||
|
assertEquals(1, back.getAccounts().size());
|
||||||
|
assertEquals(1, back.getBindings().size());
|
||||||
|
SubscriptionAccountSnapshot a = back.getAccounts().get(0);
|
||||||
|
assertEquals(12, a.getAccountId());
|
||||||
|
assertFalse(a.isEnabled());
|
||||||
|
assertTrue(a.isFilterHighMultiplier());
|
||||||
|
assertEquals("YWJj", a.getV2ContentBase64());
|
||||||
|
assertEquals("ZGVm", a.getClashContentBase64());
|
||||||
|
assertEquals("h-clash", a.getClashSha256());
|
||||||
|
assertEquals(12, back.getBindings().get(0).getAccountId());
|
||||||
|
assertEquals("pub", back.getBindings().get(0).getPublicKeySha256());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未显式设置的列表字段必须是空列表而非 null,否则节点侧遍历会 NPE。 */
|
||||||
|
@Test
|
||||||
|
void unsetPayloadListsDefaultToEmptyNotNul() {
|
||||||
|
SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload();
|
||||||
|
assertNotNull(payload.getAccounts());
|
||||||
|
assertNotNull(payload.getBindings());
|
||||||
|
assertTrue(payload.getAccounts().isEmpty());
|
||||||
|
assertTrue(payload.getBindings().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未知消息类型必须被静默丢弃,否则单条坏帧会打断整条节点连接。 */
|
||||||
|
@Test
|
||||||
|
void unknownMessageTypeIsDroppedWithoutThrowing() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel(new MessageCodec());
|
||||||
|
try {
|
||||||
|
ByteBuf buf = ch.alloc().buffer();
|
||||||
|
buf.writeByte((byte) 120);
|
||||||
|
byte[] body = "{}".getBytes(StandardCharsets.UTF_8);
|
||||||
|
buf.writeInt(body.length);
|
||||||
|
buf.writeBytes(body);
|
||||||
|
|
||||||
|
ch.writeInbound(buf);
|
||||||
|
assertNull(ch.readInbound(), "未知类型不应产出消息");
|
||||||
|
assertTrue(ch.isActive(), "未知类型不应导致通道关闭");
|
||||||
|
} finally {
|
||||||
|
ch.finishAndReleaseAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** name 为 null(@JsonInclude(NON_NULL))时仍应正常往返,不得破坏其他字段。 */
|
||||||
|
@Test
|
||||||
|
void nullOptionalFieldsDoNotBreakRoundTrip() {
|
||||||
|
GalleryTask task = new GalleryTask();
|
||||||
|
task.setGid(1);
|
||||||
|
task.setName(null);
|
||||||
|
task.setStatus(GalleryTask.DOWNLOAD_COMPLETE);
|
||||||
|
|
||||||
|
DownloadPostMessage out = new DownloadPostMessage();
|
||||||
|
out.setMessageId(1);
|
||||||
|
out.setGalleryTask(task);
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
DownloadPostMessage in = decode(ch, encode(ch, out));
|
||||||
|
assertNotNull(in);
|
||||||
|
assertNotNull(in.getGalleryTask());
|
||||||
|
assertNull(in.getGalleryTask().getName());
|
||||||
|
assertEquals(GalleryTask.DOWNLOAD_COMPLETE, in.getGalleryTask().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多字节 UTF-8(中文画廊名)长度须按字节而非字符计算,否则接收端会截断正文。 */
|
||||||
|
@Test
|
||||||
|
void multibyteNamesUseByteLengthNotCharLength() {
|
||||||
|
GalleryTask task = new GalleryTask();
|
||||||
|
task.setGid(9);
|
||||||
|
task.setName("中文画廊名");
|
||||||
|
task.setStatus(GalleryTask.DOWNLOADING);
|
||||||
|
|
||||||
|
DownloadPostMessage out = new DownloadPostMessage();
|
||||||
|
out.setMessageId(3);
|
||||||
|
out.setGalleryTask(task);
|
||||||
|
|
||||||
|
EmbeddedChannel ch = channel();
|
||||||
|
ByteBuf frame = encode(ch, out);
|
||||||
|
assertTrue(frameBody(frame).contains("中文画廊名"));
|
||||||
|
|
||||||
|
DownloadPostMessage in = decode(ch, frame);
|
||||||
|
assertEquals("中文画廊名", in.getGalleryTask().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在两个独立编解码器间往返,确保格式不依赖实例共享状态。 */
|
||||||
|
@Test
|
||||||
|
void frameEncodedByOneCodecDecodesInAnother() {
|
||||||
|
EmbeddedChannel encoder = new EmbeddedChannel(new MessageCodec());
|
||||||
|
EmbeddedChannel decoder = new EmbeddedChannel(new MessageCodec());
|
||||||
|
try {
|
||||||
|
IdentityMessage out = new IdentityMessage("lionwebsiteside");
|
||||||
|
out.setMessageId(4);
|
||||||
|
ByteBuf frame = encode(encoder, out);
|
||||||
|
IdentityMessage in = decode(decoder, frame);
|
||||||
|
assertNotNull(in);
|
||||||
|
assertEquals("lionwebsiteside", in.getIdentity());
|
||||||
|
assertEquals(4, in.getMessageId());
|
||||||
|
} finally {
|
||||||
|
encoder.finishAndReleaseAll();
|
||||||
|
decoder.finishAndReleaseAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.cache.ImageCacheMapper;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.*;
|
||||||
|
import com.lion.lionwebsite.Domain.Gallery;
|
||||||
|
import com.lion.lionwebsite.Domain.User;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务创建与状态查询的校验分支。
|
||||||
|
* 这些分支决定错误链接、节点离线、重复任务等情况下的用户可见结果与落库行为,
|
||||||
|
* 失败时不得留下脏数据,也不得误删既有任务。
|
||||||
|
*/
|
||||||
|
class GalleryManageServiceTest {
|
||||||
|
|
||||||
|
private GalleryMapper galleries;
|
||||||
|
private CollectMapper collectMapper;
|
||||||
|
private CustomConfigurationMapper configurationMapper;
|
||||||
|
private UserMapper users;
|
||||||
|
private RemoteService remote;
|
||||||
|
private PushService push;
|
||||||
|
private GalleryManageService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
galleries = mock(GalleryMapper.class);
|
||||||
|
collectMapper = mock(CollectMapper.class);
|
||||||
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
|
users = mock(UserMapper.class);
|
||||||
|
remote = mock(RemoteService.class);
|
||||||
|
push = mock(PushService.class);
|
||||||
|
service = new GalleryManageService(galleries, collectMapper,
|
||||||
|
configurationMapper, users, mock(ShareFileMapper.class),
|
||||||
|
mock(ImageCacheMapper.class), remote, push);
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(7);
|
||||||
|
user.setUsername("tester");
|
||||||
|
when(users.selectUserByAuthCode("code")).thenReturn(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- createTask 输入校验 ----------
|
||||||
|
|
||||||
|
/** 链接第 5 段非数字时应返回「链接错误」且不落库、不下发节点。 */
|
||||||
|
@Test
|
||||||
|
void malformedLinkIsRejectedWithoutPersisting() {
|
||||||
|
String response = service.createTask("https://example.org/g/not-a-number/key/", "original", "code");
|
||||||
|
|
||||||
|
assertTrue(response.contains("链接错误"), "应提示链接错误,实际: " + response);
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).insertGallery(any());
|
||||||
|
verify(remote, never()).addGalleryToQueue(any());
|
||||||
|
verify(push).taskCreateReport(eq("tester"), eq("未知任务"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归:修复前 `link.split("/")[4]` 在段数不足时抛 ArrayIndexOutOfBoundsException,
|
||||||
|
* 而只捕获 NumberFormatException,且项目无 @ControllerAdvice,会穿透为 500。
|
||||||
|
* 现在统一转成「链接错误」业务失败。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shortLinkIsRejectedGracefully() {
|
||||||
|
String response = assertDoesNotThrow(
|
||||||
|
() -> service.createTask("https://example.org/g", "original", "code"));
|
||||||
|
assertTrue(response.contains("链接错误"), "应友好提示链接错误: " + response);
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).insertGallery(any());
|
||||||
|
verify(remote, never()).addGalleryToQueue(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullLinkIsRejectedGracefully() {
|
||||||
|
String response = assertDoesNotThrow(() -> service.createTask(null, "original", "code"));
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).insertGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无效授权码应在解析链接之前就被拒,避免后续 user.getUsername() NPE。 */
|
||||||
|
@Test
|
||||||
|
void unknownAuthCodeIsRejectedBeforeParsing() {
|
||||||
|
User unknown = null;
|
||||||
|
when(users.selectUserByAuthCode("bogus")).thenReturn(unknown);
|
||||||
|
String response = assertDoesNotThrow(
|
||||||
|
() -> service.createTask("https://example.org/g/123/key/", "original", "bogus"));
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).insertGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** parseGidFromLink 的边界:合法/非法输入都应安全返回。 */
|
||||||
|
@Test
|
||||||
|
void parseGidFromLinkHandlesMalformedInput() {
|
||||||
|
assertEquals(123, GalleryManageService.parseGidFromLink("https://exhentai.org/g/123/key/"));
|
||||||
|
assertNull(GalleryManageService.parseGidFromLink(null));
|
||||||
|
assertNull(GalleryManageService.parseGidFromLink(""));
|
||||||
|
assertNull(GalleryManageService.parseGidFromLink("https://example.org/g"));
|
||||||
|
assertNull(GalleryManageService.parseGidFromLink("https://example.org/a/b/c"));
|
||||||
|
assertNull(GalleryManageService.parseGidFromLink("https://example.org/g/not-a-number/key/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点离线时必须明确告知用户,且不落库。 */
|
||||||
|
@Test
|
||||||
|
void taskIsRejectedWhenNodeIsOffline() {
|
||||||
|
when(remote.isDead()).thenReturn(true);
|
||||||
|
|
||||||
|
String response = service.createTask("https://example.org/g/555/key/", "original", "code");
|
||||||
|
|
||||||
|
assertTrue(response.contains("节点"), "应说明节点不可用,实际: " + response);
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).insertGallery(any());
|
||||||
|
verify(remote, never()).addGalleryToQueue(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 查询 ----------
|
||||||
|
|
||||||
|
/** 按链接查询:无对应任务时应返回失败而不是抛异常。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkReturnsFailureWhenAbsent() {
|
||||||
|
try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
|
||||||
|
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseGid(anyString())).thenReturn(999);
|
||||||
|
when(galleries.selectGalleryByGid(999)).thenReturn(null);
|
||||||
|
|
||||||
|
String response = service.selectTaskByLink("https://example.org/g/999/key/");
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkReturnsTaskWhenPresent() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(321);
|
||||||
|
gallery.setName("sample [321]");
|
||||||
|
when(galleries.selectGalleryByGid(321)).thenReturn(gallery);
|
||||||
|
|
||||||
|
String response = service.selectTaskByLink("https://example.org/g/321/key/");
|
||||||
|
assertTrue(response.contains("321"), "应回传对应任务: " + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 链接无法解析出 gid 时应安全失败。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkWithUnparsableLinkFailsSafely() {
|
||||||
|
try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
|
||||||
|
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseGid(anyString())).thenReturn(null);
|
||||||
|
String response = assertDoesNotThrow(() -> service.selectTaskByLink("garbage"));
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectTaskByGidReturnsFailureWhenAbsent() {
|
||||||
|
when(galleries.selectGalleryByGid(404)).thenReturn(null);
|
||||||
|
String response = service.selectTaskByGid(404);
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 删除与重试 ----------
|
||||||
|
|
||||||
|
/** 删除不存在的任务应返回失败,且不调用删除。 */
|
||||||
|
@Test
|
||||||
|
void deleteNonexistentTaskFailsWithoutDeleting() {
|
||||||
|
when(galleries.selectGalleryByGid(777)).thenReturn(null);
|
||||||
|
String response = service.deleteGalleryByGid(777, "code");
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).deleteGalleryByGid(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归:修复前当画廊无任何收藏时,`collector.isEmpty()` 使授权条件短路放行,
|
||||||
|
* 下载者身份完全未校验,任意有效授权码用户可删除他人任务。现已补上下载者校验。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void deleteWithoutCollectorsStillEnforcesDownloaderCheck() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(888);
|
||||||
|
gallery.setName("other-user-task [888]");
|
||||||
|
gallery.setDownloader(999); // 属于别的用户
|
||||||
|
when(galleries.selectGalleryByGid(888)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(888)).thenReturn(new java.util.ArrayList<>());
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(888, "code"); // 请求者是 id=7
|
||||||
|
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""), "非下载者删除必须被拒: " + response);
|
||||||
|
verify(galleries, never()).deleteGalleryByGid(anyInt());
|
||||||
|
verify(remote, never()).deleteGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归:修复前 remoteService.deleteGallery 位于授权判断之外,
|
||||||
|
* 被拒请求仍会向节点下发删除指令,且 case 0 的 success 会覆盖 failure。
|
||||||
|
* 现在授权失败即提前返回,既不落库也不通知节点。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void deniedDeleteDoesNotTouchDatabaseOrNode() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(889);
|
||||||
|
gallery.setName("collected-by-other [889]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(889)).thenReturn(gallery);
|
||||||
|
// 有他人收藏 -> 授权应被拒
|
||||||
|
when(collectMapper.selectCollectorByGid(889))
|
||||||
|
.thenReturn(new java.util.ArrayList<>(java.util.List.of(999)));
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(889, "code");
|
||||||
|
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""), "被他人收藏时删除必须被拒");
|
||||||
|
assertTrue(response.contains("别人收藏") || response.contains("不是下载人"),
|
||||||
|
"应给出与判定一致的提示: " + response);
|
||||||
|
verify(galleries, never()).deleteGalleryByGid(anyInt());
|
||||||
|
verify(remote, never()).deleteGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无收藏且本人是下载者:正常放行。 */
|
||||||
|
@Test
|
||||||
|
void deleteAllowsOwnerWhenNoCollectors() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(893);
|
||||||
|
gallery.setName("mine-no-collect [893]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(893)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(893)).thenReturn(new java.util.ArrayList<>());
|
||||||
|
when(remote.deleteGallery(any())).thenReturn((byte) 0);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(893, "code");
|
||||||
|
assertTrue(response.contains("\"result\":\"success\""), "本人任务应可删除: " + response);
|
||||||
|
verify(galleries).deleteGalleryByGid(893);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有本人收藏时,本人可删除。 */
|
||||||
|
@Test
|
||||||
|
void deleteAllowsOwnerWhenOnlySelfCollected() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(894);
|
||||||
|
gallery.setName("mine-self-collect [894]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(894)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(894))
|
||||||
|
.thenReturn(new java.util.ArrayList<>(java.util.List.of(7)));
|
||||||
|
when(remote.deleteGallery(any())).thenReturn((byte) 0);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(894, "code");
|
||||||
|
assertTrue(response.contains("\"result\":\"success\""), "仅本人收藏应可删除: " + response);
|
||||||
|
verify(galleries).deleteGalleryByGid(894);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点无响应(-1)必须如实报失败,不能被当成成功。 */
|
||||||
|
@Test
|
||||||
|
void deleteReportsFailureWhenNodeDoesNotRespond() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(895);
|
||||||
|
gallery.setName("mine [895]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(895)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(895)).thenReturn(new java.util.ArrayList<>());
|
||||||
|
when(remote.deleteGallery(any())).thenReturn((byte) -1);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(895, "code");
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""), "节点无响应不应报成功: " + response);
|
||||||
|
assertTrue(response.contains("节点无响应"), "应说明节点无响应: " + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 授权码无效(查不到用户)应被拒,且不得触发越权判定所需的空指针。 */
|
||||||
|
@Test
|
||||||
|
void deleteRejectsUnknownAuthCode() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(896);
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(896)).thenReturn(gallery);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(896, "bogus-code");
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""), "无效授权码应被拒: " + response);
|
||||||
|
verify(galleries, never()).deleteGalleryByGid(anyInt());
|
||||||
|
verify(remote, never()).deleteGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本人任务删除应放行并调用删除。 */
|
||||||
|
@Test
|
||||||
|
void deleteAllowsOwnTask() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(890);
|
||||||
|
gallery.setName("mine [890]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(890)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(890))
|
||||||
|
.thenReturn(new java.util.ArrayList<>(java.util.List.of(7)));
|
||||||
|
when(remote.deleteGallery(any())).thenReturn((byte) 0);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(890, "code");
|
||||||
|
assertTrue(response.contains("\"result\":\"success\""), "本人任务应可删除: " + response);
|
||||||
|
verify(galleries).deleteGalleryByGid(890);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点返回 IO 错误时应如实反馈,不能被 success 覆盖。 */
|
||||||
|
@Test
|
||||||
|
void deleteReportsNodeIoError() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(891);
|
||||||
|
gallery.setName("mine [891]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(891)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(891))
|
||||||
|
.thenReturn(new java.util.ArrayList<>(java.util.List.of(7)));
|
||||||
|
when(remote.deleteGallery(any())).thenReturn(
|
||||||
|
com.lion.lionwebsite.Error.ErrorCode.IO_ERROR);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(891, "code");
|
||||||
|
assertTrue(response.contains("IO错误"), "节点 IO 错误应如实返回: " + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件不存在的语义应与 IO 错误区分开。 */
|
||||||
|
@Test
|
||||||
|
void deleteReportsFileNotFoundDistinctly() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(892);
|
||||||
|
gallery.setName("mine [892]");
|
||||||
|
gallery.setDownloader(7);
|
||||||
|
when(galleries.selectGalleryByGid(892)).thenReturn(gallery);
|
||||||
|
when(collectMapper.selectCollectorByGid(892))
|
||||||
|
.thenReturn(new java.util.ArrayList<>(java.util.List.of(7)));
|
||||||
|
when(remote.deleteGallery(any())).thenReturn(
|
||||||
|
com.lion.lionwebsite.Error.ErrorCode.FILE_NOT_FOUND);
|
||||||
|
|
||||||
|
String response = service.deleteGalleryByGid(892, "code");
|
||||||
|
assertTrue(response.contains("文件不存在"), "应区分文件不存在: " + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重试不存在的任务应返回失败。 */
|
||||||
|
@Test
|
||||||
|
void retryNonexistentTaskFails() {
|
||||||
|
when(galleries.selectGalleryByGid(555)).thenReturn(null);
|
||||||
|
String response = service.retryGallery(555);
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已完成的任务重试是幂等的:返回成功并回显「下载完成」,不重复下发节点。 */
|
||||||
|
@Test
|
||||||
|
void retryOfCompletedTaskIsIdempotentSuccess() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(556);
|
||||||
|
gallery.setName("done [556]");
|
||||||
|
gallery.setStatus("下载完成");
|
||||||
|
when(galleries.selectGalleryByGid(556)).thenReturn(gallery);
|
||||||
|
|
||||||
|
String response = service.retryGallery(556);
|
||||||
|
assertTrue(response.contains("\"result\":\"success\""), "重复重试应幂等成功: " + response);
|
||||||
|
assertTrue(response.contains("下载完成"), "应回显当前已完成状态: " + response);
|
||||||
|
verify(remote, never()).retryGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点离线时重试应失败且不改变任务状态。 */
|
||||||
|
@Test
|
||||||
|
void retryFailsWhenNodeOffline() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(557);
|
||||||
|
gallery.setName("stuck [557]");
|
||||||
|
gallery.setStatus("已提交");
|
||||||
|
when(galleries.selectGalleryByGid(557)).thenReturn(gallery);
|
||||||
|
when(remote.isDead()).thenReturn(true);
|
||||||
|
|
||||||
|
String response = service.retryGallery(557);
|
||||||
|
assertFalse(response.contains("\"result\":\"success\""));
|
||||||
|
verify(galleries, never()).updateGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryRejectsUnknownGidWithNoRecord() {
|
||||||
|
when(galleries.selectGalleryByGid(anyInt())).thenReturn(null);
|
||||||
|
assertFalse(service.retryGallery(1).contains("\"result\":\"success\""));
|
||||||
|
assertFalse(service.retryGallery(2).contains("\"result\":\"success\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 列表 ----------
|
||||||
|
|
||||||
|
/** 用量查询应返回格式化后的已用量与上次重置时间。 */
|
||||||
|
@Test
|
||||||
|
void weekUsedAmountReturnsFormattedValues() {
|
||||||
|
com.lion.lionwebsite.Domain.CustomConfiguration used =
|
||||||
|
new com.lion.lionwebsite.Domain.CustomConfiguration();
|
||||||
|
used.setParameter(com.lion.lionwebsite.Domain.CustomConfiguration.WEEK_USED_AMOUNT);
|
||||||
|
used.setValue(String.valueOf(1024L * 1024 * 500));
|
||||||
|
com.lion.lionwebsite.Domain.CustomConfiguration reset =
|
||||||
|
new com.lion.lionwebsite.Domain.CustomConfiguration();
|
||||||
|
reset.setParameter(com.lion.lionwebsite.Domain.CustomConfiguration.LAST_RESET_AMOUNT_TIME);
|
||||||
|
reset.setValue("2026-09-14 14:23:58");
|
||||||
|
when(configurationMapper.selectConfiguration(
|
||||||
|
com.lion.lionwebsite.Domain.CustomConfiguration.WEEK_USED_AMOUNT)).thenReturn(used);
|
||||||
|
when(configurationMapper.selectConfiguration(
|
||||||
|
com.lion.lionwebsite.Domain.CustomConfiguration.LAST_RESET_AMOUNT_TIME)).thenReturn(reset);
|
||||||
|
|
||||||
|
String response = service.getWeekUsedAmount();
|
||||||
|
|
||||||
|
assertTrue(response.contains("500.00MB"), "已用量应格式化为人类可读: " + response);
|
||||||
|
assertTrue(response.contains("2026-09-14 14:23:58"), "应带上次重置时间: " + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回归:配置行缺失时给出默认值,不再 NPE,保证用量接口始终可用。 */
|
||||||
|
@Test
|
||||||
|
void weekUsedAmountToleratesMissingConfigRows() {
|
||||||
|
when(configurationMapper.selectConfiguration(anyString())).thenReturn(null);
|
||||||
|
|
||||||
|
String response = assertDoesNotThrow(service::getWeekUsedAmount);
|
||||||
|
assertTrue(response.contains("\"result\":\"success\""), "应成功返回默认值: " + response);
|
||||||
|
assertTrue(response.contains("0B"), "缺失时用量应为 0B: " + response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置值非法(非数字)时按 0 处理,不抛异常。 */
|
||||||
|
@Test
|
||||||
|
void weekUsedAmountToleratesMalformedValue() {
|
||||||
|
com.lion.lionwebsite.Domain.CustomConfiguration used =
|
||||||
|
new com.lion.lionwebsite.Domain.CustomConfiguration();
|
||||||
|
used.setValue("not-a-number");
|
||||||
|
when(configurationMapper.selectConfiguration(
|
||||||
|
com.lion.lionwebsite.Domain.CustomConfiguration.WEEK_USED_AMOUNT)).thenReturn(used);
|
||||||
|
|
||||||
|
String response = assertDoesNotThrow(service::getWeekUsedAmount);
|
||||||
|
assertTrue(response.contains("0B"), "非法值应按 0 处理: " + response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubBind;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import com.lion.lionwebsite.Domain.User;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子账号与绑定的业务规则。这些方法决定谁能拿到订阅、绑定到哪个账号,
|
||||||
|
* 校验失败必须返回业务 failure 而不是抛异常,且失败路径不得落库。
|
||||||
|
*/
|
||||||
|
class SubServiceTest {
|
||||||
|
|
||||||
|
private SubMapper subMapper;
|
||||||
|
private UserMapper userMapper;
|
||||||
|
private SubscriptionRefreshService refreshService;
|
||||||
|
private RemoteService remoteService;
|
||||||
|
private SubService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
subMapper = mock(SubMapper.class);
|
||||||
|
userMapper = mock(UserMapper.class);
|
||||||
|
refreshService = mock(SubscriptionRefreshService.class);
|
||||||
|
remoteService = mock(RemoteService.class);
|
||||||
|
service = new SubService(subMapper, userMapper, refreshService, remoteService,
|
||||||
|
new SubscriptionStateCoordinator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(Integer id, String name, String key, boolean enabled) {
|
||||||
|
return new SubscriptionAccount(id, name, key, false, enabled, null, null, null, null, 0, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- insertSubscriptionAccount ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankNameOrKeyIsRejectedWithoutInsert() {
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("", "key", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("name", "", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount(" ", "key", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount(null, "key", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("name", null, false, true)));
|
||||||
|
verify(subMapper, never()).insertSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void duplicateNameOrKeyIsRejected() {
|
||||||
|
when(subMapper.countSubscriptionAccountName("dup")).thenReturn(1);
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("dup", "fresh-key", false, true)));
|
||||||
|
|
||||||
|
when(subMapper.countSubscriptionAccountName("fresh-name")).thenReturn(0);
|
||||||
|
when(subMapper.countSubscriptionAccountKey("used-key")).thenReturn(1);
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("fresh-name", "used-key", false, true)));
|
||||||
|
|
||||||
|
verify(subMapper, never()).insertSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称与上游 key 两端空白应被裁剪后再校验与入库。 */
|
||||||
|
@Test
|
||||||
|
void valuesAreTrimmedBeforePersisting() {
|
||||||
|
when(subMapper.countSubscriptionAccountName("trimmed")).thenReturn(0);
|
||||||
|
when(subMapper.countSubscriptionAccountKey("key123")).thenReturn(0);
|
||||||
|
doAnswer(inv -> {
|
||||||
|
inv.getArgument(0, SubscriptionAccount.class).setId(5);
|
||||||
|
return null;
|
||||||
|
}).when(subMapper).insertSubscriptionAccount(any());
|
||||||
|
|
||||||
|
assertTrue(ok(service.insertSubscriptionAccount(" trimmed ", " key123 ", true, false)));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(SubscriptionAccount.class);
|
||||||
|
verify(subMapper).insertSubscriptionAccount(captor.capture());
|
||||||
|
assertEquals("trimmed", captor.getValue().getName());
|
||||||
|
assertEquals("key123", captor.getValue().getUpstreamKey());
|
||||||
|
assertTrue(captor.getValue().isFilterHighMultiplier());
|
||||||
|
assertFalse(captor.getValue().isEnabled(), "enabled 应原样透传");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** enabled=true 时才立刻刷新并同步;enabled=false 时不应触发上游刷新。 */
|
||||||
|
@Test
|
||||||
|
void refreshAndSyncOnlyHappenForEnabledAccount() {
|
||||||
|
when(subMapper.countSubscriptionAccountName(anyString())).thenReturn(0);
|
||||||
|
when(subMapper.countSubscriptionAccountKey(anyString())).thenReturn(0);
|
||||||
|
doAnswer(inv -> {
|
||||||
|
inv.getArgument(0, SubscriptionAccount.class).setId(9);
|
||||||
|
return null;
|
||||||
|
}).when(subMapper).insertSubscriptionAccount(any());
|
||||||
|
|
||||||
|
service.insertSubscriptionAccount("enabled-one", "k1", false, true);
|
||||||
|
verify(refreshService).refresh(9);
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
|
||||||
|
clearInvocations(refreshService, remoteService);
|
||||||
|
service.insertSubscriptionAccount("disabled-one", "k2", false, false);
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- updateSubscriptionAccount ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatingMissingAccountFails() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(404)).thenReturn(null);
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(404, "n", "k", false, true)));
|
||||||
|
verify(subMapper, never()).updateSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatingWithBlankFieldsFails() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "old", "old-key", true));
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "", "key", false, true)));
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "name", " ", false, true)));
|
||||||
|
verify(subMapper, never()).updateSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改时不得与「其他」账号重名或重 key,但与自己相同应允许。 */
|
||||||
|
@Test
|
||||||
|
void updateRejectsConflictsWithOtherAccountsButAllowsSelf() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "mine", "my-key", true));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts())
|
||||||
|
.thenReturn(new ArrayList<>(java.util.List.of(
|
||||||
|
account(1, "mine", "my-key", true),
|
||||||
|
account(2, "taken", "taken-key", true))));
|
||||||
|
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "taken", "my-key", false, true)),
|
||||||
|
"与其他账号重名应拒绝");
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "mine", "taken-key", false, true)),
|
||||||
|
"与其他账号重 key 应拒绝");
|
||||||
|
|
||||||
|
assertTrue(ok(service.updateSubscriptionAccount(1, "mine", "my-key", false, true)),
|
||||||
|
"与自身相同的名称/key 应允许");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRejectsAccountThatStillHasBindings() {
|
||||||
|
SubscriptionAccount bound = account(3, "bound", "k", true);
|
||||||
|
bound.setBoundUserCount(2);
|
||||||
|
when(subMapper.selectSubscriptionAccount(3)).thenReturn(bound);
|
||||||
|
|
||||||
|
assertFalse(ok(service.deleteSubscriptionAccount(3)));
|
||||||
|
verify(subMapper, never()).deleteSubscriptionAccount(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSucceedsWhenNoBindingsRemain() {
|
||||||
|
SubscriptionAccount free = account(4, "free", "k", true);
|
||||||
|
free.setBoundUserCount(0);
|
||||||
|
when(subMapper.selectSubscriptionAccount(4)).thenReturn(free);
|
||||||
|
|
||||||
|
assertTrue(ok(service.deleteSubscriptionAccount(4)));
|
||||||
|
verify(subMapper).deleteSubscriptionAccount(4);
|
||||||
|
verify(refreshService).invalidateCache(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- insertSubBind ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindRejectsUnknownUser() {
|
||||||
|
when(userMapper.selectUserByUsername("ghost")).thenReturn(null);
|
||||||
|
assertFalse(ok(service.insertSubBind("ghost", 1)));
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindRejectsDisabledOrMissingAccount() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(7)).thenReturn(null);
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 7)));
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(8)).thenReturn(account(8, "off", "k", false));
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 8)), "已停用账号不可绑定");
|
||||||
|
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号尚无完整缓存时必须拒绝,否则用户会拿到空订阅。 */
|
||||||
|
@Test
|
||||||
|
void bindRejectsAccountWithoutCompleteCache() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(false);
|
||||||
|
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 1)));
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindRejectsUserThatAlreadyHasBinding() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(true);
|
||||||
|
when(subMapper.countSubBindByUser("alice")).thenReturn(1);
|
||||||
|
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 1)));
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功绑定时 key 应是 8 位随机串,且会避开已存在的 key。 */
|
||||||
|
@Test
|
||||||
|
void bindGeneratesUniqueEightCharKey() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(true);
|
||||||
|
when(subMapper.countSubBindByUser("alice")).thenReturn(0);
|
||||||
|
// 前两次「已存在」,第三次通过——验证重试而非直接失败
|
||||||
|
when(subMapper.selectSubBindExist(anyString())).thenReturn(true, true, false);
|
||||||
|
|
||||||
|
assertTrue(ok(service.insertSubBind("alice", 1)));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(SubBind.class);
|
||||||
|
verify(subMapper).insertSubBind(captor.capture());
|
||||||
|
assertEquals(8, captor.getValue().getKey().length(), "订阅 key 应为 8 位");
|
||||||
|
assertEquals("alice", captor.getValue().getUser());
|
||||||
|
assertEquals(1, captor.getValue().getSubscriptionAccountId());
|
||||||
|
verify(subMapper, times(3)).selectSubBindExist(anyString());
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetKeyRejectsUserWithoutBinding() {
|
||||||
|
when(subMapper.countSubBindByUser("nobody")).thenReturn(0);
|
||||||
|
assertFalse(ok(service.resetKey("nobody")));
|
||||||
|
verify(subMapper, never()).updateSubBindKey(anyString(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetKeyReplacesKeyAndClearsAccessRecords() {
|
||||||
|
when(subMapper.countSubBindByUser("alice")).thenReturn(1);
|
||||||
|
when(subMapper.selectSubBindExist(anyString())).thenReturn(false);
|
||||||
|
|
||||||
|
assertTrue(ok(service.resetKey("alice")));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(subMapper).updateSubBindKey(eq("alice"), captor.capture());
|
||||||
|
assertEquals(8, captor.getValue().length());
|
||||||
|
verify(subMapper).deleteSubUpdateRecord("alice");
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rebindRejectsDisabledTargetAndMissingBinding() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(2)).thenReturn(account(2, "off", "k", false));
|
||||||
|
assertFalse(ok(service.rebind("alice", 2)), "目标账号停用应拒绝");
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(3)).thenReturn(account(3, "on", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(3)).thenReturn(true);
|
||||||
|
when(subMapper.updateSubBindAccount("alice", 3)).thenReturn(0);
|
||||||
|
assertFalse(ok(service.rebind("alice", 3)), "无既有绑定应拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rebindSucceedsWhenTargetHealthyAndBindingExists() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(3)).thenReturn(account(3, "on", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(3)).thenReturn(true);
|
||||||
|
when(subMapper.updateSubBindAccount("alice", 3)).thenReturn(1);
|
||||||
|
|
||||||
|
assertTrue(ok(service.rebind("alice", 3)));
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 公开订阅分发 updateSub ----------
|
||||||
|
|
||||||
|
/** 未知 key 必须回 404,不能泄漏「账号存在但未绑定」这类差异。 */
|
||||||
|
@Test
|
||||||
|
void publicSubReturns404ForUnknownKey() throws Exception {
|
||||||
|
when(subMapper.selectSubBind("nokey")).thenReturn(null);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "nokey");
|
||||||
|
verify(response).sendError(eq(404), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicSubReturns404WhenAccountDisabled() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", false, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "key1");
|
||||||
|
verify(response).sendError(eq(404), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicSubReturns404WhenAccountIdMissing() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", null, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "key1");
|
||||||
|
verify(response).sendError(eq(404), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非法 client(既非 v2 也非 cat)应回 400,避免把未知格式当订阅返回。 */
|
||||||
|
@Test
|
||||||
|
void publicSubRejectsUnknownClient() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
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);
|
||||||
|
|
||||||
|
service.updateSub(response, request, "clashmeta", "key1");
|
||||||
|
verify(response).sendError(eq(400), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存文件缺失时应回 503(暂时不可用),而不是 404 或空响应。 */
|
||||||
|
@Test
|
||||||
|
void publicSubReturns503WhenCacheMissing() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
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);
|
||||||
|
|
||||||
|
service.updateSub(response, request, "v2", "key1");
|
||||||
|
verify(response).sendError(eq(503), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicSubIgnoresNullKeyOrClient() throws Exception {
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
service.updateSub(response, request, null, "key1");
|
||||||
|
service.updateSub(response, request, "v2", null);
|
||||||
|
verifyNoInteractions(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** 通用工具:体积换算、时间格式化、端口探测、404 输出。 */
|
||||||
|
class CustomUtilTest {
|
||||||
|
|
||||||
|
// ---------- fileSizeToString ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytesBelowOneKbAreShownAsPlainBytes() {
|
||||||
|
assertEquals("0B", CustomUtil.fileSizeToString(0));
|
||||||
|
assertEquals("1B", CustomUtil.fileSizeToString(1));
|
||||||
|
assertEquals("1023B", CustomUtil.fileSizeToString(1023));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void kilobytesUseTwoDecimals() {
|
||||||
|
assertEquals("1.00KB", CustomUtil.fileSizeToString(1024));
|
||||||
|
assertEquals("1.50KB", CustomUtil.fileSizeToString(1536));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void megabytesAndGigabytesUseTwoDecimals() {
|
||||||
|
assertEquals("1.00MB", CustomUtil.fileSizeToString(1024L * 1024));
|
||||||
|
assertEquals("2.50GB", CustomUtil.fileSizeToString((long) (2.5 * 1024 * 1024 * 1024)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 边界值必须落在「较大」的那一档,不能出现 1024B 这种输出。 */
|
||||||
|
@Test
|
||||||
|
void unitBoundariesRollOverToTheNextUnit() {
|
||||||
|
assertEquals("1.00KB", CustomUtil.fileSizeToString(1024), "1024B 应进位为 KB");
|
||||||
|
assertEquals("1.00MB", CustomUtil.fileSizeToString(1024L * 1024), "MB 边界应进位");
|
||||||
|
assertEquals("1023.99KB", CustomUtil.fileSizeToString(1024L * 1024 - 11));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- stringToFileSize ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesPlainByteValues() {
|
||||||
|
assertEquals(512L, CustomUtil.stringToFileSize("512B"));
|
||||||
|
assertEquals(0L, CustomUtil.stringToFileSize("0B"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesIntegerAndDecimalValuesWithUnits() {
|
||||||
|
assertEquals(1024L, CustomUtil.stringToFileSize("1KB"));
|
||||||
|
assertEquals(1024L, CustomUtil.stringToFileSize("1KiB"));
|
||||||
|
assertEquals(1024L * 1024, CustomUtil.stringToFileSize("1MB"));
|
||||||
|
assertEquals(1024L * 1024, CustomUtil.stringToFileSize("1MiB"));
|
||||||
|
assertEquals(1536L, CustomUtil.stringToFileSize("1.5KB"));
|
||||||
|
assertEquals((long) (2.5 * 1024 * 1024 * 1024), CustomUtil.stringToFileSize("2.5GB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无法识别的单位返回 0,而不是抛异常,调用方据此判定无效输入。 */
|
||||||
|
@Test
|
||||||
|
void unknownUnitYieldsZero() {
|
||||||
|
assertEquals(0L, CustomUtil.stringToFileSize("10TB"));
|
||||||
|
assertEquals(0L, CustomUtil.stringToFileSize("10XB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 fileSizeToString 互为逆运算(KB 及以上取整容差)。 */
|
||||||
|
@Test
|
||||||
|
void roundTripsThroughBothDirections() {
|
||||||
|
for (long size : new long[]{512, 1024, 2048, 1024L * 512, 1024L * 1024, 1024L * 1024 * 8}) {
|
||||||
|
String text = CustomUtil.fileSizeToString(size);
|
||||||
|
assertEquals(size, CustomUtil.stringToFileSize(text), "往返应还原: " + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空串/纯文本无数字时不抛异常(当前实现会抛 NumberFormatException,此处锁住该行为)。 */
|
||||||
|
@Test
|
||||||
|
void malformedInputWithoutDigitsThrows() {
|
||||||
|
assertThrows(Exception.class, () -> CustomUtil.stringToFileSize("abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 时间 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nowMatchesConfiguredPattern() {
|
||||||
|
String now = CustomUtil.now();
|
||||||
|
assertDoesNotThrow(() -> LocalDateTime.parse(now, CustomUtil.dateTimeFormatter()),
|
||||||
|
"now() 必须与 dateTimeFormatter() 的模式一致,否则解析失败");
|
||||||
|
assertEquals(19, now.length(), "yyyy-MM-dd HH:mm:ss 固定 19 字符");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatterIsStableAcrossCalls() {
|
||||||
|
assertSame(CustomUtil.dateTimeFormatter(), CustomUtil.dateTimeFormatter());
|
||||||
|
assertNotNull(CustomUtil.dateTimeFormatter().parse("2026-09-14 14:23:58"));
|
||||||
|
assertThrows(DateTimeParseException.class,
|
||||||
|
() -> CustomUtil.dateTimeFormatter().parse("2026/09/14 14:23:58"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 端口探测 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findIdlePortReturnsUsablePort() throws IOException {
|
||||||
|
int port = CustomUtil._findIdlePort(49152);
|
||||||
|
assertTrue(port >= 49152 && port < 65535, "应返回区间内的端口,实际: " + port);
|
||||||
|
try (ServerSocket probe = new ServerSocket(port)) {
|
||||||
|
assertEquals(port, probe.getLocalPort(), "返回的端口应真的可用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 起始端口被占用时应向后续端口回退。 */
|
||||||
|
@Test
|
||||||
|
void findIdlePortSkipsOccupiedPort() throws IOException {
|
||||||
|
try (ServerSocket occupied = new ServerSocket(0)) {
|
||||||
|
int busy = occupied.getLocalPort();
|
||||||
|
int found = CustomUtil._findIdlePort(busy);
|
||||||
|
assertNotEquals(busy, found, "被占用的端口不应被返回");
|
||||||
|
assertTrue(found > busy, "应向后寻找,实际: " + found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sizeConstantsAreConsistent() {
|
||||||
|
assertEquals(1024.0, CustomUtil.ONE_KB);
|
||||||
|
assertEquals(1024.0 * 1024, CustomUtil.ONE_MB);
|
||||||
|
assertEquals(1024.0 * 1024 * 1024, CustomUtil.ONE_GB);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void objectMapperIsSharedAndUsable() {
|
||||||
|
assertNotNull(CustomUtil.objectMapper);
|
||||||
|
assertSame(CustomUtil.objectMapper, CustomUtil.objectMapper, "应为共享单例");
|
||||||
|
assertTrue(CustomUtil.objectMapper.createObjectNode().isObject());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- fourZeroFour ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fourZeroFourSends404() throws IOException {
|
||||||
|
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||||
|
CustomUtil.fourZeroFour(response);
|
||||||
|
verify(response).sendError(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sendError 抛 IOException 时应被吞掉并记日志,不能把异常抛给调用方。 */
|
||||||
|
@Test
|
||||||
|
void fourZeroFourSwallowsIoException() throws IOException {
|
||||||
|
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||||
|
doThrow(new IOException("client gone")).when(response).sendError(404);
|
||||||
|
assertDoesNotThrow(() -> CustomUtil.fourZeroFour(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图库解析与图片地址相关的纯逻辑部分。
|
||||||
|
* parse()/parseImageKeys() 依赖外部站点,这里只覆盖可离线判定的分支:
|
||||||
|
* 链接校验、gid 提取、mpvKey 缓存行为,以及取图失败时的降级(必须返回 null 而非抛异常)。
|
||||||
|
*/
|
||||||
|
class GalleryUtilTest {
|
||||||
|
|
||||||
|
// ---------- verifyLink ----------
|
||||||
|
|
||||||
|
/** 非法或过短链接必须被拒(返回 null),这是录入侧的第一道闸门。 */
|
||||||
|
@Test
|
||||||
|
void verifyLinkRejectsNullOrTooShort() {
|
||||||
|
assertNull(GalleryUtil.verifyLink(null));
|
||||||
|
assertNull(GalleryUtil.verifyLink(""));
|
||||||
|
assertNull(GalleryUtil.verifyLink("https://exhentai.org/g/123/abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 必须同时含 /g/ 与 exhentai,缺一即拒。 */
|
||||||
|
@Test
|
||||||
|
void verifyLinkRequiresGalleryPathAndSite() {
|
||||||
|
assertNull(GalleryUtil.verifyLink(
|
||||||
|
"https://example.com/g/1234567/0123456789"), "非 e-hentai 域名应拒绝");
|
||||||
|
assertNull(GalleryUtil.verifyLink(
|
||||||
|
"https://exhentai.org/some/other/very/long/path/here"), "缺少 /g/ 应拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyLinkAcceptsWellFormedGalleryUrl() {
|
||||||
|
String url = "https://exhentai.org/g/1234567/0123456789ab/";
|
||||||
|
assertNotNull(GalleryUtil.verifyLink(url));
|
||||||
|
assertEquals("", GalleryUtil.verifyLink(url), "通过校验时返回空串作为 origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- parseGid ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseGidExtractsNumericIdFromLink() {
|
||||||
|
assertEquals(1234567, GalleryUtil.parseGid("https://exhentai.org/g/1234567/0123456789ab/"));
|
||||||
|
assertEquals(1, GalleryUtil.parseGid("https://exhentai.org/g/1/x/"));
|
||||||
|
assertEquals(987654321, GalleryUtil.parseGid("https://exhentai.org/g/987654321/key/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺少 /g/ 段时必须返回 null 而不是抛异常。 */
|
||||||
|
@Test
|
||||||
|
void parseGidReturnsNullWhenMarkerMissing() {
|
||||||
|
assertNull(GalleryUtil.parseGid("https://exhentai.org/1234567/abc/"));
|
||||||
|
assertNull(GalleryUtil.parseGid(""));
|
||||||
|
assertNull(GalleryUtil.parseGid("no-marker-here"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** gid 非数字时返回 null(NumberFormatException 未被捕获会向上抛,这里锁住实际行为)。 */
|
||||||
|
@Test
|
||||||
|
void parseGidOnNonNumericSegmentEitherNullsOrThrows() {
|
||||||
|
try {
|
||||||
|
Integer result = GalleryUtil.parseGid("https://exhentai.org/g/not-a-number/key/");
|
||||||
|
assertNull(result, "非数字 gid 应为 null");
|
||||||
|
} catch (NumberFormatException expected) {
|
||||||
|
// 当前实现对非数字段会抛 NumberFormatException,属既有行为;
|
||||||
|
// 调用方(parse/refreshMpvKey)传入的都是已验证链接。
|
||||||
|
assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- getImageUrl 失败降级 ----------
|
||||||
|
|
||||||
|
/** 取图接口异常时必须返回 null,供上层回退到 404,而不是把异常抛穿请求链路。 */
|
||||||
|
@Test
|
||||||
|
void getImageUrlReturnsNullWhenUpstreamFails() {
|
||||||
|
ImageKeyCache cache = new ImageKeyCache();
|
||||||
|
cache.setGid("1234567");
|
||||||
|
cache.setPage(1);
|
||||||
|
cache.setImgkey("abc123");
|
||||||
|
|
||||||
|
try (var methods = mockStatic(GalleryUtil.class)) {
|
||||||
|
methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenThrow(new java.io.IOException("upstream down"));
|
||||||
|
methods.when(() -> GalleryUtil.getImageUrl(anyString(), any()))
|
||||||
|
.thenCallRealMethod();
|
||||||
|
|
||||||
|
assertNull(GalleryUtil.getImageUrl("mpv-key", cache));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游返回 Key mismatch 时同样返回 null(表示当前 mpvKey 已失效)。 */
|
||||||
|
@Test
|
||||||
|
void getImageUrlReturnsNullOnKeyMismatch() {
|
||||||
|
ImageKeyCache cache = new ImageKeyCache();
|
||||||
|
cache.setGid("1234567");
|
||||||
|
cache.setPage(2);
|
||||||
|
cache.setImgkey("abc123");
|
||||||
|
|
||||||
|
try (var methods = mockStatic(GalleryUtil.class)) {
|
||||||
|
methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn("{\"error\":\"Key mismatch\"}");
|
||||||
|
methods.when(() -> GalleryUtil.getImageUrl(anyString(), any()))
|
||||||
|
.thenCallRealMethod();
|
||||||
|
|
||||||
|
assertNull(GalleryUtil.getImageUrl("stale-key", cache));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正常响应应取出 "i" 字段作为图片地址。 */
|
||||||
|
@Test
|
||||||
|
void getImageUrlExtractsUrlFromValidResponse() {
|
||||||
|
ImageKeyCache cache = new ImageKeyCache();
|
||||||
|
cache.setGid("1234567");
|
||||||
|
cache.setPage(3);
|
||||||
|
cache.setImgkey("abc123");
|
||||||
|
|
||||||
|
try (var methods = mockStatic(GalleryUtil.class)) {
|
||||||
|
methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn("{\"i\":\"https://s.exhentai.org/s/abc/1234567-3\"}");
|
||||||
|
methods.when(() -> GalleryUtil.getImageUrl(anyString(), any()))
|
||||||
|
.thenCallRealMethod();
|
||||||
|
|
||||||
|
assertEquals("https://s.exhentai.org/s/abc/1234567-3",
|
||||||
|
GalleryUtil.getImageUrl("good-key", cache));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求体必须带上 gid / mpvkey / imgkey / method / page 五个契约字段。 */
|
||||||
|
@Test
|
||||||
|
void getImageUrlSendsRequiredApiFields() {
|
||||||
|
ImageKeyCache cache = new ImageKeyCache();
|
||||||
|
cache.setGid("777");
|
||||||
|
cache.setPage(5);
|
||||||
|
cache.setImgkey("img-key-value");
|
||||||
|
|
||||||
|
try (var methods = mockStatic(GalleryUtil.class)) {
|
||||||
|
methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn("{\"i\":\"https://example.org/x\"}");
|
||||||
|
methods.when(() -> GalleryUtil.getImageUrl(anyString(), any()))
|
||||||
|
.thenCallRealMethod();
|
||||||
|
GalleryUtil.getImageUrl("mpv-value", cache);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var bodyCaptor = org.mockito.ArgumentCaptor.forClass(HashMap.class);
|
||||||
|
methods.verify(() -> GalleryUtil.requests(anyString(), eq("post"), any(), bodyCaptor.capture()));
|
||||||
|
|
||||||
|
var body = (HashMap<String, String>) bodyCaptor.getValue();
|
||||||
|
assertEquals("777", body.get("gid"));
|
||||||
|
assertEquals("mpv-value", body.get("mpvkey"));
|
||||||
|
assertEquals("img-key-value", body.get("imgkey"));
|
||||||
|
assertEquals("imagedispatch", body.get("method"));
|
||||||
|
assertEquals("5", body.get("page"));
|
||||||
|
assertEquals("json", body.get("payload"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- convertImg ----------
|
||||||
|
|
||||||
|
/** 后缀已匹配目标格式时应原样返回,不做无谓转换。 */
|
||||||
|
@Test
|
||||||
|
void convertImgReturnsInputWhenTargetEqualsSource() {
|
||||||
|
assertEquals("/tmp/pic.avif", GalleryUtil.convertImg("/tmp/pic.avif", ".avif"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转换器不存在或失败时返回 null(调用方据此回退原图),不得抛异常。 */
|
||||||
|
@Test
|
||||||
|
void convertImgReturnsNullWhenConverterUnavailable() {
|
||||||
|
String result = GalleryUtil.convertImg("/nonexistent/image-xyz.png", ".png");
|
||||||
|
assertNull(result, "转换失败应返回 null 让调用方回退");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- mpvKey 缓存 ----------
|
||||||
|
|
||||||
|
/** 缓存命中时不应触发刷新(避免每次取图都打上游)。 */
|
||||||
|
@Test
|
||||||
|
void getMpvKeyUsesCacheWithoutRefreshing() {
|
||||||
|
String url = "https://example.org/g/555000111/key/";
|
||||||
|
GalleryUtil.gid2MpvKey.put("555000111", "cached-value");
|
||||||
|
try (var methods = mockStatic(GalleryUtil.class)) {
|
||||||
|
methods.when(() -> GalleryUtil.parseGid(url)).thenReturn(555000111);
|
||||||
|
methods.when(() -> GalleryUtil.getMpvKey(url)).thenCallRealMethod();
|
||||||
|
|
||||||
|
assertEquals("cached-value", GalleryUtil.getMpvKey(url));
|
||||||
|
methods.verify(() -> GalleryUtil.refreshMpvKey(anyString()), never());
|
||||||
|
} finally {
|
||||||
|
GalleryUtil.gid2MpvKey.remove("555000111");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 体量换算与展示 ----------
|
||||||
|
|
||||||
|
/** fileSizeToString 与 stringToFileSize 是展示/回读的一对,须保持互逆。 */
|
||||||
|
@Test
|
||||||
|
void displayAndParseFileSizeAreInverse() {
|
||||||
|
long size = 350L * 1024 * 1024;
|
||||||
|
assertEquals(size, CustomUtil.stringToFileSize(CustomUtil.fileSizeToString(size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析出的 gid 列表用于批量取图,顺序与页序必须一致。 */
|
||||||
|
@Test
|
||||||
|
void imageKeyCacheCarriesGidPageAndKey() {
|
||||||
|
List<ImageKeyCache> caches = new ArrayList<>();
|
||||||
|
for (int page = 1; page <= 3; page++) {
|
||||||
|
ImageKeyCache cache = new ImageKeyCache();
|
||||||
|
cache.setGid("1234567");
|
||||||
|
cache.setPage(page);
|
||||||
|
cache.setImgkey("key-" + page);
|
||||||
|
caches.add(cache);
|
||||||
|
}
|
||||||
|
assertEquals(3, caches.size());
|
||||||
|
assertEquals(1, caches.get(0).getPage());
|
||||||
|
assertEquals("key-3", caches.get(2).getImgkey());
|
||||||
|
assertEquals("1234567", caches.get(0).getGid());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一响应封装。所有 Controller 都通过它产出 JSON,
|
||||||
|
* 且前端与机器人按 "result"/"data" 两个字段判定成败,故键名与语义必须锁死。
|
||||||
|
*/
|
||||||
|
class ResponseTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归:修复前 isSuccess() 直接 result.get("result").asText(),
|
||||||
|
* 未设置该键时抛 NPE。现在把「未设置」视为失败。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void isSuccessOnUnsetStatusIsFalseInsteadOfThrowing() {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
assertFalse(assertDoesNotThrow(response::isSuccess),
|
||||||
|
"未设置 result 键应视为失败,而不是抛 NPE");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取不存在的键应返回 null,而不是 NPE。 */
|
||||||
|
@Test
|
||||||
|
void getReturnsNullForMissingKey() {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
assertNull(assertDoesNotThrow(() -> response.get("nope")));
|
||||||
|
assertNull(assertDoesNotThrow(response::getData));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successSetsResultFlag() {
|
||||||
|
Response response = Response.generateResponse().success();
|
||||||
|
assertTrue(response.isSuccess());
|
||||||
|
assertEquals("{\"result\":\"success\"}", response.toJSONString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successWithDataKeepsBothFields() {
|
||||||
|
Response response = Response.generateResponse().success("payload");
|
||||||
|
assertTrue(response.isSuccess());
|
||||||
|
assertEquals("payload", response.getData());
|
||||||
|
assertTrue(response.toJSONString().contains("\"data\":\"payload\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failureSetsFlagAndData() {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
response.failure("boom");
|
||||||
|
assertFalse(response.isSuccess());
|
||||||
|
assertEquals("boom", response.getData());
|
||||||
|
assertEquals("failure", response.get("result"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonNodeDataIsEmbeddedAsStructuredValue() {
|
||||||
|
tools.jackson.databind.ObjectMapper mapper = new tools.jackson.databind.ObjectMapper();
|
||||||
|
tools.jackson.databind.node.ObjectNode node = mapper.createObjectNode();
|
||||||
|
node.put("gid", 42);
|
||||||
|
|
||||||
|
Response response = Response.generateResponse().success(node);
|
||||||
|
assertTrue(response.isSuccess());
|
||||||
|
String json = response.toJSONString();
|
||||||
|
assertTrue(json.contains("\"gid\":42"), "结构化数据应内嵌为对象而非字符串: " + json);
|
||||||
|
assertFalse(json.contains("\\\"gid\\\""), "不应被双重转义");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void arbitraryKeyValueRoundTrips() {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
response.set("custom", "value");
|
||||||
|
assertEquals("value", response.get("custom"));
|
||||||
|
assertTrue(response.toJSONString().contains("\"custom\":\"value\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静态便捷方法供异常等场景直接返回 JSON 字符串。 */
|
||||||
|
@Test
|
||||||
|
void staticHelpersProduceExpectedJson() {
|
||||||
|
assertEquals("{\"result\":\"success\"}", Response._success());
|
||||||
|
assertTrue(Response._success("done").contains("\"result\":\"success\""));
|
||||||
|
assertTrue(Response._success("done").contains("\"data\":\"done\""));
|
||||||
|
assertTrue(Response._failure("bad").contains("\"result\":\"failure\""));
|
||||||
|
assertTrue(Response._failure("bad").contains("\"data\":\"bad\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已废弃的 getResult() 实际返回 data 字段(保留兼容,锁住该行为以免误改)。 */
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
void deprecatedGetResultReturnsDataField() {
|
||||||
|
Response response = Response.generateResponse().success("the-data");
|
||||||
|
assertEquals("the-data", response.getResult());
|
||||||
|
assertEquals(response.getData(), response.getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多次 success/failure 调用以后者为准。 */
|
||||||
|
@Test
|
||||||
|
void lastStatusCallWins() {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
response.success("first");
|
||||||
|
response.failure("second");
|
||||||
|
assertFalse(response.isSuccess());
|
||||||
|
assertEquals("second", response.getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个 Response 实例必须独立,避免共享 ObjectNode 造成串数据。 */
|
||||||
|
@Test
|
||||||
|
void instancesDoNotShareState() {
|
||||||
|
Response first = Response.generateResponse().success("one");
|
||||||
|
Response second = Response.generateResponse().success("two");
|
||||||
|
assertEquals("one", first.getData());
|
||||||
|
assertEquals("two", second.getData());
|
||||||
|
assertNotSame(first.toJSONString(), second.toJSONString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中文与特殊字符应被正确转义或原样输出,不破坏 JSON 结构。 */
|
||||||
|
@Test
|
||||||
|
void nonAsciiDataProducesValidJson() {
|
||||||
|
Response response = Response.generateResponse().success("下载失败:网络异常 \"quoted\"");
|
||||||
|
String json = response.toJSONString();
|
||||||
|
assertDoesNotThrow(() -> new tools.jackson.databind.ObjectMapper().readTree(json),
|
||||||
|
"输出必须是合法 JSON");
|
||||||
|
assertTrue(json.contains("下载失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user