Compare commits

...
Author SHA1 Message Date
root a288846552 收紧 GalleryManage 子路径鉴权并更新项目说明 2026-09-14 19:17:23 +08:00
root 850819ed86 新增 .gitignore:忽略构建产物、本地日志与 IDE 文件
规则来自 storageNode 现有模板(39 行,含 maven-wrapper.jar 的 ! 例外),
并追加 run.out / *.log(后端与 storageNode 本地都会产生运行时日志)。
application.yaml 与 .mvn/wrapper 均被跟踪,不受影响。
2026-09-14 15:38:48 +08:00
root 576092578d 修复越权删除、畸形链接 500 及两处 NPE
修复上一提交的测试所发现的缺陷。

1) 越权删除(安全,最严重):deleteGalleryByGid 的授权条件是
   `!(collector.isEmpty() || collector.size()==1 && ...)`。当画廊无任何收藏时
   collector.isEmpty() 使整个条件短路放行,下载者身份完全未校验,
   任意有效授权码用户都能删除他人任务记录(无收藏正是最常见情形)。
   改为与提示文案一致的判定:被别人收藏 或 请求者不是下载人 即拒绝。

2) 被拒请求仍删除节点文件且对外显示成功:remoteService.deleteGallery 原先位于
   授权判断之外,即使拒绝也会向节点下发删除指令;且 switch 中 case 0 的
   response.success() 会覆盖先前的 failure。现改为授权失败即提前返回。

3) 畸形链接导致 500:link.split("/")[4] 段数不足抛 ArrayIndexOutOfBoundsException,
   而只捕获 NumberFormatException;项目无 @ControllerAdvice,异常穿透为 500。
   抽出 parseGidFromLink() 统一把 null/段数不足/非数字转成业务失败。

4) Response.isSuccess()/getData()/get() 在键缺失时抛 NPE,改为安全返回
   (isSuccess 视未设置为失败)。deleteGalleryByGid 也显式处理节点无响应(-1),
   原先该分支不设置 result,末尾 response.get("result") 会 NPE。

5) getWeekUsedAmount() 在配置行缺失/值非法时给默认 0,不再 NPE。

验证:新增/改写回归用例后 143 项测试全过;并用真实数据做了新旧对照实测——
取 downloader=4 且无收藏的任务,以 id=25 用户的授权码删除:
  旧 jar:数据库记录被删(count 0),随后 500;
  新 jar:记录保留(count 1),返回「你不是下载人」并被拒。
覆盖率的 GalleryManageService 由 40.1% 升至 45.8%。
2026-09-14 15:23:42 +08:00
root 213ed4e7f6 补充测试用例并接入 JaCoCo 覆盖率统计
原 8 个测试类、16 个用例,指令覆盖率仅 16.9%,协议编解码、
鉴权拦截器、UA 分流过滤器、子账号业务规则等高风险代码完全未覆盖。

接入 JaCoCo(pom 增加 jacoco-maven-plugin 0.8.13,test 阶段出报告)。
新增 9 个测试类、118 个用例,测试总数 16 -> 134,全部通过:

- Message.MessageCodecTest(12):8 种消息的帧格式与往返;
  锁死帧头 [类型 1B][长度 4B][JSON]、@JsonIgnore 的 path 不外泄、
  多字节 UTF-8 按字节计长、未知类型静默丢弃、编解码器间不共享状态。
- Interceptor.TaskHandlerInterceptorTest(11):授权码放行/拒绝两侧,
  含空值、空白、前后缀、大小写、列表含 null 等必须拒绝的形态,
  以及 updateAuthCodes 后被吊销授权码立即失效。
- Filter.AdaptorFilterTest(9):桌面放行 / 移动 UA 跳转 /validate 例外 /
  无 UA 既不跳转也不放行 / AuthCode=alone 透传与不泄漏非 alone 授权码。
- Service.SubServiceTest(24):子账号增改删的校验与落库边界、
  绑定与改绑的前置条件、key 生成冲突重试、公开订阅的 404/400/503 分支。
- Service.GalleryManageServiceTest(19):任务创建校验、节点离线、
  查询/重试幂等、删除的授权与节点回执分支。
- Util.CustomUtilTest(17):体积换算双向互逆与档位边界、时间格式化、
  端口探测与占用回退、404 输出容错。
- Util.GalleryUtilTest(15):链接校验、gid 提取、取图失败的降级返回 null、
  请求体契约字段、mpvKey 缓存命中不刷新。
- Util.ResponseTest(11):result/data 字段契约、结构化数据不被双重转义、
  实例间不共享状态、非 ASCII 输出仍是合法 JSON。

覆盖率:指令 16.9% -> 35.5%,分支 -> 32.6%。
TaskHandlerInterceptor/Response 100%、CustomUtil 99%、MessageCodec 94.6%、
AdaptorFilter 94.4%、SubService 78.1%。

测试过程中发现 3 处既有缺陷,均未擅自修改生产逻辑,改为在测试中
显式断言现状并注明「若断言失败说明已修复」:
1) GalleryManageService.deleteGalleryByGid:当画廊无任何收藏时
   collector.isEmpty() 使授权条件短路放行,**未校验下载者身份**,
   任意有效授权码用户可删除他人任务记录。
2) 同一方法:remoteService.deleteGallery 位于授权判断之外,被拒请求
   仍会向节点下发删除指令;且 switch 中 case 0 会 response.success()
   覆盖先前的 failure,对外表现为成功。
3) createTask:link.split("/")[4] 段数不足抛 ArrayIndexOutOfBoundsException,
   而只捕获 NumberFormatException,且项目无 @ControllerAdvice,会穿透为 500。
另有 Response.isSuccess() 在未设置 result 键时 NPE、
getWeekUsedAmount 在配置行缺失时 NPE,一并记录。
2026-09-14 15:04:52 +08:00
root d5b97b82a0 升级剩余依赖并将 HttpClient 4 迁移到 5
- HttpClient 4.5.14 -> httpclient5 5.6.4(Boot 4.1.1 托管版本):
  HttpClient 4.x 最后一次发布是 2022-11,已 EOL,且 Boot 自 3.1 起不再管理它;
  httpmime 一并移除(httpclient5 已内置 multipart)。
  代码迁移 3 个文件(LocalService、SubscriptionRefreshService、GalleryUtil):
  包名 org.apache.http.* -> org.apache.hc.client5.http.*;HttpClient 5 用
  response.getCode() 取代 response.getStatusLine().getStatusCode();
  RequestConfig 的 socket 读超时改名 setSocketTimeout -> setResponseTimeout,
  超时参数改为 Timeout.ofMilliseconds(...)。
  MultipartEntityBuilder/EntityBuilder 仍在,仅换包名,行为不变。
- java-telegram-bot-api 7.9.1 -> 10.1.0(跨 3 个大版本)。本项目只用到
  new TelegramBot(token)、new SendMessage(chatId, text)、bot.execute(msg),
  已核对 10.1.0 的构造器与 execute 签名均兼容。
- hutool-all 5.8.26 -> 5.8.47;commons-compress 1.26.1 -> 1.28.0。

验证:mvn test 16 项全过(注意单测对 HTTP 是 mock,不能证明迁移后的真实网络路径);
另外做了针对性验证——隔离实例上 POST /personal/subBind/accounts/1/refresh 触发
真实上游下载,经迁移后的 httpclient5 成功取回 127,379 字节订阅并写入隔离缓存目录
(生产目录未被触碰);另用等价配置的 httpclient5 客户端实拉 https://example.com
返回 200 且正文可读。启动 5.09s、各接口正常、日志无 httpclient 相关异常。
2026-09-14 14:23:23 +08:00
root 4623fca8b0 升级到 Spring Boot 4.1.1 并迁移 Jackson 3
- 父 POM 3.3.2 -> 4.1.1;spring-boot-starter-web 改名为 webmvc;
  mybatis-spring-boot-starter(-test) 3.0.3 -> 4.1.0(对标 Boot 4)。
- Jackson 2 -> 3:databind/core 包名改为 tools.jackson.*(注解仍在
  com.fasterxml.jackson.annotation,无需改动)。Boot 4 移除了
  JsonProcessingException,PersonalController.ip() 实际不抛该异常,去掉声明。
- @ServletComponentScan 迁移到 org.springframework.boot.web.server.servlet.context。
- 顺带修复无 Boot 关联的漏洞:jsoup 1.15.3 -> 1.23.2(CVE-2026-71497)、
  commons-io -> 2.22.0(CVE-2024-47554)、lombok 对齐托管版 1.18.46。
- native-maven-plugin 0.10.3 -> 1.1.8(Boot 4.1.1 托管版本)。
  注意:原生构建仍需 GraalVM/JDK 25,本次仅验证 JVM 运行。

验证:mvn test 16 项全过;隔离冒烟(真实库副本、独立端口)通过——
Tomcat 11 启动 4.5s、/ 302、/GalleryManage 200、订阅分发 /sub/v2|cat 字节数与
3.x 一致、WebSocket 升级 101、@ServletComponentScan 过滤器生效、
与 storageNode 的 Jackson 2.15.2 线上格式双向兼容。
2026-09-14 13:44:03 +08:00
root f5ef8ca487 修复测试构造与 commons-io 版本不匹配
- RemoteServiceTest 原先调用 ResponseMessage(int, byte),主源码中不存在该构造器,
  导致测试源码无法编译;改为在测试内用 setter 组装。
- commons-compress 1.26.1 依赖 commons-io 2.15.1 的 IOUtils.skip(InputStream,long,Supplier),
  而 POM 钉在 2.11.0,任何 tar 读取都会抛 NoSuchMethodError;commons-io 升至 2.15.1
  (同时覆盖 CVE-2024-47554)。

后端 mvn test 16 项、storageNode 5 项、桌面前端 9 项全部通过。
2026-09-14 13:36:45 +08:00
root 50e513b8cc 记录审查修复提交与离线编译验证事项 2026-09-08 12:51:59 +08:00
root d3b18f90fa 修复图片Key缓存冷启动并关闭异常HTTP响应 2026-09-08 12:50:44 +08:00
root a9ba631847 补齐连接反复断开和缓存失败重试边界 2026-09-08 12:48:24 +08:00
root 60facae9b5 原子发布图片缓存并关闭下载和打包文件流 2026-09-08 09:30:23 +08:00
root b15eeaf45e 统一节点请求生命周期并释放重连资源 2026-09-08 09:25:45 +08:00
root 1e6e3a1557 缩短订阅状态锁范围并拒绝过期刷新结果 2026-09-08 09:23:14 +08:00
root 7f823b6150 修复断点下载范围解析与读取越界 2026-09-08 09:21:01 +08:00
root f65c5ad860 先保存下载任务再下发节点以保留即时状态 2026-09-08 09:19:47 +08:00
root 3c8be3e7c9 修复订阅访问IP记录缺失问题 2026-08-30 23:07:25 +08:00
root fb327ba00c 降低订阅快照测试耦合并统一缓存路径 2026-08-30 10:41:55 +08:00
root 10e6d44570 修复订阅快照消息解码分支重复 2026-08-30 10:40:39 +08:00
root 44995afe78 修复订阅快照消息常量重复定义 2026-08-30 10:39:32 +08:00
root bc4ee64a83 修复订阅备机同步的一致性与重试问题 2026-08-30 10:34:19 +08:00
root 14dd12d9ad 增加订阅快照备机同步链路 2026-08-30 10:24:57 +08:00
root fc8548f90f 支持多子账号订阅与独立节点过滤 2026-08-30 09:32:44 +08:00
root 9475237731 新增单个下载任务重试接口 2026-07-11 15:02:04 +08:00
root ccc05fda19 优化任务通知格式并记录完成时间 2026-07-11 14:52:46 +08:00
root 72f8ab597a 移除 G1 垃圾回收器配置 2026-07-11 14:29:47 +08:00
root 5dd09e003b 修复未知节点任务状态导致的空指针 2026-07-11 13:31:14 +08:00
lion 7ca12d7999 新增ai总结大纲 2026-06-06 18:26:51 +08:00
lion 2f10d7a868 refactor: 规范化代码 — 线程安全、日志、资源泄漏、敏感配置、命名、Lombok
- 线程安全: ArrayList→CopyOnWriteArrayList, HashMap→ConcurrentHashMap+synchronized→computeIfAbsent
  - 日志规范: 22处println→SLF4J, 20处printStackTrace→log.error, 空catch加入log.warn
  - 资源泄漏: CloseableHttpClient提取为static单例复用TCP连接
  - 敏感配置: Bot Token/Cookie/IP/订阅URL移至application.yaml+@Value注入
  - 命名规范: *ServiceImpl→*Service去除误导Impl后缀
  - API设计: Response.getResult()→getData(),旧方法标@Deprecated兼容
  - 构造器注入: 全部替换为Lombok @RequiredArgsConstructor,init逻辑→@PostConstruct
  - 依赖: Lombok 1.18.30→1.18.40支持JDK25,新增maven-compiler-plugin注解处理器路径
2026-06-06 18:26:19 +08:00
68 changed files with 4400 additions and 742 deletions
+43
View File
@@ -0,0 +1,43 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
# 本地运行与测试产生的日志
run.out
*.log
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
/.idea/encodings.xml
+64
View File
@@ -0,0 +1,64 @@
# 2026-09-08 审查修复与编译交接
本批源码已在本机按问题分别提交,尚未推送、后端编译或部署。编译机恢复后,需要先取得下列仓库的提交,再按现有发布手册构建、验证与上线。此次没有调整鉴权或数据库表结构。
## 提交清单
| 仓库 | 提交 | 内容 |
| --- | --- | --- |
| storageNode | cbfd634 | 压缩临时文件、ZIP 完整性校验、失败保留源目录并恢复可重试状态 |
| lionwebsite-backend | f65c5ad | 下发节点前持久化任务,超时保留记录,原子累加用量 |
| lionwebsite-backend | 7f823b6 | 修复 Range 解析、读取长度、空文件和越界处理 |
| lionwebsite-backend | 1e6e3a1 | 上游订阅下载移出全局锁,设置超时,拒绝旧配置/旧请求的刷新结果 |
| lionwebsite-backend | b15eeaf | 复用连接线程组,登记等待对象后再发送,统一超时/失败/关闭清理 |
| lionwebsite-backend | 60facae | 关闭图片和 TAR 文件流,合并同图请求,完成后发布缓存,检查转换结果 |
| lionwebsite-frontend-desktop | 287dc09 | 图片失败继续加载,单图重试,忽略旧页面图片事件 |
| lionwebsite-frontend-desktop | b0f832c | 退避重连、连接状态提示、唤醒后重连、重连与提交后的全量刷新 |
| lionwebsite-frontend-desktop | d0eb2f0 | 链接 GID 类型匹配,补充任务提交回归测试 |
| lionwebsite-backend | a9ba631 | 连接反复断开时重新安排监听,验证缓存失败后可重试 |
| lionwebsite-backend | d3b18f9 | 修复图片 Key 缓存递归更新与 null 写入,异常时关闭 HTTP 响应 |
各仓库分别保留了原有历史。前端原先未提交的 `index.html`、`vite.config.js`、`llm_readme.md` 以及未跟踪的 `dist/`、`node_modules/` 未纳入本批提交。
## 已验证与待验证
已完成:
- 前端 `node --test tests/*.test.mjs`:9 项通过。
- 前端生产构建:成功,产物输出到 `/tmp/lionwebsite-review-build`,没有覆盖现有 `dist/` 或线上资源。构建提示主包体积超过 500 kB,本批未做依赖拆包。
- 各次提交的 `git diff --check` 与最终源码调用链检查。
尚未执行:
- 主站、存储节点的 Java 编译及 JUnit 测试。
- GraalVM 原生构建、真实节点断连、真实上游下载和生产端到端验证。
**以下 Java 测试仅在编译机的独立工作副本执行,不在生产主站或存储机执行。两个仓库依次运行 `mvn test`,通过后再按现有部署手册构建原生程序。** 新增测试均使用临时目录、Mock 或内存通道,不需要生产数据库和真实上游。
主站新增测试:`GallerySubmissionTest`、`FileDownloadTest`、`SubscriptionRefreshServiceTest`、`RemoteServiceTest`、`PersonalArchiveTest`、`ImageFileCacheTest`、`GalleryKeyCacheTest`。
存储节点新增测试:`DownloadCheckServiceTest`。两个仓库已有的订阅快照测试也应一起运行。
## 行为与接口说明
HTTP 路由、参数名和节点消息格式保持兼容,任务状态继续使用 `已提交`、`下载中`、`等待压缩`、`压缩中`、`下载完成`。
- `POST /GalleryManage`:先保存任务再下发。节点未确认时返回业务 failure,但任务记录仍在,前端刷新后可以重试;不会把已经收到的完成状态写回已提交。新增任务时即计入本周用量,重试不重复计量。
- 存储节点压缩失败:保留源文件,回到等待压缩状态,30 秒后可自动重试;手动重试会取消等待。仅完整校验通过的 ZIP 才视为完成。
- 使用 `FileDownload.export` 的文件接口:支持单个普通范围、后缀范围和开放结尾范围;非法或不可满足范围返回 416,`Content-Range: bytes */<size>`;多个范围回退为完整 200 响应;HEAD 不输出正文;普通完整响应不再带 Content-Range。
- 图片缓存:同一图片的并发请求共享下载,临时文件关闭且转换成功后才发布。下载连接超时 5 秒、读取超时 15 秒,转换超时 60 秒。图片缺失或下载失败仍返回 404,中断可返回 503。
- 订阅刷新:连接和连接池等待超时 5 秒,读取超时 15 秒;网络操作不占用订阅状态锁。上游 Key、过滤选项或启用状态改变后会使旧缓存失效,旧刷新结果不能重新发布。仅名称修改且刷新失败时保留原有缓存。
- WebSocket:连接失败按 1/2/4/8/16/30 秒退避,建立连接超时 10 秒;重连后重新读取任务列表和用量。页面恢复可见或网络恢复时重新连接。
## 上线验证重点
按现有发布手册安排存储节点、主站和前端上线,避开正在运行的生产任务。源码修复本身不代表生产已经更新。
1. 新建任务,模拟节点已有归档或立即返回状态,确认数据库与页面均保留完成状态且没有重复行。
2. 使用临时测试目录制造一次压缩失败,确认源文件保留,恢复目录可写后能重试,损坏 ZIP 不会标记完成。
3. 下载完整文件和小范围/尾部范围,确认响应字节数;验证空文件和越界请求。
4. 在测试上游阻塞刷新时修改其他账号/读取快照,确认操作不被网络等待阻塞;旧请求结束后不能覆盖新配置。
5. 阅读器人为令一张图片失败,确认后续图片继续加载、单图重试有效。
6. 浏览器断网、恢复网络、切后台再返回,确认连接提示和进度能够恢复。
根目录 `/home/lionwebsite/API_DOCUMENTATION.md` 已追加待发布说明;本文件是这部分接口行为变更的仓库内交接记录。
+166
View File
@@ -0,0 +1,166 @@
================================================================================
LionWebsite 项目总结
================================================================================
一、项目概况
──────────────────────────────────────────────────────────────────────────────
名称: LionWebsite
技术栈: Spring Boot 4.1.1, Java 21 字节码(生产 JDK 25), Maven, SQLite, MyBatis 4, Netty 4.2
定位: 个人/私有 Web 应用,兼具 E-Hentai 画廊下载管理、个人文件服务、
代理订阅管理等功能。
运行端口: 8888
数据库: 双 SQLite 数据库 — LionWebsite.db (主库) + cache.db (缓存库)
构建目标: 当前以 JVM/JAR 运行;保留 GraalVM Native Image 配置但尚未在 JDK 25 完成原生验证
二、项目结构
──────────────────────────────────────────────────────────────────────────────
src/main/java/com/lion/lionwebsite/
├── LionWebsiteApplication.java 主启动类 (@EnableScheduling, 双数据源 MapperScan)
│
├── Configuration/
│ ├── SqlConfiguration.java 双数据源 (main + cache) SQLite 配置
│ ├── MyBatisNativeConfiguration.java GraalVM Native 适配 (AOT hints)
│ ├── WebsocketConfiguration.java WebSocket 注册 (/ws/)
│ ├── InterceptorConfiguration.java 拦截器链注册
│ ├── CorsConfig.java CORS 全开放
│ └── CustomBean.java Telegram Bot Bean + Native 反射注册
│
├── Controller/
│ ├── GalleryManageController.java 画廊任务 CRUD、收藏、图片在线缓存 /GalleryManage
│ ├── QueryController.java E-Hentai 搜索代理 /query
│ ├── PublicController.java 根路由、IP、订阅、文件分享、验证 /、/ip、/sub/、/GetFile/、/validate
│ ├── PersonalController.java 个人文件管理 /personal/
│ ├── SubController.java 订阅绑定管理 /personal/subBind/
│ └── UserController.java 用户管理 /personal/user
│
├── Service/
│ ├── GalleryManageService.java 核心画廊管理 (任务创建/查询/删除/图片缓存/在线图片)
│ ├── RemoteService.java Netty TCP 客户端连接远程存储节点 (5.255.110.45:26321+)
│ ├── WebSocketService.java WebSocket 推送下载进度给前端
│ ├── PushService.java Telegram Bot 通知 (admin 告警)
│ ├── QueryService.java E-Hentai 搜索 + 缩略图代理缓存 (转 AVIF)
│ ├── LocalServiceImpl.java 定时任务 (连接检测/额度重置/Cookie验证/订阅更新/缩略图清理)
│ ├── PublicServiceImpl.java IP 记录、分享码文件获取、授权码修改
│ ├── PersonalServiceImpl.java 文件管理 (浏览/上传/下载/分享/压缩/删除/TAR打包)
│ ├── SubService.java 代理订阅绑定/重置/查询/更新记录
│ ├── CollectService.java 画廊收藏/取消收藏
│ └── UserServiceImpl.java 用户 CRUD + 授权码管理
│
├── Dao/
│ ├── normal/ 主库 Mapper
│ │ ├── GalleryMapper.java gallery 表 CRUD
│ │ ├── UserMapper.java user 表 CRUD
│ │ ├── CollectMapper.java collect 表 CRUD
│ │ ├── ShareFileMapper.java ShareFile 表 CRUD
│ │ ├── CustomConfigurationMapper.java 配置键值对读写
│ │ └── SubMapper.java 订阅绑定 & 更新记录
│ └── cache/ 缓存库 Mapper
│ └── ImageCacheMapper.java 图片 key 缓存 (gidToKey + ImageKeyCache)
│
├── Domain/ 实体类 (Lombok @Data)
│ ├── Gallery.java 画廊 (gid, name, link, pages, status, resolution, ...)
│ ├── GalleryForQuery.java 搜索结果的画廊精简信息
│ ├── GalleryTask.java 下载任务状态 (下载中/下载完成/压缩中/压缩完成)
│ ├── User.java 用户 (id, AuthCode, username, isEnable)
│ ├── GidToKey.java 画廊 GID → MPV Key 映射
│ ├── ImageKeyCache.java 图片 key 缓存 (gid, page, imgkey)
│ ├── CustomConfiguration.java 配置键常量定义
│ ├── ShareFile.java 文件分享 (ShareCode, FilePath, ExpireTime)
│ ├── SubBind.java 订阅绑定 (key, user)
│ ├── SubUpdateRecord.java 订阅更新记录 (ip, UA, time, location)
│ └── PageNameCache.java 页面名缓存 (gid, page, pageName)
│
├── Message/ 自定义 TCP 消息协议 (Netty)
│ ├── AbstractMessage.java 消息基类 (定义了 7 种消息类型常量)
│ ├── MessageCodec.java Netty ByteToMessageCodec 编解码器
│ ├── DownloadPostMessage.java 下发下载任务
│ ├── DownloadStatusMessage.java 下载进度状态上报
│ ├── ResponseMessage.java 通用响应
│ ├── DeleteGalleryMessage.java 删除画廊指令
│ ├── IdentityMessage.java 身份认证
│ ├── MaintainMessage.java 维护/心跳消息
│ └── AvailableCheckMessage.java 可用性检测
│
├── Interceptor/
│ ├── TaskHandlerInterceptor.java 验证 AuthCode 是否有效 (用于 /GalleryManage, /validate)
│ ├── PersonalInterceptor.java 限制 /personal/**, /remote/** 仅 AuthCode="alone"
│ └── HumanInterceptor.java 拦截无 User-Agent 的请求 (机器人防护)
│
├── Filter/
│ ├── AdaptorFilter.java UA 检测: 移动端重定向到 /mobile, 桌面端放行; 日志记录
│ └── AccessFilter.java 更新用户最后访问时间 (/validate 接口)
│
├── Util/
│ ├── Response.java 通用 JSON 响应封装 ({result, data})
│ ├── CustomUtil.java 工具类 (文件大小格式化/时间/空闲端口/404)
│ ├── GalleryUtil.java E-Hentai 网页解析/图片下载/MPV key 管理/图片格式转换
│ └── FileDownload.java 支持断点续传的文件下载工具 (Range)
│
├── Error/
│ └── ErrorCode.java 错误码常量 (IO_ERROR=1, FILE_NOT_FOUND=2, COMPRESS_ERROR=3)
│
└── Exception/
└── ResolutionNotMatchException.java 分辨率不匹配异常
三、核心功能模块
──────────────────────────────────────────────────────────────────────────────
1. E-Hentai 画廊下载管理
- 用户通过 AuthCode 提交 E-Hentai 画廊链接,指定目标分辨率
- GalleryUtil 解析页面 (Jsoup) 获取: 名称/语言/页数/文件大小/可选分辨率
- 通过 Netty TCP 将下载任务发往远程存储节点 (RemoteService)
- RemoteService 维护与存储节点的长连接 (自动重连+端口探测)
- 存储节点实时回传下载进度 (DownloadStatusMessage),通过 WebSocket 推送给前端
- 支持图片在线预览: 缓存 MPV key → 按需下载单页 → 转为 AVIF 格式
- 画廊收藏/取消收藏
2. 个人文件服务
- 文件浏览器: 按路径浏览文件/文件夹,显示大小、分享状态
- 上传: MultipartFile 上传到指定路径
- 下载: 支持 HTTP Range 断点续传
- 分享: 生成 8 位随机分享码,设置过期时间,可延长/取消
- 压缩: 异步 TAR 打包目录
- 删除文件/文件夹
3. E-Hentai 搜索代理
- 代理搜索 exhentai.org,返回格式化结果 (含缩略图 URL)
- 缩略图代理: 下载 → ImageMagick 转 AVIF → 本地缓存 → 返回
4. 代理订阅管理
- 定时从外部链接拉取 V2Ray/Clash 订阅配置
- 过滤高倍率节点 (流量倍率 > 2)
- 为每个用户生成唯一订阅 Key,记录更新 IP/UA/时间/位置
5. Telegram 通知
- 通过 Telegram Bot 向 admin 推送: 任务提交/完成/失败、存储节点上下线、
Cookie 过期、订阅异常等
四、定时任务 (@Scheduled)
──────────────────────────────────────────────────────────────────────────────
- 每 30 分钟: 检测存储节点连接,断开则自动重连
- 每周一 4:00: 重置每周下载额度
- 每天 0:00: 验证 E-Hentai Cookie 有效性
- 每天 4:00: 清理过期分享码
- 每 24 小时: 更新代理订阅配置文件
- 每周一 4:00: 清理缩略图缓存 (保留最近 10000 个)
五、安全机制
──────────────────────────────────────────────────────────────────────────────
- 所有管理接口需 AuthCode 参数 (TaskHandlerInterceptor 校验)
- /personal 和 /remote 路径限 AuthCode="alone" 用户
- HumanInterceptor 拒绝无 User-Agent 请求
- AdaptorFilter 记录所有请求日志 (IP/路径/UA/时间)
- 分享码失败黑名单 (连续失败则屏蔽)
六、依赖
──────────────────────────────────────────────────────────────────────────────
spring-boot-starter-webmvc, spring-boot-starter-websocket, mybatis-spring-boot-starter 4.1
jsoup (HTML 解析), hutool-all (工具集), sqlite-jdbc (数据库)
httpclient5 (HTTP 请求), commons-compress (TAR 打包)
commons-io, commons-lang3, netty-all (TCP 通信)
java-telegram-bot-api (Telegram Bot), graalvm native-maven-plugin (AOT)
================================================================================
End of Summary
================================================================================
+46 -21
View File
@@ -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.30</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.11.0</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>
@@ -110,14 +103,46 @@
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
</path>
</annotationProcessorPaths>
</configuration>
</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>
<arg>--gc=G1</arg>
<arg>-H:+ReportExceptionStackTraces</arg> <arg>-H:+ReportExceptionStackTraces</arg>
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.sqlite.util.ProcessRunner</arg> <arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.sqlite.util.ProcessRunner</arg>
</buildArgs> </buildArgs>
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
db_path="${1:-LionWebsite.db}"
legacy_key="${2:-}"
if [[ -z "$legacy_key" || ! "$legacy_key" =~ ^[A-Za-z0-9._~-]+$ ]]; then
echo "用法: $0 <LionWebsite.db> <现有共享订阅 upstream key>" >&2
exit 2
fi
if [[ ! -f "$db_path" ]]; then
echo "数据库不存在: $db_path" >&2
exit 2
fi
sqlite3 "$db_path" -cmd ".parameter init" -cmd ".parameter set :legacy_key '$legacy_key'" \
< "$(dirname "$0")/migrate_subscription_accounts.sql"
echo "订阅子账号迁移完成: $db_path"
+38
View File
@@ -0,0 +1,38 @@
BEGIN;
CREATE TABLE IF NOT EXISTS subscription_account (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
upstream_key VARCHAR(255) NOT NULL UNIQUE,
filter_high_multiplier INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
last_success_at DATETIME,
last_error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO subscription_account (name, upstream_key, filter_high_multiplier, enabled)
SELECT '旧共享订阅', :legacy_key, 1, 1
WHERE NOT EXISTS (SELECT 1 FROM subscription_account WHERE name = '旧共享订阅')
AND NOT EXISTS (SELECT 1 FROM subscription_account WHERE upstream_key = :legacy_key);
CREATE TABLE sub_bind_new (
key VARCHAR(255) NOT NULL PRIMARY KEY,
user VARCHAR(255) NOT NULL UNIQUE,
subscription_account_id INTEGER NOT NULL,
FOREIGN KEY (subscription_account_id) REFERENCES subscription_account(id)
);
INSERT INTO sub_bind_new (key, user, subscription_account_id)
SELECT sb.key, sb.user, sa.id
FROM sub_bind sb
JOIN (SELECT id FROM subscription_account
WHERE name = '旧共享订阅' OR upstream_key = :legacy_key
ORDER BY id LIMIT 1) sa;
DROP TABLE sub_bind;
ALTER TABLE sub_bind_new RENAME TO sub_bind;
CREATE INDEX idx_sub_bind_account ON sub_bind(subscription_account_id);
COMMIT;
@@ -2,12 +2,15 @@ package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Domain.*; import com.lion.lionwebsite.Domain.*;
import com.lion.lionwebsite.Message.*; import com.lion.lionwebsite.Message.*;
import com.lion.lionwebsite.Util.GalleryUtil;
import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.model.*; import com.pengrad.telegrambot.model.*;
import com.pengrad.telegrambot.model.User; import com.pengrad.telegrambot.model.User;
import com.pengrad.telegrambot.response.SendResponse; import com.pengrad.telegrambot.response.SendResponse;
import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariConfig;
import jakarta.annotation.PostConstruct;
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -18,11 +21,24 @@ import org.springframework.context.annotation.Configuration;
SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class, SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class,
Chat.class, MessageEntity.class, Chat.class, MessageEntity.class,
AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class, AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class,
IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, AvailableCheckMessage.class, LinkPreviewOptions.class}) IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, AvailableCheckMessage.class,
SubscriptionSnapshotMessage.class, SubscriptionSnapshotPayload.class, SubscriptionAccountSnapshot.class,
SubscriptionBindingSnapshot.class, LinkPreviewOptions.class})
public class CustomBean { public class CustomBean {
@Value("${bot.token:5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA}")
private String botToken;
@Value("${gallery.cookie:ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1}")
private String ehentaiCookie;
@PostConstruct
void initGalleryCookie() {
GalleryUtil.setEhentaiCookie(ehentaiCookie);
}
@Bean @Bean
public TelegramBot getTelegramBot(){ public TelegramBot getTelegramBot(){
return new TelegramBot("5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"); return new TelegramBot(botToken);
} }
} }
@@ -3,6 +3,7 @@ package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Interceptor.HumanInterceptor; import com.lion.lionwebsite.Interceptor.HumanInterceptor;
import com.lion.lionwebsite.Interceptor.PersonalInterceptor; import com.lion.lionwebsite.Interceptor.PersonalInterceptor;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
@@ -10,17 +11,14 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
@RequiredArgsConstructor
public class InterceptorConfiguration implements WebMvcConfigurer { public class InterceptorConfiguration implements WebMvcConfigurer {
TaskHandlerInterceptor taskHandlerInterceptor; final TaskHandlerInterceptor taskHandlerInterceptor;
public InterceptorConfiguration(TaskHandlerInterceptor taskHandlerInterceptor) {
this.taskHandlerInterceptor = taskHandlerInterceptor;
}
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getPersonalInterceptor()).addPathPatterns("/personal/**", "/remote/**"); registry.addInterceptor(getPersonalInterceptor()).addPathPatterns("/personal/**", "/remote/**");
registry.addInterceptor(taskHandlerInterceptor).addPathPatterns("/GalleryManage", "/validate"); registry.addInterceptor(taskHandlerInterceptor).addPathPatterns("/GalleryManage", "/GalleryManage/**", "/validate");
registry.addInterceptor(getHumanInterceptor()).addPathPatterns("/", "/mobile"); registry.addInterceptor(getHumanInterceptor()).addPathPatterns("/", "/mobile");
} }
@@ -1,6 +1,7 @@
package com.lion.lionwebsite.Configuration; package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Service.WebSocketService; import com.lion.lionwebsite.Service.WebSocketService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
@@ -8,12 +9,10 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
@Configuration @Configuration
@EnableWebSocket @EnableWebSocket
@RequiredArgsConstructor
public class WebsocketConfiguration implements WebSocketConfigurer { public class WebsocketConfiguration implements WebSocketConfigurer {
WebSocketService webSocketService; final WebSocketService webSocketService;
public WebsocketConfiguration(WebSocketService webSocketService) {
this.webSocketService = webSocketService;
}
@Override @Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
@@ -3,10 +3,11 @@ package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.CollectService; import com.lion.lionwebsite.Service.CollectService;
import com.lion.lionwebsite.Service.GalleryManageService; import com.lion.lionwebsite.Service.GalleryManageService;
import com.lion.lionwebsite.Service.RemoteService; import com.lion.lionwebsite.Service.RemoteService;
import com.lion.lionwebsite.Service.UserServiceImpl; import com.lion.lionwebsite.Service.UserService;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
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.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,21 +16,15 @@ import java.util.concurrent.Callable;
@RestController @RestController
@RequestMapping("/GalleryManage") @RequestMapping("/GalleryManage")
@Slf4j @Slf4j
@RequiredArgsConstructor
public class GalleryManageController { public class GalleryManageController {
GalleryManageService galleryManageService; final GalleryManageService galleryManageService;
CollectService collectService; final CollectService collectService;
UserServiceImpl userService; final UserService userService;
RemoteService remoteService; final RemoteService remoteService;
public GalleryManageController(GalleryManageService galleryManageService, CollectService collectService, UserServiceImpl userService, RemoteService remoteService) {
this.galleryManageService = galleryManageService;
this.collectService = collectService;
this.userService = userService;
this.remoteService = remoteService;
}
@PostMapping("") @PostMapping("")
public String create_task(String link, String targetResolution, String AuthCode){ public String create_task(String link, String targetResolution, String AuthCode){
@@ -102,4 +97,11 @@ public class GalleryManageController {
public String resetUndone(){ public String resetUndone(){
return galleryManageService.resetUndone(); return galleryManageService.resetUndone();
} }
@PostMapping("/retry")
public String retryGallery(Integer gid){
if(gid == null)
return Response._failure("参数不全");
return galleryManageService.retryGallery(gid);
}
} }
@@ -1,11 +1,11 @@
package com.lion.lionwebsite.Controller; package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.LocalServiceImpl; import com.lion.lionwebsite.Service.LocalService;
import com.lion.lionwebsite.Service.PersonalServiceImpl; 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.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -18,17 +18,13 @@ import java.io.IOException;
@RestController @RestController
@Slf4j @Slf4j
@RequiredArgsConstructor
@RequestMapping("/personal") @RequestMapping("/personal")
public class PersonalController { public class PersonalController {
PersonalServiceImpl personalService; final PersonalService personalService;
LocalServiceImpl localService; final LocalService localService;
public PersonalController(PersonalServiceImpl personalService, LocalServiceImpl localService) {
this.personalService = personalService;
this.localService = localService;
}
@GetMapping("/") @GetMapping("/")
public void index(HttpServletResponse resp) throws IOException { public void index(HttpServletResponse resp) throws IOException {
@@ -91,7 +87,7 @@ public class PersonalController {
} }
@GetMapping("/ip") @GetMapping("/ip")
public String ip() throws JsonProcessingException { public String ip() {
return personalService.getIp(); return personalService.getIp();
} }
@@ -2,13 +2,14 @@ package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Domain.User; import com.lion.lionwebsite.Domain.User;
import com.lion.lionwebsite.Service.PublicServiceImpl; import com.lion.lionwebsite.Service.PublicService;
import com.lion.lionwebsite.Service.QueryService; import com.lion.lionwebsite.Service.QueryService;
import com.lion.lionwebsite.Service.RemoteService; import com.lion.lionwebsite.Service.RemoteService;
import com.lion.lionwebsite.Service.SubService; import com.lion.lionwebsite.Service.SubService;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
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.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -19,24 +20,18 @@ import java.util.List;
@RestController @RestController
@Slf4j @Slf4j
@RequiredArgsConstructor
public class PublicController { public class PublicController {
final List<String> black_share_codes = new LinkedList<>(); final List<String> black_share_codes = new LinkedList<>();
PublicServiceImpl publicService; final PublicService publicService;
RemoteService remoteService; final RemoteService remoteService;
SubService subService; final SubService subService;
QueryService queryService; final QueryService queryService;
public PublicController(PublicServiceImpl publicService, RemoteService remoteService, SubService subService, QueryService queryService) {
this.publicService = publicService;
this.remoteService = remoteService;
this.subService = subService;
this.queryService = queryService;
}
@GetMapping("/") @GetMapping("/")
public void index(HttpServletResponse resp) throws IOException { public void index(HttpServletResponse resp) throws IOException {
@@ -1,6 +1,7 @@
package com.lion.lionwebsite.Controller; package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.QueryService; import com.lion.lionwebsite.Service.QueryService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -8,13 +9,10 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/query") @RequestMapping("/query")
@RequiredArgsConstructor
public class QueryController { public class QueryController {
QueryService queryService; final QueryService queryService;
public QueryController(QueryService queryService) {
this.queryService = queryService;
}
@GetMapping("") @GetMapping("")
public String query(String keyword, String prev, String next){ public String query(String keyword, String prev, String next){
@@ -1,20 +1,18 @@
package com.lion.lionwebsite.Controller; package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.SubService; import com.lion.lionwebsite.Service.SubService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/personal/subBind/") @RequestMapping("/personal/subBind/")
@RequiredArgsConstructor
public class SubController { public class SubController {
SubService subService; final SubService subService;
public SubController(SubService subService) {
this.subService = subService;
}
@PostMapping("") @PostMapping("")
public String addSubBind(String user){ public String addSubBind(String user, Integer accountId){
return subService.insertSubBind(user); return subService.insertSubBind(user, accountId);
} }
@PutMapping("") @PutMapping("")
@@ -36,4 +34,38 @@ public class SubController {
public String deleteSubBind(String user){ public String deleteSubBind(String user){
return subService.deleteSubBind(user); return subService.deleteSubBind(user);
} }
@GetMapping("accounts")
public String getAccounts(){
return subService.listSubscriptionAccounts();
}
@PostMapping("accounts")
public String addAccount(String name, String upstreamKey,
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
@RequestParam(defaultValue = "true") boolean enabled){
return subService.insertSubscriptionAccount(name, upstreamKey, filterHighMultiplier, enabled);
}
@PutMapping("accounts/{id}")
public String updateAccount(@PathVariable Integer id, String name, String upstreamKey,
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
@RequestParam(defaultValue = "true") boolean enabled){
return subService.updateSubscriptionAccount(id, name, upstreamKey, filterHighMultiplier, enabled);
}
@PostMapping("accounts/{id}/refresh")
public String refreshAccount(@PathVariable Integer id){
return subService.refreshSubscriptionAccount(id);
}
@DeleteMapping("accounts/{id}")
public String deleteAccount(@PathVariable Integer id){
return subService.deleteSubscriptionAccount(id);
}
@PutMapping("{user}/account")
public String rebind(@PathVariable String user, Integer accountId){
return subService.rebind(user, accountId);
}
} }
@@ -1,18 +1,15 @@
package com.lion.lionwebsite.Controller; package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.UserServiceImpl; import com.lion.lionwebsite.Service.UserService;
import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/personal/user") @RequestMapping("/personal/user")
@RequiredArgsConstructor
public class UserController { public class UserController {
UserServiceImpl userService; final UserService userService;
public UserController(UserServiceImpl userService) {
this.userService = userService;
}
@GetMapping("") @GetMapping("")
public String getAllUser(){ public String getAllUser(){
@@ -16,6 +16,9 @@ public interface CustomConfigurationMapper {
// @Delete("delete from customConfiguration where parameter=#{parameter}") // @Delete("delete from customConfiguration where parameter=#{parameter}")
// void deleteConfiguration(CustomConfiguration configuration); // void deleteConfiguration(CustomConfiguration configuration);
@Update("update customConfiguration set value=cast(value as integer)+#{amount} where parameter=#{parameter}")
void incrementConfiguration(@Param("parameter") String parameter, @Param("amount") long amount);
@Select("select * from customConfiguration where parameter=#{parameter}") @Select("select * from customConfiguration where parameter=#{parameter}")
CustomConfiguration selectConfiguration(String parameter); CustomConfiguration selectConfiguration(String parameter);
} }
@@ -25,7 +25,7 @@ public interface GalleryMapper {
@Select("select * from gallery where downloader=#{downloader}") @Select("select * from gallery where downloader=#{downloader}")
Gallery[] selectGalleryByDownloader(int downloader); Gallery[] selectGalleryByDownloader(int downloader);
@Select("select * from gallery where status in ('已提交', '下载中', '压缩中')") @Select("select * from gallery where status in ('已提交', '下载中', '等待压缩', '压缩中')")
Gallery[] selectUnDoneGalleries(); Gallery[] selectUnDoneGalleries();
@Select("select * from gallery order by createTime") @Select("select * from gallery order by createTime")
@@ -2,29 +2,61 @@ package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.SubBind; import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord; import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.ArrayList; import java.util.ArrayList;
@Mapper @Mapper
public interface SubMapper { public interface SubMapper {
@Insert("insert into sub_bind values (#{key}, #{user})") @Insert("insert into subscription_account (name, upstream_key, filter_high_multiplier, enabled, created_at, updated_at) values (#{name}, #{upstreamKey}, #{filterHighMultiplier}, #{enabled}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertSubscriptionAccount(SubscriptionAccount account);
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, (select count(*) from sub_bind sb where sb.subscription_account_id = sa.id) as boundUserCount from subscription_account sa order by id")
ArrayList<SubscriptionAccount> selectAllSubscriptionAccounts();
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, (select count(*) from sub_bind sb where sb.subscription_account_id = sa.id) as boundUserCount from subscription_account sa where id=#{id}")
SubscriptionAccount selectSubscriptionAccount(Integer id);
@Select("select count(*) from subscription_account where name=#{name}")
int countSubscriptionAccountName(String name);
@Select("select count(*) from subscription_account where upstream_key=#{upstreamKey}")
int countSubscriptionAccountKey(String upstreamKey);
@Update("update subscription_account set name=#{name}, upstream_key=#{upstreamKey}, filter_high_multiplier=#{filterHighMultiplier}, enabled=#{enabled}, updated_at=CURRENT_TIMESTAMP, last_error=null where id=#{id}")
void updateSubscriptionAccount(SubscriptionAccount account);
@Update("update subscription_account set last_success_at=CURRENT_TIMESTAMP, last_error=null, updated_at=CURRENT_TIMESTAMP where id=#{id}")
void markSubscriptionRefreshSuccess(Integer id);
@Update("update subscription_account set last_error=#{error}, updated_at=CURRENT_TIMESTAMP where id=#{id}")
void markSubscriptionRefreshFailure(@Param("id") Integer id, @Param("error") String error);
@Delete("delete from subscription_account where id=#{id}")
void deleteSubscriptionAccount(Integer id);
@Insert("insert into sub_bind (key, user, subscription_account_id) values (#{key}, #{user}, #{subscriptionAccountId})")
void insertSubBind(SubBind subBind); void insertSubBind(SubBind subBind);
@Select("select * from sub_bind") @Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id order by sb.user")
ArrayList<SubBind> selectAllSubBind(); ArrayList<SubBind> selectAllSubBind();
@Select("select * from sub_bind where key=#{key}") @Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id where sb.key=#{key}")
SubBind selectSubBind(String key); SubBind selectSubBind(String key);
@Select("select count(key) from sub_bind where key=#{key}") @Select("select count(key) from sub_bind where key=#{key}")
boolean selectSubBindExist(String key); boolean selectSubBindExist(String key);
@Select("select count(user) from sub_update_record where user=#{user}") @Select("select count(*) from sub_bind where user=#{user}")
Integer selectUpdateRecordCount(String user); int countSubBindByUser(String user);
@Select("select min(id) from sub_update_record where user=#{user}") @Update("update sub_bind set subscription_account_id=#{accountId} where user=#{user}")
Integer selectMinUpdateRecordId(String user); int updateSubBindAccount(@Param("user") String user, @Param("accountId") Integer accountId);
@Update("update sub_bind set key=#{key} where user=#{user}")
int updateSubBindKey(@Param("user") String user, @Param("key") String key);
@Delete("delete from sub_bind where user=#{user}") @Delete("delete from sub_bind where user=#{user}")
void deleteSubBind(String user); void deleteSubBind(String user);
@@ -38,6 +70,12 @@ public interface SubMapper {
@Delete("delete from sub_update_record where user=#{user}") @Delete("delete from sub_update_record where user=#{user}")
void deleteSubUpdateRecord(String user); void deleteSubUpdateRecord(String user);
@Select("select count(user) from sub_update_record where user=#{user}")
Integer selectUpdateRecordCount(String user);
@Select("select min(id) from sub_update_record where user=#{user}")
Integer selectMinUpdateRecordId(String user);
@Delete("delete from sub_update_record where id=#{id}") @Delete("delete from sub_update_record where id=#{id}")
void deleteSubUpdateRecordById(int id); void deleteSubUpdateRecordById(int id);
} }
@@ -11,6 +11,9 @@ public interface UserMapper {
@Select("select * from User where AuthCode=#{AuthCode}") @Select("select * from User where AuthCode=#{AuthCode}")
User selectUserByAuthCode(String AuthCode); User selectUserByAuthCode(String AuthCode);
@Select("select * from User where username=#{username}")
User selectUserByUsername(String username);
@Select("select AuthCode from User") @Select("select AuthCode from User")
String[] selectAllAuthCode(); String[] selectAllAuthCode();
@@ -10,4 +10,8 @@ import lombok.NoArgsConstructor;
public class SubBind { public class SubBind {
String key; String key;
String user; String user;
Integer subscriptionAccountId;
String subscriptionAccountName;
boolean subscriptionAccountEnabled;
boolean filterHighMultiplier;
} }
@@ -0,0 +1,25 @@
package com.lion.lionwebsite.Domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SubscriptionAccount {
private Integer id;
private String name;
private String upstreamKey;
private boolean filterHighMultiplier;
private boolean enabled;
private Date lastSuccessAt;
private String lastError;
private Date createdAt;
private Date updatedAt;
private Integer boundUserCount;
private String v2Url;
private String clashUrl;
}
@@ -4,17 +4,15 @@ import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.CustomUtil;
import jakarta.servlet.*; import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.annotation.WebFilter;
import lombok.RequiredArgsConstructor;
import java.io.IOException; import java.io.IOException;
@WebFilter(filterName = "AccessFilter", urlPatterns = {"/validate"}) @WebFilter(filterName = "AccessFilter", urlPatterns = {"/validate"})
@RequiredArgsConstructor
public class AccessFilter implements Filter { public class AccessFilter implements Filter {
UserMapper userMapper; final UserMapper userMapper;
public AccessFilter(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override @Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
@@ -4,11 +4,13 @@ import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
import java.util.Calendar; import java.util.Calendar;
@WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"}) @WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"})
@Slf4j
public class AdaptorFilter implements Filter { public class AdaptorFilter implements Filter {
@Override @Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
@@ -27,7 +29,7 @@ public class AdaptorFilter implements Filter {
String now = String.format("%s:%s:%s", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND)); String now = String.format("%s:%s:%s", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND));
//日志 //日志
System.out.printf("%s ip:%s \tpath:%s \tAuthCode:%s ua:%s\n", now, ip, ServletPath, AuthCode, UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent); log.info("{} ip:{} \tpath:{} \tAuthCode:{} ua:{}", now, ip, ServletPath, AuthCode, UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent);
//如果是验证,则直接跳转 //如果是验证,则直接跳转
if(ServletPath.equals("/validate")) if(ServletPath.equals("/validate"))
@@ -1,24 +1,25 @@
package com.lion.lionwebsite.Interceptor; package com.lion.lionwebsite.Interceptor;
import com.lion.lionwebsite.Dao.normal.UserMapper; import com.lion.lionwebsite.Dao.normal.UserMapper;
import jakarta.annotation.Resource; import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
@Component @Component
@RequiredArgsConstructor
public class TaskHandlerInterceptor implements HandlerInterceptor { public class TaskHandlerInterceptor implements HandlerInterceptor {
@Resource final UserMapper userMapper;
UserMapper userMapper;
String[] AuthCodes; String[] AuthCodes;
public TaskHandlerInterceptor(UserMapper userMapper) { @PostConstruct
this.userMapper = userMapper; void init() {
AuthCodes = userMapper.selectAllAuthCode(); AuthCodes = userMapper.selectAllAuthCode();
} }
@@ -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
@@ -19,6 +19,8 @@ public class AbstractMessage {
public static final byte AVAILABLE_CHECK_MESSAGE = 8; public static final byte AVAILABLE_CHECK_MESSAGE = 8;
public static final byte SUBSCRIPTION_SNAPSHOT_MESSAGE = 9;
public byte messageType; public byte messageType;
public int messageId; public int messageId;
@@ -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;
@@ -44,6 +44,7 @@ public class MessageCodec extends ByteToMessageCodec<AbstractMessage> {
case AbstractMessage.IDENTITY_MESSAGE -> objectMapper.readValue(metadata, IdentityMessage.class); case AbstractMessage.IDENTITY_MESSAGE -> objectMapper.readValue(metadata, IdentityMessage.class);
case AbstractMessage.MAINTAIN_MESSAGE -> objectMapper.readValue(metadata, MaintainMessage.class); case AbstractMessage.MAINTAIN_MESSAGE -> objectMapper.readValue(metadata, MaintainMessage.class);
case AbstractMessage.AVAILABLE_CHECK_MESSAGE -> objectMapper.readValue(metadata, AvailableCheckMessage.class); case AbstractMessage.AVAILABLE_CHECK_MESSAGE -> objectMapper.readValue(metadata, AvailableCheckMessage.class);
case AbstractMessage.SUBSCRIPTION_SNAPSHOT_MESSAGE -> objectMapper.readValue(metadata, SubscriptionSnapshotMessage.class);
default -> null; default -> null;
}; };
@@ -0,0 +1,16 @@
package com.lion.lionwebsite.Message;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SubscriptionAccountSnapshot {
private Integer accountId;
private boolean enabled;
private boolean filterHighMultiplier;
private String v2ContentBase64;
private String v2Sha256;
private String clashContentBase64;
private String clashSha256;
}
@@ -0,0 +1,11 @@
package com.lion.lionwebsite.Message;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SubscriptionBindingSnapshot {
private String publicKeySha256;
private Integer accountId;
}
@@ -0,0 +1,21 @@
package com.lion.lionwebsite.Message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@NoArgsConstructor
@ToString(exclude = {"payloadBase64", "signature"})
public class SubscriptionSnapshotMessage extends AbstractMessage {
{
messageType = SUBSCRIPTION_SNAPSHOT_MESSAGE;
}
private int schemaVersion;
private String revision;
private long generatedAt;
private String payloadBase64;
private String payloadSha256;
private String signature;
}
@@ -0,0 +1,15 @@
package com.lion.lionwebsite.Message;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
public class SubscriptionSnapshotPayload {
private int schemaVersion;
private List<SubscriptionAccountSnapshot> accounts = new ArrayList<>();
private List<SubscriptionBindingSnapshot> bindings = new ArrayList<>();
}
@@ -8,11 +8,7 @@ import org.springframework.stereotype.Service;
@Service @Service
@Data @Data
public class CollectService { public class CollectService {
CollectMapper collectMapper; final CollectMapper collectMapper;
public CollectService(CollectMapper collectMapper){
this.collectMapper = collectMapper;
}
public String collectGallery(int gid, int collector){ public String collectGallery(int gid, int collector){
Response response = Response.generateResponse(); Response response = Response.generateResponse();
@@ -7,9 +7,11 @@ import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
import com.lion.lionwebsite.Error.ErrorCode; import com.lion.lionwebsite.Error.ErrorCode;
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.ImageFileCache;
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;
@@ -30,33 +32,21 @@ import static com.lion.lionwebsite.Util.GalleryUtil.*;
public class GalleryManageService { public class GalleryManageService {
String cachePath = "/storage/galleryCache/onlineImages/"; String cachePath = "/storage/galleryCache/onlineImages/";
GalleryMapper galleryMapper; final GalleryMapper galleryMapper;
CollectMapper collectMapper; final CollectMapper collectMapper;
CustomConfigurationMapper configurationMapper; final CustomConfigurationMapper configurationMapper;
UserMapper userMapper; final UserMapper userMapper;
ShareFileMapper shareFileMapper; final ShareFileMapper shareFileMapper;
ImageCacheMapper imageCacheMapper; final ImageCacheMapper imageCacheMapper;
RemoteService remoteService; final RemoteService remoteService;
PushService pushService; final PushService pushService;
public GalleryManageService(GalleryMapper galleryMapper, CollectMapper collectMapper, CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper,
RemoteService remoteService, PushService pushService, ImageCacheMapper imageCacheMapper) {
this.galleryMapper = galleryMapper;
this.collectMapper = collectMapper;
this.configurationMapper = configurationMapper;
this.userMapper = userMapper;
this.shareFileMapper = shareFileMapper;
this.remoteService = remoteService;
this.pushService = pushService;
this.imageCacheMapper = imageCacheMapper;
}
/** /**
* 创建任务 * 创建任务
@@ -70,17 +60,25 @@ public class GalleryManageService {
User user = userMapper.selectUserByAuthCode(AuthCode); User user = userMapper.selectUserByAuthCode(AuthCode);
// return Response._failure("调试中,请勿提交任务"); // return Response._failure("调试中,请勿提交任务");
if (remoteService.isDead()) { if (user == null) {
response.failure("节点挂了,找狮子处理"); response.failure("授权码无效");
pushService.taskCreateReport(user.getUsername(), link, response);
return response.toJSONString(); return response.toJSONString();
} }
int gid;
try { // 段数不足会先抛 ArrayIndexOutOfBoundsException,非数字段抛 NumberFormatException;
gid = Integer.parseInt(link.split("/")[4]); // 只捕后者会让畸形链接穿透为 500(本项目无 @ControllerAdvice)。
} catch (NumberFormatException e) { Integer parsedGid = parseGidFromLink(link);
if (parsedGid == null) {
response.failure("链接错误"); response.failure("链接错误");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), "未知任务", response);
return response.toJSONString();
}
int gid = parsedGid;
String taskName = "任务 [" + gid + "]";
if (remoteService.isDead()) {
response.failure("节点挂了,找狮子处理");
pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} }
Gallery gallery; Gallery gallery;
@@ -88,7 +86,7 @@ public class GalleryManageService {
//判断数据库中是否有这个任务,有则返回状态 //判断数据库中是否有这个任务,有则返回状态
if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) { if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) {
response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务"); response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), gallery.getName(), response);
return response.toJSONString(); return response.toJSONString();
} }
@@ -98,47 +96,59 @@ public class GalleryManageService {
if (gallery == null || !gallery.getStatus().equals("已提交")) { if (gallery == null || !gallery.getStatus().equals("已提交")) {
log.error("创建任务失败: {},", link); log.error("创建任务失败: {},", link);
response.failure("提交任务失败,未知原因,请检查链接是否正常"); response.failure("提交任务失败,未知原因,请检查链接是否正常");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} else { } else {
taskName = gallery.getName();
log.info("创建任务: {} 目标分辨率:{}", link, targetResolution); log.info("创建任务: {} 目标分辨率:{}", link, targetResolution);
// Persist before dispatch: the node sends its current status before its ACK.
gallery.setDownloader(user.getId());
gallery.set_download(true);
galleryMapper.insertGallery(gallery);
configurationMapper.incrementConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, gallery.getFileSize());
if (remoteService.addGalleryToQueue(gallery) != 0) { if (remoteService.addGalleryToQueue(gallery) != 0) {
log.error("传送任务{}失败, 未知原因", gallery.getName()); log.error("传送任务{}失败, 未知原因", gallery.getName());
response.failure("任务传送失败,未知原因,尝试点击重连按钮看看"); response.failure("任务已保存,但节点未确认接收;请刷新任务列表后重试");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} }
} }
} catch (ResolutionNotMatchException e) { } catch (ResolutionNotMatchException e) {
response.failure("提交失败,分辨率不存在"); response.failure("提交失败,分辨率不存在");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} catch (IOException e) { } catch (IOException e) {
log.error(e.getMessage()); log.error(e.getMessage());
response.failure("IO错误,可能是网络波动"); response.failure("IO错误,可能是网络波动");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} }
//处理下载结果,将任务插入数据库并且更新每周用量 // Do not overwrite an immediate node status with the original submitted state.
if (gallery.getStatus().equals("已提交")) { Gallery current = galleryMapper.selectGalleryByGid(gallery.getGid());
response.success(gallery.toString()); response.success((current == null ? gallery : current).toString());
gallery.setDownloader(user.getId()); pushService.taskCreateReport(user.getUsername(), taskName, response);
gallery.set_download(true);
galleryMapper.insertGallery(gallery);
long usedAmount = Long.parseLong(configurationMapper.selectConfiguration(CustomConfiguration.WEEK_USED_AMOUNT).getValue());
usedAmount += gallery.getFileSize();
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, String.valueOf(usedAmount));
} else {
response.failure("提交失败,未知原因");
galleryMapper.deleteGalleryByGid(gallery.getGid());
}
pushService.taskCreateReport(user.getUsername(), link, response);
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 重连结果
@@ -297,22 +307,36 @@ 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.getResult()); log.info(response.getData());
return response.toJSONString(); return response.toJSONString();
} }
@@ -326,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();
@@ -361,74 +397,37 @@ public class GalleryManageService {
return response.toJSONString(); return response.toJSONString();
} }
String[] suffixes = {".avif", ".gif"};
public Callable<?> getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) { public Callable<?> getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) {
//检查文件夹是否存在 Path directory = Path.of(cachePath, gid);
File folder = new File(cachePath + gid); String name = String.valueOf(page);
if(!folder.isDirectory()) Path cached = ImageFileCache.find(directory, name);
folder.mkdirs(); if (cached != null) {
FileDownload.export(request, response, cached.toString());
//检查对应图片是否存在,存在则直接返回
for (String suffix : suffixes) {
if(new File(cachePath + gid + "/" + page + suffix).exists()){
FileDownload.export(request, response, cachePath + gid + "/" + page + suffix);
return null; return null;
} }
}
//检查该图片缓存是否存在
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
if(gidToKey == null)
try {
log.error("未缓存gid:" + gid);
response.sendError(404);
return null;
}catch (IOException ignored){
return null;
}
return () -> { return () -> {
if(response.isCommitted()) { if (response.isCommitted()) return null;
log.info("连接已关闭: gid=" + gid + " page=" + page); try {
return null; Path image = ImageFileCache.get(directory, name, () -> {
} GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
ImageKeyCache imageKey = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page);
String imageUrl = null; if (gidToKey == null || imageKey == null)
//获取该图片key throw new IOException("图片索引不存在");
ImageKeyCache imageKeyCache = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page); for (int attempt = 0; attempt < 2; attempt++) {
if (imageKeyCache == null) { String url = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKey);
CustomUtil.fourZeroFour(response); if (url != null) return url;
return null;
}
//获取图片地址
for (int i = 0; i < 2; i++) {
imageUrl = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKeyCache);
if (imageUrl != null)
break;
GalleryUtil.refreshMpvKey(gidToKey.toUrl()); GalleryUtil.refreshMpvKey(gidToKey.toUrl());
} }
throw new IOException("无法获取图片地址");
if (imageUrl == null) { });
CustomUtil.fourZeroFour(response); FileDownload.export(request, response, image.toString());
log.error("获取图片url失败:gid=" + gid + " page=" + page + " imageKey=" + imageKeyCache.getImgkey()); } catch (InterruptedException e) {
return null; Thread.currentThread().interrupt();
} if (!response.isCommitted()) response.sendError(503);
//下载图片,转格式并返回
String suffix = imageUrl.substring(imageUrl.lastIndexOf("."));
String imagePath = cachePath + gid + "/" + page + suffix;
try {
new URI(imageUrl).toURL().openConnection().getInputStream().transferTo(new FileOutputStream(imagePath));
} catch (Exception e) { } catch (Exception e) {
log.error("下载图片失败:url" + imageUrl); log.warn("获取在线图片失败: gid={} page={} errorType={}", gid, page, e.getClass().getSimpleName());
e.printStackTrace(); if (!response.isCommitted()) response.sendError(404);
CustomUtil.fourZeroFour(response);
return null;
} }
if (!suffix.equals(".gif"))
imagePath = GalleryUtil.convertImg(imagePath, suffix);
FileDownload.export(request, response, imagePath);
return null; return null;
}; };
} }
@@ -453,4 +452,19 @@ public class GalleryManageService {
return response.toJSONString(); return response.toJSONString();
} }
public String retryGallery(int gid){
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
if(gallery == null)
return Response._failure("任务不存在");
if("下载完成".equals(gallery.getStatus()))
return Response._success("下载完成");
if(remoteService.isDead())
return Response._failure("节点不在线,无法重试");
RemoteService.RetryResult retryResult = remoteService.retryGallery(gallery);
if(retryResult.success())
return Response._success(retryResult.message());
return Response._failure(retryResult.message());
}
} }
@@ -8,11 +8,13 @@ import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Util.CustomUtil; 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 org.apache.http.HttpEntity; import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.http.client.methods.HttpGet; 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.HttpEntity;
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;
@@ -31,28 +33,27 @@ import static java.nio.file.FileVisitResult.CONTINUE;
@Service @Service
@Data @Data
public class LocalServiceImpl{ @Slf4j
String DouNaiClash = "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta"; public class LocalService{
@Value("${local.dou-nai-clash:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta}")
String DouNaiClash;
String DouNaiV2ray = "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2"; @Value("${local.dou-nai-v2ray:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2}")
String DouNaiV2ray;
CustomConfigurationMapper configurationMapper; private static final CloseableHttpClient httpClient = HttpClients.createDefault();
ShareFileMapper shareFileMapper; final CustomConfigurationMapper configurationMapper;
GalleryMapper galleryMapper; final ShareFileMapper shareFileMapper;
PushService pushService; final GalleryMapper galleryMapper;
RemoteService remoteService; final PushService pushService;
public LocalServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, GalleryMapper galleryMapper, PushService pushService, RemoteService remoteService) { final RemoteService remoteService;
this.shareFileMapper = shareFileMapper;
this.configurationMapper = configurationMapper; final SubscriptionRefreshService subscriptionRefreshService;
this.galleryMapper = galleryMapper;
this.pushService = pushService;
this.remoteService = remoteService;
}
/** /**
* 检查连接是否有效,如果无效自动重连 * 检查连接是否有效,如果无效自动重连
@@ -62,7 +63,7 @@ public class LocalServiceImpl{
if (remoteService.isDead()){ if (remoteService.isDead()){
remoteService.initChannel(); remoteService.initChannel();
pushService.sendToMe("主动检测连接已断开,自动进行重连"); pushService.sendToMe("主动检测连接已断开,自动进行重连");
System.out.println("主动检测连接已断开,自动进行重连"); log.warn("主动检测连接已断开,自动进行重连");
return; return;
} }
@@ -75,7 +76,7 @@ public class LocalServiceImpl{
default -> "未知错误"; default -> "未知错误";
}; };
pushService.sendToMe("主动检测连接无数据返回,自动进行重连:" + result); pushService.sendToMe("主动检测连接无数据返回,自动进行重连:" + result);
System.out.println("主动检测连接无数据返回,自动进行重连:" + result); log.warn("主动检测连接无数据返回,自动进行重连:{}", result);
} }
} }
@@ -115,6 +116,14 @@ public class LocalServiceImpl{
* 更新订阅链接的实际方法 * 更新订阅链接的实际方法
*/ */
public boolean updateSub(boolean isManual) throws IOException { public boolean updateSub(boolean isManual) throws IOException {
// 手动和定时入口均刷新全部启用子账号;全部成功后更新原有“上次更新时间”。
boolean success = subscriptionRefreshService.refreshAll();
remoteService.requestSubscriptionSync();
if (success)
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME,
CustomUtil.dateTimeFormatter().format(LocalDateTime.now()));
return success;
/*
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter(); DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
CustomConfiguration customConfiguration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME); CustomConfiguration customConfiguration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
@@ -165,10 +174,9 @@ public class LocalServiceImpl{
} }
writer.write(new String(Base64.getEncoder().encode(stringBuilder.toString().getBytes(StandardCharsets.UTF_8)))); writer.write(new String(Base64.getEncoder().encode(stringBuilder.toString().getBytes(StandardCharsets.UTF_8))));
System.out.println("load DouNai v2ray complete"); log.info("load DouNai v2ray complete");
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.error("load DouNai v2ray failure", e);
System.out.println("load DouNai v2ray failure");
} }
//下载豆奶clash订阅 //下载豆奶clash订阅
@@ -197,14 +205,14 @@ public class LocalServiceImpl{
for(String line: clashProcessed) for(String line: clashProcessed)
writer.write(line + "\n"); writer.write(line + "\n");
System.out.println("load DouNai clash complete"); log.info("load DouNai clash complete");
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.error("load DouNai clash failure", e);
System.out.println("load DouNai clash failure");
} }
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME, dateTimeFormatter.format(LocalDateTime.now())); configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME, dateTimeFormatter.format(LocalDateTime.now()));
return true; return true;
*/
} }
/** /**
@@ -214,14 +222,13 @@ public class LocalServiceImpl{
* @throws IOException 网络异常 * @throws IOException 网络异常
*/ */
public static ArrayList<String> Get(String url) throws IOException { public static ArrayList<String> Get(String url) throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse httpResponse; CloseableHttpResponse httpResponse;
HttpGet httpGet = new HttpGet(url); HttpGet httpGet = new HttpGet(url);
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) {
@@ -231,7 +238,6 @@ public class LocalServiceImpl{
temp.add(str); temp.add(str);
} }
httpClient.close();
httpResponse.close(); httpResponse.close();
return temp; return temp;
} }
@@ -290,12 +296,12 @@ public class LocalServiceImpl{
try { try {
Files.delete(file); Files.delete(file);
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.warn("删除缩略图缓存文件失败: {}", file, e);
} }
} }
System.out.println("Deleted " + toDelete.size() + " files"); log.info("Deleted {} files", toDelete.size());
} else { } else {
System.out.println("No files to delete"); log.info("No files to delete");
} }
} }
} }
@@ -12,8 +12,8 @@ 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.annotation.Resource;
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;
@@ -27,6 +27,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -42,35 +43,22 @@ import java.util.concurrent.Executors;
@Service @Service
@Data @Data
@Slf4j @Slf4j
public class PersonalServiceImpl{ public class PersonalService{
@Resource final CustomConfigurationMapper configurationMapper;
CustomConfigurationMapper configurationMapper;
@Resource final UserMapper userMapper;
UserMapper userMapper;
@Resource final ShareFileMapper shareFileMapper;
ShareFileMapper shareFileMapper;
@Resource final TaskHandlerInterceptor taskHandlerInterceptor;
TaskHandlerInterceptor taskHandlerInterceptor;
String StoragePath = "/storage/"; String StoragePath = "/storage/";
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter(); DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
ExecutorService compressThreadPool; ExecutorService compressThreadPool = Executors.newFixedThreadPool(1);
PushService pushService; final PushService pushService;
public PersonalServiceImpl(CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper, TaskHandlerInterceptor taskHandlerInterceptor, PushService pushService){
this.configurationMapper = configurationMapper;
this.userMapper = userMapper;
this.shareFileMapper = shareFileMapper;
this.taskHandlerInterceptor = taskHandlerInterceptor;
this.pushService = pushService;
compressThreadPool = Executors.newFixedThreadPool(1);
}
/** /**
* 获取文件列表,同时带上分享码以及过期时间 * 获取文件列表,同时带上分享码以及过期时间
@@ -150,7 +138,7 @@ public class PersonalServiceImpl{
try{ try{
response.getWriter().print("404 NOT FOUND"); response.getWriter().print("404 NOT FOUND");
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.warn("输出404失败", e);
} }
} }
@@ -182,7 +170,7 @@ public class PersonalServiceImpl{
response.success("上传成功"); response.success("上传成功");
} catch (IOException e) { } catch (IOException e) {
response.failure("上传失败"); response.failure("上传失败");
e.printStackTrace(); log.error("上传失败: {}", fileName, e);
} }
} }
else else
@@ -316,39 +304,51 @@ public class PersonalServiceImpl{
} }
compressThreadPool.submit(() -> { compressThreadPool.submit(() -> {
try(OutputStream bos = new BufferedOutputStream(Files.newOutputStream(Paths.get(finalPath + ".tar***undone"))); Path temporary = Paths.get(finalPath + ".tar***undone");
TarArchiveOutputStream aos = new TarArchiveOutputStream(bos)) { try {
aos.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU); //解除文件名长度限制 writeTar(Paths.get(finalPath), temporary);
Path dirPath = Paths.get(finalPath); // writeTar closes the archive (including its trailer) before publication.
Files.walkFileTree(dirPath, new SimpleFileVisitor<>() { try {
@Override Files.move(temporary, Paths.get(finalPath + ".tar"), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { } catch (AtomicMoveNotSupportedException e) {
TarArchiveEntry entry = new TarArchiveEntry(dir.toFile(), dirPath.relativize(dir).toString()); Files.move(temporary, Paths.get(finalPath + ".tar"), StandardCopyOption.REPLACE_EXISTING);
aos.putArchiveEntry(entry);
aos.closeArchiveEntry();
return super.preVisitDirectory(dir, attrs);
} }
log.info("打包成功: {}.tar", finalPath);
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
TarArchiveEntry entry = new TarArchiveEntry(file.toFile(), dirPath.relativize(file).toString());
aos.putArchiveEntry(entry);
IOUtils.copy(Files.newInputStream(file.toFile().toPath()), aos);
aos.closeArchiveEntry();
return super.visitFile(file, attrs);
}
});
File targetFile = new File(finalPath + ".tar***undone");
log.info("打包成功,重命名:" + targetFile.renameTo(new File(finalPath + ".tar")));
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); log.error("打包失败", e);
log.info("打包失败,删除文件结果:" + new File(finalPath + ".tar***undone").delete()); } finally {
try { Files.deleteIfExists(temporary); }
catch (IOException e) { log.warn("清理打包临时文件失败", e); }
} }
}); });
response.success("加入队列成功"); response.success("加入队列成功");
return response.toJSONString(); return response.toJSONString();
} }
static void writeTar(Path directory, Path output) throws IOException {
try (OutputStream stream = new BufferedOutputStream(Files.newOutputStream(output));
TarArchiveOutputStream archive = new TarArchiveOutputStream(stream)) {
archive.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
Files.walkFileTree(directory, new SimpleFileVisitor<>() {
@Override public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
if (!path.equals(directory)) {
archive.putArchiveEntry(new TarArchiveEntry(path.toFile(), directory.relativize(path).toString()));
archive.closeArchiveEntry();
}
return FileVisitResult.CONTINUE;
}
@Override public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
archive.putArchiveEntry(new TarArchiveEntry(path.toFile(), directory.relativize(path).toString()));
try (InputStream input = Files.newInputStream(path)) {
IOUtils.copy(input, archive);
}
archive.closeArchiveEntry();
return FileVisitResult.CONTINUE;
}
});
}
}
/** /**
* 删除文件 * 删除文件
* @param path 目标路径 * @param path 目标路径
@@ -12,9 +12,9 @@ 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 jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -24,26 +24,16 @@ import java.nio.charset.StandardCharsets;
import java.util.Calendar; import java.util.Calendar;
@Service @Service
public class PublicServiceImpl { @RequiredArgsConstructor
public class PublicService {
@Resource final CustomConfigurationMapper configurationMapper;
CustomConfigurationMapper configurationMapper;
@Resource final ShareFileMapper shareFileMapper;
ShareFileMapper shareFileMapper;
@Resource final UserMapper userMapper;
UserMapper userMapper;
@Resource final TaskHandlerInterceptor taskHandlerInterceptor;
TaskHandlerInterceptor taskHandlerInterceptor;
public PublicServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, UserMapper userMapper, TaskHandlerInterceptor taskHandlerInterceptor) {
this.configurationMapper = configurationMapper;
this.shareFileMapper = shareFileMapper;
this.userMapper = userMapper;
this.taskHandlerInterceptor = taskHandlerInterceptor;
}
/** /**
* 记录家里ip地址 * 记录家里ip地址
@@ -3,36 +3,37 @@ package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Domain.Gallery; import com.lion.lionwebsite.Domain.Gallery;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.UpdatesListener;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.request.SendMessage; import com.pengrad.telegrambot.request.SendMessage;
import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Service @Service
@Slf4j
@RequiredArgsConstructor
public class PushService { public class PushService {
long self = 686839482; long self = 686839482;
@Resource private static final DateTimeFormatter COMPLETION_TIME_FORMATTER =
TelegramBot bot; DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
public PushService(TelegramBot bot) { final TelegramBot bot;
this.bot = bot;
}
public void taskCreateReport(String username, String link, Response response){ public void taskCreateReport(String username, String taskName, Response response){
if(response.isSuccess()) if(response.isSuccess())
sendToMe(String.format("用户%s提交下载%s", username, link)); sendToMe(String.format("用户%s提交下载任务:%s", username, taskName));
else else
sendToMe(String.format("用户%s提交下载%s , 下载失败:%s", username, link, response.get("data"))); sendToMe(String.format("用户%s提交下载任务:%s,下载失败:%s", username, taskName, response.get("data")));
} }
public void downloadComplete(Gallery gallery){ public void downloadComplete(Gallery gallery){
sendToMe(gallery.getLink() + " 下载完成"); String completionTime = COMPLETION_TIME_FORMATTER.format(Instant.now());
sendToMe(String.format("任务下载完成:%s\n完成时间:%s", gallery.getName(), completionTime));
} }
public void storageNodeOnline(){ public void storageNodeOnline(){
@@ -44,7 +45,7 @@ public class PushService {
} }
public void sendToMe(String text){ public void sendToMe(String text){
System.out.println(text); log.info(text);
SendMessage sendMessage = new SendMessage(self, text); SendMessage sendMessage = new SendMessage(self, text);
bot.execute(sendMessage); bot.execute(sendMessage);
} }
@@ -3,8 +3,10 @@ package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Domain.GalleryForQuery; import com.lion.lionwebsite.Domain.GalleryForQuery;
import com.lion.lionwebsite.Util.FileDownload; import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.GalleryUtil; import com.lion.lionwebsite.Util.GalleryUtil;
import com.lion.lionwebsite.Util.ImageFileCache;
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;
@@ -41,7 +43,7 @@ public class QueryService {
try{ try{
result = GalleryUtil.requests("https://exhentai.org/" + param, "get", null, null); result = GalleryUtil.requests("https://exhentai.org/" + param, "get", null, null);
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.error("query failure", e);
response.failure("query failure"); response.failure("query failure");
return response.toJSONString(); return response.toJSONString();
} }
@@ -103,22 +105,16 @@ public class QueryService {
String fileName = path.substring(path.lastIndexOf("/") + 1); String fileName = path.substring(path.lastIndexOf("/") + 1);
String suffix = fileName.substring(fileName.lastIndexOf(".")); String suffix = fileName.substring(fileName.lastIndexOf("."));
fileName = fileName.substring(0, fileName.lastIndexOf(".")); fileName = fileName.substring(0, fileName.lastIndexOf("."));
File image = new File(CachePath, fileName + ".avif"); String sourceUrl = "https://ehgt.org/" + path;
try {
if(image.isFile()){ Path image = ImageFileCache.get(Path.of(CachePath), fileName, () -> sourceUrl);
FileDownload.export(request, response, image.getAbsolutePath()); FileDownload.export(request, response, image.toString());
return; } catch (InterruptedException e) {
} Thread.currentThread().interrupt();
response.setStatus(503);
path = "https://ehgt.org/" + path; } catch (Exception e) {
try(ServletOutputStream outputStream = response.getOutputStream()){ log.warn("获取缩略图失败: errorType={}", e.getClass().getSimpleName());
new URI(path).toURL().openConnection().getInputStream().transferTo(new FileOutputStream(CachePath + fileName + suffix)); if (!response.isCommitted()) response.setStatus(404);
GalleryUtil.convertImg(CachePath + fileName + suffix, suffix);
FileInputStream inputStream = new FileInputStream(image.getAbsoluteFile()); //如果放到括号里,会导致图片未创建时创建文件流失败报错
outputStream.write(inputStream.readAllBytes());
inputStream.close();
}catch (IOException | URISyntaxException e){
System.out.println(e.getMessage());
} }
} }
} }
@@ -12,87 +12,123 @@ import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder; import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.logging.LoggingHandler; import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.logging.ByteBufFormat;
import io.netty.util.concurrent.DefaultPromise; import io.netty.util.concurrent.DefaultPromise;
import io.netty.util.concurrent.Promise; import io.netty.util.concurrent.Promise;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.*; import java.net.*;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicBoolean;
@Service @Service
@Data @Data
@Slf4j @Slf4j
public class RemoteService { public class RemoteService {
ChannelFuture channelFuture; volatile ChannelFuture channelFuture;
Channel channel; volatile Channel channel;
String ip = "5.255.110.45"; @Value("${remote.ip:5.255.110.45}")
String ip;
short port = 26321; short port = 26321;
GalleryMapper galleryMapper; final GalleryMapper galleryMapper;
PushService pushService; final PushService pushService;
HashMap<Integer, Promise<AbstractMessage>> promiseHashMap; ConcurrentHashMap<Integer, Promise<AbstractMessage>> promiseHashMap = new ConcurrentHashMap<>();
EventLoop eventLoopGroup; ConcurrentHashMap<Integer, CopyOnWriteArrayList<CompletableFuture<String>>> retryStatusWaiters =
new ConcurrentHashMap<>();
ExecutorService downloadThread; final EventLoop eventLoopGroup = new DefaultEventLoop();
final EventLoopGroup networkGroup = new NioEventLoopGroup(2);
final AtomicBoolean connecting = new AtomicBoolean();
final AtomicBoolean monitoring = new AtomicBoolean();
volatile boolean stopping;
volatile ServerSocket monitorSocket;
ExecutorService downloadThread = Executors.newCachedThreadPool();
Thread monitor; Thread monitor;
AtomicInteger atomicInteger; AtomicInteger atomicInteger = new AtomicInteger(0);
WebSocketService webSocketService; final WebSocketService webSocketService;
public RemoteService(GalleryMapper galleryMapper, PushService pushService, WebSocketService webSocketService){ final SubscriptionStandbySnapshotService subscriptionStandbySnapshotService;
this.galleryMapper = galleryMapper;
this.pushService = pushService;
atomicInteger = new AtomicInteger(0);
eventLoopGroup = new DefaultEventLoop();
downloadThread = Executors.newCachedThreadPool();
promiseHashMap = new HashMap<>();
this.webSocketService = webSocketService;
if(!initChannel()){ //如果远程服务器连接失败,则开启本地监听 final ExecutorService subscriptionSyncExecutor = Executors.newSingleThreadExecutor(r -> {
monitor = new Thread(this::monitorFunc); Thread thread = new Thread(r, "subscription-standby-sync");
monitor.start(); thread.setDaemon(true);
} return thread;
});
final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean();
final AtomicBoolean subscriptionSyncRunning = new AtomicBoolean();
@Value("${subscription.standby.sync-enabled:false}")
boolean subscriptionSyncEnabled;
@PostConstruct
void init() {
initChannel();
} }
public boolean initChannel(){ public boolean initChannel(){
if (stopping || !connecting.compareAndSet(false, true))
return !isDead();
try { try {
if (!isDead())
return true;
int i; int i;
for(i=0; i<20; i++) { for(i=0; i<20; i++) {
try { try {
channelFuture = new Bootstrap() channelFuture = new Bootstrap()
.channel(NioSocketChannel.class) .channel(NioSocketChannel.class)
.group(new NioEventLoopGroup()) .group(networkGroup)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000)
.handler(new ChannelInitializer<NioSocketChannel>() { .handler(new ChannelInitializer<NioSocketChannel>() {
@Override @Override
protected void initChannel(NioSocketChannel channel) { protected void initChannel(NioSocketChannel channel) {
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4)); channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
channel.pipeline().addLast(new MessageCodec()); channel.pipeline().addLast(new MessageCodec());
channel.pipeline().addLast(new LoggingHandler()); // 只记录事件和字节数,避免把订阅正文、签名等消息内容写入日志。
channel.pipeline().addLast(new LoggingHandler(io.netty.handler.logging.LogLevel.DEBUG, ByteBufFormat.SIMPLE));
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter()); channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
} }
}).connect(new InetSocketAddress(ip, port + i)).sync(); }).connect(new InetSocketAddress(ip, port + i)).sync();
break; break;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} catch (Exception e) { } catch (Exception e) {
log.error("连接storageNode失败,端口偏移量(重试次数):{}", i); log.error("连接storageNode失败,端口偏移量(重试次数):{}", i);
} }
if (stopping)
return false;
} }
//超过二十次连不上,主动抛出错误,由下方catch //超过二十次连不上,主动抛出错误,由下方catch
@@ -100,20 +136,29 @@ public class RemoteService {
throw new Exception(); throw new Exception();
} }
if (stopping) {
channelFuture.channel().close();
return false;
}
log.info("connect success"); log.info("connect success");
if(pushService != null) if(pushService != null)
pushService.storageNodeOnline(); pushService.storageNodeOnline();
channel = channelFuture.channel(); channel = channelFuture.channel();
closeMonitorSocket();
channel.writeAndFlush(new IdentityMessage("lionwebsite")); channel.writeAndFlush(new IdentityMessage("lionwebsite"));
//子节点上线时,发送未完成的任务 //子节点上线时,发送未完成的任务
resetUndone(); resetUndone();
requestSubscriptionSync();
return true; return true;
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("connect node failed, wait for node back online", e);
log.info("connect node failed, wait for node back online");
return false; return false;
} finally {
connecting.set(false);
if (isDead())
startMonitor();
} }
} }
@@ -123,7 +168,7 @@ public class RemoteService {
return -2; return -2;
} }
channelFuture.channel().close(); channelFuture.channel().close().awaitUninterruptibly();
if(initChannel()){ if(initChannel()){
return 0; return 0;
@@ -133,27 +178,110 @@ public class RemoteService {
} }
public byte checkAvailability(){ public byte checkAvailability(){
AvailableCheckMessage acm = new AvailableCheckMessage(); return sendRequest(new AvailableCheckMessage(), 10, TimeUnit.SECONDS);
acm.setMessageId(atomicInteger.getAndIncrement()); }
channel.writeAndFlush(acm); byte sendRequest(AbstractMessage message, long timeout, TimeUnit unit) {
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup); Channel target = channel;
promiseHashMap.put(acm.messageId, promise); if (stopping || target == null || !target.isActive())
try {
boolean result = promise.await(10, TimeUnit.SECONDS);
if(result){
ResponseMessage rsm = (ResponseMessage)promise.getNow();
return rsm.getResult();
}
else return -1;
}catch (InterruptedException e){
e.printStackTrace();
return -1; return -1;
message.setMessageId(atomicInteger.getAndIncrement());
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(message.messageId, promise);
try {
target.writeAndFlush(message).addListener(future -> {
if (!future.isSuccess())
promise.tryFailure(future.cause() == null ? new IOException("节点发送失败") : future.cause());
});
if (promise.await(timeout, unit) && promise.isSuccess()
&& promise.getNow() instanceof ResponseMessage response)
return response.getResult();
return -1;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return -1;
} catch (Exception e) {
log.warn("节点请求失败: messageId={}", message.messageId, e);
return -1;
} finally {
promiseHashMap.remove(message.messageId, promise);
} }
} }
/** 请求将当前全部订阅状态异步同步到存储节点,短时间内的多次请求会合并。 */
public void requestSubscriptionSync() {
if (stopping || !subscriptionSyncEnabled)
return;
subscriptionSyncQueued.set(true);
if (!subscriptionSyncRunning.compareAndSet(false, true))
return;
try {
subscriptionSyncExecutor.execute(this::drainSubscriptionSyncQueue);
} catch (java.util.concurrent.RejectedExecutionException e) {
subscriptionSyncRunning.set(false);
if (!stopping) throw e;
}
}
@Scheduled(fixedDelayString = "${subscription.standby.retry-interval-ms:60000}")
void scheduledSubscriptionSync() {
requestSubscriptionSync();
}
private void drainSubscriptionSyncQueue() {
try {
while (subscriptionSyncQueued.getAndSet(false)) {
if (isDead())
continue;
syncSubscriptionSnapshotOnce();
}
} finally {
subscriptionSyncRunning.set(false);
if (subscriptionSyncQueued.get())
requestSubscriptionSync();
}
}
private void syncSubscriptionSnapshotOnce() {
try {
SubscriptionSnapshotMessage message = subscriptionStandbySnapshotService.build();
byte result = sendRequest(message, 30, TimeUnit.SECONDS);
if (result == 0 || result == 3)
log.info("订阅快照同步完成 revision={} result={}", shortRevision(message.getRevision()), result);
else
log.warn("订阅快照同步失败或超时 revision={} result={}", shortRevision(message.getRevision()), result);
} catch (Exception e) {
log.warn("生成或发送订阅快照失败: {}", e.getMessage());
}
}
private static String shortRevision(String revision) {
return revision == null ? null : revision.substring(0, Math.min(12, revision.length()));
}
@PreDestroy
void shutdownResources() {
stopping = true;
closeMonitorSocket();
if (monitor != null) monitor.interrupt();
failPendingRequests();
if (channel != null) channel.close();
subscriptionSyncExecutor.shutdownNow();
downloadThread.shutdownNow();
networkGroup.shutdownGracefully();
eventLoopGroup.shutdownGracefully();
}
private void failPendingRequests() {
promiseHashMap.forEach((id, promise) -> promise.tryFailure(new IOException("节点连接已关闭")));
promiseHashMap.clear();
retryStatusWaiters.forEach((gid, waiters) ->
waiters.forEach(waiter -> waiter.completeExceptionally(new IOException("节点连接已关闭"))));
retryStatusWaiters.clear();
}
public boolean isDead(){ public boolean isDead(){
return channelFuture.channel() == null || !channelFuture.channel().isActive(); return channelFuture == null || channelFuture.channel() == null || !channelFuture.channel().isActive();
} }
public void resetUndone(){ public void resetUndone(){
@@ -173,69 +301,98 @@ public class RemoteService {
GalleryTask galleryTask = new GalleryTask(); GalleryTask galleryTask = new GalleryTask();
galleryTask.setGid(gallery.getGid()); galleryTask.setGid(gallery.getGid());
galleryTask.setName(gallery.getName()); galleryTask.setName(gallery.getName());
DownloadPostMessage message = new DownloadPostMessage();
message.setGalleryTask(galleryTask);
return sendRequest(message, 10, TimeUnit.SECONDS);
}
DownloadPostMessage dpm = new DownloadPostMessage(); public RetryResult retryGallery(Gallery gallery){
dpm.messageId = atomicInteger.getAndIncrement(); CompletableFuture<String> statusFuture = new CompletableFuture<>();
dpm.setGalleryTask(galleryTask); retryStatusWaiters.computeIfAbsent(gallery.getGid(), ignored -> new CopyOnWriteArrayList<>())
channel.writeAndFlush(dpm); .add(statusFuture);
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(dpm.messageId, promise);
try { try {
boolean result = promise.await(10, TimeUnit.SECONDS); byte submitResult = addGalleryToQueue(gallery);
if(result){ if(submitResult != 0 && !statusFuture.isDone())
ResponseMessage rsm = (ResponseMessage)promise.getNow(); return new RetryResult(false, "节点未接受重试请求");
return rsm.getResult(); return new RetryResult(true, statusFuture.get(10, TimeUnit.SECONDS));
} }catch (TimeoutException e){
else return -1; return new RetryResult(false, "节点已收到重试请求,但未及时返回任务状态");
}catch (ExecutionException e){
log.warn("等待重试状态失败, gid={}", gallery.getGid(), e);
return new RetryResult(false, "获取任务状态失败");
}catch (InterruptedException e){ }catch (InterruptedException e){
e.printStackTrace(); log.warn("等待重试状态被中断, gid={}", gallery.getGid(), e);
return -1; Thread.currentThread().interrupt();
return new RetryResult(false, "获取任务状态被中断");
}finally {
retryStatusWaiters.computeIfPresent(gallery.getGid(), (gid, waiters) -> {
waiters.remove(statusFuture);
return waiters.isEmpty() ? null : waiters;
});
} }
} }
private void completeRetryStatusWaiters(int gid, String status){
CopyOnWriteArrayList<CompletableFuture<String>> waiters = retryStatusWaiters.remove(gid);
if(waiters != null)
waiters.forEach(waiter -> waiter.complete(status));
}
public record RetryResult(boolean success, String message) {}
public byte deleteGallery(Gallery gallery){ public byte deleteGallery(Gallery gallery){
DeleteGalleryMessage dgm = new DeleteGalleryMessage(); DeleteGalleryMessage message = new DeleteGalleryMessage();
dgm.setGalleryName(gallery.getName()); message.setGalleryName(gallery.getName());
dgm.messageId = atomicInteger.getAndIncrement(); return sendRequest(message, 10, TimeUnit.SECONDS);
}
channel.writeAndFlush(dgm); private void startMonitor() {
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup); if (stopping || !monitoring.compareAndSet(false, true))
promiseHashMap.put(dgm.messageId, promise); return;
try{ monitor = new Thread(this::monitorFunc, "storage-node-monitor");
boolean result = promise.await(10, TimeUnit.SECONDS); monitor.setDaemon(true);
if(result){ monitor.start();
ResponseMessage rsm = (ResponseMessage) promise.getNow(); }
return rsm.getResult();
}else return -1; private void closeMonitorSocket() {
}catch (InterruptedException e){ ServerSocket socket = monitorSocket;
return -1; if (socket != null) {
try { socket.close(); }
catch (IOException e) { log.debug("关闭节点监听失败", e); }
} }
} }
public void monitorFunc(){ public void monitorFunc(){
int real_port = CustomUtil._findIdlePort(port + 1); try (ServerSocket socket = new ServerSocket(CustomUtil._findIdlePort(port + 1))) {
log.info("监听端口: {}等待节点上线", real_port); monitorSocket = socket;
try(ServerSocket socket = new ServerSocket(real_port)) { if (stopping || !isDead())
Socket client; return;
while(true){ log.info("监听端口: {}等待节点上线", socket.getLocalPort());
client = socket.accept(); while (!stopping) {
try (Socket client = socket.accept()) {
if(client.getInetAddress().getHostAddress().equals("5.255.110.45")){ if (!client.getInetAddress().getHostAddress().equals(ip))
//连接之后发送lionwebsite,否则存储节点不能确认这个端口是否有效 continue;
OutputStream outputStream = client.getOutputStream(); OutputStream output = client.getOutputStream();
outputStream.write("lionwebsite".getBytes()); output.write("lionwebsite".getBytes(java.nio.charset.StandardCharsets.UTF_8));
outputStream.flush(); output.flush();
outputStream.close(); client.shutdownOutput();
log.info("尝试连接"); if (initChannel())
initChannel();
client.close();
socket.close();
break; break;
} }
} }
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); if (!stopping && isDead())
log.warn("等待节点上线失败", e);
} finally {
monitorSocket = null;
monitoring.set(false);
// A connection may flap while the previous monitor is still exiting.
if (!stopping && isDead()) {
try { networkGroup.next().schedule(this::startMonitor, 5, TimeUnit.SECONDS); }
catch (java.util.concurrent.RejectedExecutionException e) {
if (!stopping) log.warn("安排节点监听重试失败", e);
}
}
} }
} }
@@ -245,45 +402,59 @@ public class RemoteService {
//如果不是响应信息或者响应信息为失败,则打印 //如果不是响应信息或者响应信息为失败,则打印
if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0) if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0)
if(! (msg instanceof MaintainMessage)) if(! (msg instanceof MaintainMessage))
System.out.println(msg); log.debug("{}", msg);
//下载状态 //下载状态
if(msg instanceof DownloadStatusMessage dsm){ if(msg instanceof DownloadStatusMessage dsm){
GalleryTask[] galleryTasks = dsm.getGalleryTasks(); GalleryTask[] galleryTasks = dsm.getGalleryTasks();
for (GalleryTask galleryTask : galleryTasks) { for (GalleryTask galleryTask : galleryTasks) {
Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid()); Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid());
if (gallery == null) {
log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}",
galleryTask.getGid(), galleryTask.getName());
continue;
}
gallery.setProceeding(galleryTask.getProceeding()); gallery.setProceeding(galleryTask.getProceeding());
if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null) if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
gallery.setName(galleryTask.getName()); gallery.setName(galleryTask.getName());
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) { if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
boolean justCompleted = !"下载完成".equals(gallery.getStatus());
gallery.setStatus("下载完成"); gallery.setStatus("下载完成");
if (justCompleted)
pushService.downloadComplete(gallery); pushService.downloadComplete(gallery);
} }
else if(galleryTask.getStatus() == GalleryTask.COMPRESSING) else if(galleryTask.getStatus() == GalleryTask.COMPRESSING)
gallery.setStatus("压缩中"); gallery.setStatus("压缩中");
else if(galleryTask.getProceeding() != 0) else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE)
gallery.setStatus("等待压缩");
else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING)
gallery.setStatus("下载中"); gallery.setStatus("下载中");
log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages()); log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages());
galleryMapper.updateGallery(gallery); galleryMapper.updateGallery(gallery);
completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus());
} }
webSocketService.updateTaskProcessing(galleryTasks); webSocketService.updateTaskProcessing(galleryTasks);
} }
else if(msg instanceof ResponseMessage rsm) else if(msg instanceof ResponseMessage rsm) {
promiseHashMap.get(rsm.messageId).setSuccess(rsm); Promise<AbstractMessage> promise = promiseHashMap.remove(rsm.messageId);
if(promise != null)
promise.trySuccess(rsm);
else
log.warn("收到无等待者的响应消息: messageId={}", rsm.messageId);
}
} }
@Override @Override
public void channelUnregistered(ChannelHandlerContext ctx) { public void channelUnregistered(ChannelHandlerContext ctx) {
System.out.println(ctx.channel()); if (ctx.channel() == channel) {
System.out.println(channel); failPendingRequests();
if(ctx.channel() != null && ctx.channel().remoteAddress().toString().equals(channel.remoteAddress().toString())){ if (!stopping) {
System.out.println("activate monitor thread, waiting for node back online");
pushService.storageNodeOffline(); pushService.storageNodeOffline();
monitor = new Thread(RemoteService.this::monitorFunc); startMonitor();
monitor.start(); }
} }
} }
} }
@@ -2,14 +2,17 @@ package com.lion.lionwebsite.Service;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.lion.lionwebsite.Dao.normal.SubMapper; 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.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord; import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
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.GalleryUtil; import com.lion.lionwebsite.Util.GalleryUtil;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
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.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
@@ -17,111 +20,270 @@ import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.concurrent.locks.Lock;
import java.util.function.Supplier;
@Service @Service
@Slf4j @Slf4j
@RequiredArgsConstructor
public class SubService { public class SubService {
SubMapper subMapper; final SubMapper subMapper;
final UserMapper userMapper;
final SubscriptionRefreshService refreshService;
final RemoteService remoteService;
final SubscriptionStateCoordinator stateCoordinator;
public SubService(SubMapper subMapper) { public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
this.subMapper = subMapper; Response response = Response.generateResponse();
SubscriptionAccount account;
Lock lock = stateCoordinator.writeLock();
lock.lock();
try {
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
return response.failure("名称和上游 key 不能为空").toJSONString();
if (subMapper.countSubscriptionAccountName(name.trim()) > 0 || subMapper.countSubscriptionAccountKey(upstreamKey.trim()) > 0)
return response.failure("名称或上游 key 已存在").toJSONString();
account = new SubscriptionAccount(null, name.trim(), upstreamKey.trim(), filterHighMultiplier, enabled, null, null, null, null, 0, null, null);
subMapper.insertSubscriptionAccount(account);
} finally {
lock.unlock();
}
if (enabled)
refreshService.refresh(account.getId());
remoteService.requestSubscriptionSync();
return response.success(accountJson(account)).toJSONString();
} }
public String insertSubBind(String user){ public String listSubscriptionAccounts() {
ArrayList<SubscriptionAccount> accounts = subMapper.selectAllSubscriptionAccounts();
for (SubscriptionAccount account : accounts) {
account.setV2Url(refreshService.v2Url(account));
account.setClashUrl(refreshService.clashUrl(account));
}
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(accounts)).toJSONString();
}
public String updateSubscriptionAccount(Integer id, String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
SubscriptionAccount account;
Lock lock = stateCoordinator.writeLock();
lock.lock();
try {
account = subMapper.selectSubscriptionAccount(id);
if (account == null)
return response.failure("子账号不存在").toJSONString();
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
return response.failure("名称和上游 key 不能为空").toJSONString();
for (SubscriptionAccount existing : subMapper.selectAllSubscriptionAccounts()) {
if (!existing.getId().equals(id) && (existing.getName().equals(name.trim()) || existing.getUpstreamKey().equals(upstreamKey.trim())))
return response.failure("名称或上游 key 已存在").toJSONString();
}
boolean changed = !account.getUpstreamKey().equals(upstreamKey.trim())
|| account.isFilterHighMultiplier() != filterHighMultiplier || account.isEnabled() != enabled;
account.setName(name.trim());
account.setUpstreamKey(upstreamKey.trim());
account.setFilterHighMultiplier(filterHighMultiplier);
account.setEnabled(enabled);
subMapper.updateSubscriptionAccount(account);
if (changed)
refreshService.invalidateCache(id);
} finally {
lock.unlock();
}
if (enabled)
refreshService.refresh(id);
remoteService.requestSubscriptionSync();
return response.success(accountJson(account)).toJSONString();
}
public String deleteSubscriptionAccount(Integer id) {
return withWriteLock(() -> deleteSubscriptionAccountUnlocked(id));
}
private String deleteSubscriptionAccountUnlocked(Integer id) {
Response response = Response.generateResponse();
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
if (account == null)
return response.failure("子账号不存在").toJSONString();
if (account.getBoundUserCount() != null && account.getBoundUserCount() > 0)
return response.failure("子账号仍绑定用户,请先改绑").toJSONString();
subMapper.deleteSubscriptionAccount(id);
refreshService.invalidateCache(id);
remoteService.requestSubscriptionSync();
return response.success("删除成功").toJSONString();
}
public String refreshSubscriptionAccount(Integer id) {
boolean success = refreshService.refresh(id);
remoteService.requestSubscriptionSync();
return success ? Response._success("刷新成功") : Response._failure("刷新失败,请查看子账号错误状态");
}
public String insertSubBind(String user, Integer accountId) {
return withWriteLock(() -> insertSubBindUnlocked(user, accountId));
}
private String insertSubBindUnlocked(String user, Integer accountId) {
Response response = Response.generateResponse();
if (user == null || user.isBlank() || userMapper.selectUserByUsername(user) == null)
return response.failure("用户不存在").toJSONString();
SubscriptionAccount account = accountId == null ? firstEnabledAccount() : subMapper.selectSubscriptionAccount(accountId);
if (account == null || !account.isEnabled())
return response.failure("子账号不存在或已停用").toJSONString();
if (!refreshService.hasCompleteCache(account.getId()))
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
if (subMapper.countSubBindByUser(user) > 0)
return response.failure("用户已绑定子账号,请使用改绑").toJSONString();
String key = RandomUtil.randomString(8); String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key)) while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8); key = RandomUtil.randomString(8);
subMapper.insertSubBind(new SubBind(key, user, account.getId(), account.getName(), account.isEnabled(), account.isFilterHighMultiplier()));
SubBind subBind = new SubBind(key, user); remoteService.requestSubscriptionSync();
subMapper.insertSubBind(subBind);
return response.success("添加成功").toJSONString(); return response.success("添加成功").toJSONString();
} }
public String resetKey(String user) { public String resetKey(String user) {
Response response = Response.generateResponse(); return withWriteLock(() -> resetKeyUnlocked(user));
}
subMapper.deleteSubBind(user); private String resetKeyUnlocked(String user) {
subMapper.deleteSubUpdateRecord(user); Response response = Response.generateResponse();
if (subMapper.countSubBindByUser(user) == 0)
return response.failure("绑定不存在").toJSONString();
String key = RandomUtil.randomString(8); String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key)) while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8); key = RandomUtil.randomString(8);
SubBind subBind = new SubBind(key, user); subMapper.updateSubBindKey(user, key);
subMapper.insertSubBind(subBind); subMapper.deleteSubUpdateRecord(user);
remoteService.requestSubscriptionSync();
return response.success().toJSONString(); return response.success().toJSONString();
} }
public String selectAllSubBind(){ public String rebind(String user, Integer accountId) {
return withWriteLock(() -> rebindUnlocked(user, accountId));
}
private String rebindUnlocked(String user, Integer accountId) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
ArrayList<SubBind> subBinds = subMapper.selectAllSubBind(); SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
return response.success(CustomUtil.objectMapper.valueToTree(subBinds)).toJSONString(); if (account == null || !account.isEnabled())
return response.failure("子账号不存在或已停用").toJSONString();
if (!refreshService.hasCompleteCache(account.getId()))
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
if (subMapper.updateSubBindAccount(user, accountId) == 0)
return response.failure("绑定不存在").toJSONString();
remoteService.requestSubscriptionSync();
return response.success("改绑成功").toJSONString();
}
public String selectAllSubBind() {
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubBind())).toJSONString();
} }
public String SelectAllSubUpdateRecord() { public String SelectAllSubUpdateRecord() {
Response response = Response.generateResponse(); return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubUpdateRecord())).toJSONString();
ArrayList<SubUpdateRecord> subUpdateRecords = subMapper.selectAllSubUpdateRecord();
return response.success(CustomUtil.objectMapper.valueToTree(subUpdateRecords)).toJSONString();
} }
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key) { public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key) {
if (key == null || client == null) if (key == null || client == null)
return; return;
SubBind subBind = subMapper.selectSubBind(key); SubBind subBind = subMapper.selectSubBind(key);
if(subBind == null) if (subBind == null || subBind.getSubscriptionAccountId() == null || !subBind.isSubscriptionAccountEnabled()) {
sendStatus(response, HttpServletResponse.SC_NOT_FOUND, "subscription not found");
return; return;
}
String ip; String ip = resolveClientIp(request);
if(request.getRemoteAddr().equals("127.0.0.1")){ String ua = request.getHeader("User-Agent");
ip = request.getHeader("X-Forwarded-For"); if (ua == null) return;
if(ip.contains(",")) recordUpdate(subBind.getUser(), ip, ua);
ip = ip.split(",")[0].trim(); if (!"v2".equals(client) && !"cat".equals(client)) {
if(ip.contains(":")) sendStatus(response, HttpServletResponse.SC_BAD_REQUEST, "client error");
ip = ip.split(":")[0].trim();
} else
ip = request.getRemoteAddr();
String UA = request.getHeader("User-Agent");
if(UA == null)
return; return;
}
java.nio.file.Path path = refreshService.cachedPath(subBind.getSubscriptionAccountId(), client);
if (!Files.isRegularFile(path)) {
sendStatus(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "subscription unavailable");
return;
}
FileDownload.export(request, response, path.toString());
}
private static String resolveClientIp(HttpServletRequest request) {
String remoteAddr = headerAddress(request.getRemoteAddr());
if (isLoopback(remoteAddr)) {
String forwardedFor = headerAddress(request.getHeader("X-Forwarded-For"));
if (forwardedFor != null)
return forwardedFor;
String realIp = headerAddress(request.getHeader("X-Real-IP"));
if (realIp != null)
return realIp;
}
return remoteAddr == null ? "unknown" : remoteAddr;
}
private static String headerAddress(String value) {
if (value == null || value.isBlank())
return null;
String address = value.split(",", 2)[0].trim();
return address.isBlank() || "unknown".equalsIgnoreCase(address) ? null : address;
}
private static boolean isLoopback(String address) {
return "127.0.0.1".equals(address) || "::1".equals(address)
|| "0:0:0:0:0:0:0:1".equals(address);
}
private void recordUpdate(String user, String ip, String ua) {
String location; String location;
try { try {
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null); String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td"); Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
if(tds.size() > 3) location = tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim() : (tds.isEmpty() ? "unknown" : tds.get(1).text());
location = tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim(); } catch (Exception e) {
else
location = tds.get(1).text();
}catch (IOException e){
log.error("获取ip地址信息失败: {}", e.getMessage());
location = "unknown"; location = "unknown";
} }
subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), location));
SubUpdateRecord subUpdateRecord = new SubUpdateRecord(0, subBind.getUser(), ip, UA, new Date(), location); if (subMapper.selectUpdateRecordCount(user) > 10)
subMapper.insertSubUpdateRecord(subUpdateRecord); subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user));
if(subMapper.selectUpdateRecordCount(subBind.getUser()) > 10)
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(subBind.getUser()));
switch (client){
case "v2" -> FileDownload.export(request, response, "sub/DouNaiV2ray.txt");
case "cat" -> FileDownload.export(request, response, "sub/DouNaiClash.txt");
default -> {
try{
response.getOutputStream().write("client error".getBytes(StandardCharsets.UTF_8));
} catch (IOException ignored){}
}
}
} }
public String deleteSubBind(String user) { public String deleteSubBind(String user) {
Response response = Response.generateResponse(); return withWriteLock(() -> deleteSubBindUnlocked(user));
}
private String deleteSubBindUnlocked(String user) {
subMapper.deleteSubBind(user); subMapper.deleteSubBind(user);
subMapper.deleteSubUpdateRecord(user); subMapper.deleteSubUpdateRecord(user);
return response.success("删除成功").toJSONString(); remoteService.requestSubscriptionSync();
return Response._success("删除成功");
}
private String withWriteLock(Supplier<String> action) {
Lock stateLock = stateCoordinator.writeLock();
stateLock.lock();
try {
return action.get();
} finally {
stateLock.unlock();
}
}
private SubscriptionAccount firstEnabledAccount() {
return subMapper.selectAllSubscriptionAccounts().stream().filter(SubscriptionAccount::isEnabled).findFirst().orElse(null);
}
private String accountJson(SubscriptionAccount account) {
account.setV2Url(refreshService.v2Url(account));
account.setClashUrl(refreshService.clashUrl(account));
return CustomUtil.objectMapper.valueToTree(account).toString();
}
private static void sendStatus(HttpServletResponse response, int status, String message) {
try {
response.sendError(status, message);
} catch (IOException ignored) { }
} }
} }
@@ -0,0 +1,271 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.util.Timeout;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.concurrent.locks.Lock;
@Service
@Slf4j
@RequiredArgsConstructor
public class SubscriptionRefreshService {
private static final CloseableHttpClient HTTP_CLIENT = HttpClients.custom()
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(5_000))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(5_000))
// HttpClient 5 将 socket 读超时改名为 responseTimeout。
.setResponseTimeout(Timeout.ofMilliseconds(15_000)).build())
.build();
private static final Pattern MULTIPLIER = Pattern.compile("(\\d+(?:\\.\\d+)?)x\\s*$", Pattern.CASE_INSENSITIVE);
final SubMapper subMapper;
final SubscriptionStateCoordinator stateCoordinator;
@Value("${subscription.upstream.v2-url-template}")
String v2UrlTemplate;
@Value("${subscription.upstream.clash-url-template}")
String clashUrlTemplate;
@Value("${subscription.upstream.high-multiplier-threshold:2.0}")
double highMultiplierThreshold;
@Value("${subscription.cache-root:sub/accounts}")
String cacheRoot;
public boolean refreshAll() {
boolean success = true;
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
if (account.isEnabled() && !refresh(account.getId()))
success = false;
}
return success;
}
private long refreshSequence;
// Accessed only while holding the coordinator write lock.
private final Map<Integer, Long> latestRefresh = new HashMap<>();
public boolean refresh(Integer accountId) {
Lock stateLock = stateCoordinator.writeLock();
SubscriptionAccount account;
long version;
stateLock.lock();
try {
account = subMapper.selectSubscriptionAccount(accountId);
if (account == null || !account.isEnabled())
return false;
version = ++refreshSequence;
latestRefresh.put(accountId, version);
} finally {
stateLock.unlock();
}
// Network access and parsing never hold the shared subscription lock.
try {
String v2 = processV2(firstLine(download(v2Url(account))), account.isFilterHighMultiplier(), highMultiplierThreshold);
List<String> clash = processClash(download(clashUrl(account)), account.isFilterHighMultiplier(), highMultiplierThreshold);
stateLock.lock();
try {
if (!isCurrent(account, version))
return false;
Path dir = Paths.get(cacheRoot, String.valueOf(accountId));
Files.createDirectories(dir);
atomicWrite(dir.resolve("v2ray.txt"), v2.getBytes(StandardCharsets.UTF_8));
atomicWrite(dir.resolve("clash.yaml"), String.join("\n", clash).concat("\n").getBytes(StandardCharsets.UTF_8));
subMapper.markSubscriptionRefreshSuccess(accountId);
return true;
} finally {
stateLock.unlock();
}
} catch (Exception e) {
stateLock.lock();
try {
if (isCurrent(account, version)) {
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
subMapper.markSubscriptionRefreshFailure(accountId, message.length() > 500 ? message.substring(0, 500) : message);
}
} finally {
stateLock.unlock();
}
log.warn("刷新子账号订阅失败 accountId={} errorType={}", accountId, e.getClass().getSimpleName());
return false;
}
}
private boolean isCurrent(SubscriptionAccount expected, long version) {
SubscriptionAccount current = subMapper.selectSubscriptionAccount(expected.getId());
return Objects.equals(latestRefresh.get(expected.getId()), version)
&& current != null && current.isEnabled()
&& Objects.equals(current.getUpstreamKey(), expected.getUpstreamKey())
&& current.isFilterHighMultiplier() == expected.isFilterHighMultiplier();
}
public String v2Url(SubscriptionAccount account) {
return applyTemplate(v2UrlTemplate, account.getUpstreamKey());
}
public String clashUrl(SubscriptionAccount account) {
return applyTemplate(clashUrlTemplate, account.getUpstreamKey());
}
private String applyTemplate(String template, String key) {
if (template == null || template.indexOf("{key}") < 0 || template.indexOf("{key}") != template.lastIndexOf("{key}"))
throw new IllegalStateException("订阅 URL 模板必须包含且只能包含一个 {key}");
return template.replace("{key}", java.net.URLEncoder.encode(key, StandardCharsets.UTF_8));
}
private static String firstLine(List<String> lines) {
if (lines.isEmpty())
throw new IllegalStateException("V2Ray 上游返回为空");
return lines.getFirst().trim();
}
private static String processV2(String encoded, boolean filter, double threshold) {
byte[] decoded;
try {
decoded = Base64.getMimeDecoder().decode(encoded);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("V2Ray 上游不是有效 Base64");
}
StringBuilder kept = new StringBuilder();
for (String node : new String(decoded, StandardCharsets.UTF_8).split("\\R")) {
if (node.isBlank())
continue;
if (!filter || !isHigh(nodeName(node), threshold))
kept.append(node).append('\n');
}
return Base64.getEncoder().encodeToString(kept.toString().getBytes(StandardCharsets.UTF_8));
}
private static String nodeName(String node) {
int hash = node.lastIndexOf('#');
if (hash < 0 || hash == node.length() - 1)
return "";
return URLDecoder.decode(node.substring(hash + 1), StandardCharsets.UTF_8);
}
private List<String> processClash(List<String> source, boolean filter, double threshold) {
Set<String> removed = new HashSet<>();
List<String> result = new ArrayList<>();
boolean inProxies = false;
boolean skipNode = false;
for (String line : source) {
if (line.equals("proxies:")) {
inProxies = true;
skipNode = false;
result.add(line);
continue;
}
if (line.equals("proxy-groups:")) {
inProxies = false;
skipNode = false;
result.add(line);
continue;
}
if (inProxies && line.matches("^\\s{2}-\\s+name:.*")) {
String name = clashName(line);
skipNode = filter && isHigh(name, threshold);
if (skipNode)
removed.add(name);
else
result.add(line);
continue;
}
if (skipNode)
continue;
if (!inProxies && !removed.isEmpty() && line.trim().startsWith("- ")) {
String ref = line.trim().substring(2).trim();
if (removed.contains(unquote(ref)))
continue;
}
result.add(line);
}
return result;
}
private static String clashName(String line) {
int index = line.indexOf("name:");
return unquote(line.substring(index + 5).trim());
}
private static String unquote(String value) {
if (value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))))
return value.substring(1, value.length() - 1);
return value;
}
private static boolean isHigh(String name, double threshold) {
Matcher matcher = MULTIPLIER.matcher(name);
return matcher.find() && Double.parseDouble(matcher.group(1)) > threshold;
}
List<String> download(String url) throws IOException {
HttpGet get = new HttpGet(url);
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
if (response.getCode() != 200)
throw new IOException("上游 HTTP 状态码 " + response.getCode());
HttpEntity entity = response.getEntity();
if (entity == null)
throw new IOException("上游返回为空");
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(entity.getContent(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null)
lines.add(line);
}
return lines;
}
}
private static void atomicWrite(Path target, byte[] data) throws IOException {
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
Files.write(temp, data);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
}
public Path cachedPath(Integer accountId, String client) {
return Paths.get(cacheRoot, String.valueOf(accountId), client.equals("v2") ? "v2ray.txt" : "clash.yaml");
}
public boolean hasCompleteCache(Integer accountId) {
return Files.isRegularFile(cachedPath(accountId, "v2")) && Files.isRegularFile(cachedPath(accountId, "cat"));
}
public void invalidateCache(Integer accountId) {
Lock stateLock = stateCoordinator.writeLock();
stateLock.lock();
try {
latestRefresh.remove(accountId); // In-flight responses must not restore invalidated content.
Files.deleteIfExists(cachedPath(accountId, "v2"));
Files.deleteIfExists(cachedPath(accountId, "cat"));
} catch (IOException e) {
log.warn("清理失效订阅缓存失败 accountId={}", accountId, e);
} finally {
stateLock.unlock();
}
}
}
@@ -0,0 +1,138 @@
package com.lion.lionwebsite.Service;
import tools.jackson.databind.ObjectMapper;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Message.*;
import com.lion.lionwebsite.Util.CustomUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.zip.GZIPOutputStream;
import java.util.concurrent.locks.Lock;
@Service
@RequiredArgsConstructor
public class SubscriptionStandbySnapshotService {
private final SubMapper subMapper;
private final SubscriptionStateCoordinator stateCoordinator;
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
@Value("${subscription.standby.sync-secret:}")
String syncSecret;
@Value("${subscription.standby.sync-enabled:false}")
boolean syncEnabled;
@Value("${subscription.cache-root:sub/accounts}")
String cacheRoot;
@PostConstruct
void validateConfiguration() {
if (syncEnabled && (syncSecret == null || syncSecret.isBlank()))
throw new IllegalStateException("启用订阅备机同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
}
public SubscriptionSnapshotMessage build() throws IOException {
Lock stateLock = stateCoordinator.readLock();
stateLock.lock();
try {
Map<Integer, SubscriptionAccountSnapshot> accountMap = new HashMap<>();
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
Path v2Path = cachedPath(account.getId(), "v2");
Path clashPath = cachedPath(account.getId(), "cat");
if (!account.isEnabled() || !Files.isRegularFile(v2Path) || !Files.isRegularFile(clashPath))
continue;
byte[] v2 = Files.readAllBytes(v2Path);
byte[] clash = Files.readAllBytes(clashPath);
SubscriptionAccountSnapshot snapshot = new SubscriptionAccountSnapshot();
snapshot.setAccountId(account.getId());
snapshot.setEnabled(true);
snapshot.setFilterHighMultiplier(account.isFilterHighMultiplier());
snapshot.setV2ContentBase64(Base64.getEncoder().encodeToString(v2));
snapshot.setV2Sha256(sha256(v2));
snapshot.setClashContentBase64(Base64.getEncoder().encodeToString(clash));
snapshot.setClashSha256(sha256(clash));
accountMap.put(account.getId(), snapshot);
}
List<SubscriptionAccountSnapshot> accounts = new ArrayList<>(accountMap.values());
accounts.sort(Comparator.comparing(SubscriptionAccountSnapshot::getAccountId));
List<SubscriptionBindingSnapshot> bindings = new ArrayList<>();
for (SubBind bind : subMapper.selectAllSubBind()) {
if (bind.getSubscriptionAccountId() == null || !accountMap.containsKey(bind.getSubscriptionAccountId()))
continue;
SubscriptionBindingSnapshot snapshot = new SubscriptionBindingSnapshot();
snapshot.setPublicKeySha256(sha256(bind.getKey().getBytes(StandardCharsets.UTF_8)));
snapshot.setAccountId(bind.getSubscriptionAccountId());
bindings.add(snapshot);
}
bindings.sort(Comparator.comparing(SubscriptionBindingSnapshot::getPublicKeySha256));
SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload();
payload.setSchemaVersion(1);
payload.setAccounts(accounts);
payload.setBindings(bindings);
byte[] payloadJson = objectMapper.writeValueAsBytes(payload);
byte[] compressed = gzip(payloadJson);
String revision = sha256(payloadJson);
String payloadSha256 = sha256(compressed);
long generatedAt = System.currentTimeMillis();
String signatureInput = "1\n" + revision + "\n" + generatedAt + "\n" + payloadSha256;
SubscriptionSnapshotMessage message = new SubscriptionSnapshotMessage();
message.setSchemaVersion(1);
message.setRevision(revision);
message.setGeneratedAt(generatedAt);
message.setPayloadBase64(Base64.getEncoder().encodeToString(compressed));
message.setPayloadSha256(payloadSha256);
message.setSignature(hmac(signatureInput.getBytes(StandardCharsets.UTF_8)));
return message;
} finally {
stateLock.unlock();
}
}
private String hmac(byte[] input) {
if (syncSecret == null || syncSecret.isBlank())
throw new IllegalStateException("订阅备机同步密钥未配置");
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(syncSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(input));
} catch (Exception e) {
throw new IllegalStateException("生成订阅快照签名失败", e);
}
}
private static byte[] gzip(byte[] input) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(output)) {
gzip.write(input);
}
return output.toByteArray();
}
private static String sha256(byte[] input) {
try { return hex(MessageDigest.getInstance("SHA-256").digest(input)); }
catch (Exception e) { throw new IllegalStateException(e); }
}
private static String hex(byte[] input) { return HexFormat.of().formatHex(input); }
private Path cachedPath(Integer accountId, String client) {
return Path.of(cacheRoot, String.valueOf(accountId), "v2".equals(client) ? "v2ray.txt" : "clash.yaml");
}
}
@@ -0,0 +1,20 @@
package com.lion.lionwebsite.Service;
import org.springframework.stereotype.Component;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/** Coordinates database bindings and the two cache files as one subscription state. */
@Component
public final class SubscriptionStateCoordinator {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public Lock readLock() {
return lock.readLock();
}
public Lock writeLock() {
return lock.writeLock();
}
}
@@ -7,33 +7,25 @@ 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 jakarta.annotation.Resource; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@Service @Service
public class UserServiceImpl{ @Slf4j
@RequiredArgsConstructor
public class UserService{
@Resource final UserMapper userMapper;
UserMapper userMapper;
@Resource final GalleryMapper galleryMapper;
GalleryMapper galleryMapper;
@Resource final CollectMapper collectMapper;
CollectMapper collectMapper;
@Resource final TaskHandlerInterceptor taskHandlerInterceptor;
TaskHandlerInterceptor taskHandlerInterceptor;
public UserServiceImpl(UserMapper userMapper, GalleryMapper galleryMapper, CollectMapper collectMapper, TaskHandlerInterceptor taskHandlerInterceptor) {
this.userMapper = userMapper;
this.galleryMapper = galleryMapper;
this.collectMapper = collectMapper;
this.taskHandlerInterceptor = taskHandlerInterceptor;
}
public String addAuthCode(String targetAuthCode, String people) { public String addAuthCode(String targetAuthCode, String people) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
@@ -43,7 +35,7 @@ public class UserServiceImpl{
taskHandlerInterceptor.updateAuthCodes(); taskHandlerInterceptor.updateAuthCodes();
response.success("插入成功"); response.success("插入成功");
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("插入授权码失败", e);
response.failure("插入失败"); response.failure("插入失败");
} }
@@ -62,7 +54,7 @@ public class UserServiceImpl{
response.failure("授权码不存在"); response.failure("授权码不存在");
} }
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("修改授权码失败", e);
response.failure("修改失败"); response.failure("修改失败");
} }
@@ -80,7 +72,7 @@ public class UserServiceImpl{
response.failure("授权码不存在"); response.failure("授权码不存在");
} }
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("修改用户名失败", e);
response.failure("修改失败"); response.failure("修改失败");
} }
@@ -104,7 +96,7 @@ public class UserServiceImpl{
response.success("删除成功"); response.success("删除成功");
taskHandlerInterceptor.updateAuthCodes(); taskHandlerInterceptor.updateAuthCodes();
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("删除授权码失败", e);
response.failure("删除失败"); response.failure("删除失败");
} }
@@ -1,24 +1,26 @@
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 org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.socket.*; import org.springframework.web.socket.*;
import java.util.ArrayList; import java.util.concurrent.CopyOnWriteArrayList;
@Service @Service
@Slf4j
public class WebSocketService implements WebSocketHandler { public class WebSocketService implements WebSocketHandler {
ArrayList<WebSocketSession> sessions; CopyOnWriteArrayList<WebSocketSession> sessions;
ObjectMapper objectMapper; ObjectMapper objectMapper;
public WebSocketService() { public WebSocketService() {
sessions = new ArrayList<>(); sessions = new CopyOnWriteArrayList<>();
objectMapper = CustomUtil.objectMapper; objectMapper = CustomUtil.objectMapper;
} }
@@ -30,7 +32,8 @@ public class WebSocketService implements WebSocketHandler {
sessions.forEach(s -> { sessions.forEach(s -> {
try { try {
s.sendMessage(new TextMessage("{\"type\": \"fullUpdate\"}")); s.sendMessage(new TextMessage("{\"type\": \"fullUpdate\"}"));
} catch (Exception ignore) { } catch (Exception e) {
log.warn("WebSocket send fullUpdate failed", e);
} }
}); });
return; return;
@@ -39,11 +42,13 @@ public class WebSocketService implements WebSocketHandler {
ObjectNode objectNode = objectMapper.createObjectNode(); ObjectNode objectNode = objectMapper.createObjectNode();
objectNode.put("type", "updateTasks"); objectNode.put("type", "updateTasks");
objectNode.set("data", objectMapper.valueToTree(galleryTasks)); objectNode.set("data", objectMapper.valueToTree(galleryTasks));
System.out.println(objectNode); log.debug("{}", objectNode);
sessions.forEach(s -> { sessions.forEach(s -> {
try { try {
s.sendMessage(new TextMessage(objectNode.toString())); s.sendMessage(new TextMessage(objectNode.toString()));
}catch (Exception ignore){} }catch (Exception e){
log.warn("WebSocket send updateTasks failed", e);
}
}); });
} }
@@ -1,8 +1,9 @@
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 java.io.IOException; import java.io.IOException;
import java.net.ServerSocket; import java.net.ServerSocket;
@@ -13,6 +14,7 @@ import java.util.regex.Pattern;
@Data @Data
@Slf4j
public class CustomUtil { public class CustomUtil {
public static final double ONE_KB = 1024; public static final double ONE_KB = 1024;
@@ -86,6 +88,7 @@ public class CustomUtil {
ignored.close(); ignored.close();
return i; return i;
}catch (IOException ignored) { }catch (IOException ignored) {
log.trace("port {} unavailable", i);
} }
} }
return -1; return -1;
@@ -95,7 +98,7 @@ public class CustomUtil {
try{ try{
response.sendError(404); response.sendError(404);
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.warn("sendError 404 failed", e);
} }
} }
} }
@@ -1,122 +1,82 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException; import org.apache.catalina.connector.ClientAbortException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRange;
import java.io.BufferedOutputStream; import java.io.*;
import java.io.File; import java.nio.charset.StandardCharsets;
import java.io.IOException; import java.util.List;
import java.io.RandomAccessFile;
@Slf4j
public class FileDownload { public class FileDownload {
public static void export(HttpServletRequest request, HttpServletResponse response, String path) { public static void export(HttpServletRequest request, HttpServletResponse response, String path) {
File file = new File(path); File file = new File(path);
if (!file.isFile()) {
String fileName = file.getName(); response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// Size and content refer to the same opened file, even if a cache is replaced.
try (RandomAccessFile input = new RandomAccessFile(file, "r")) {
long size = input.length();
long start = 0;
long end = size - 1;
boolean partial = false;
String range = request.getHeader(HttpHeaders.RANGE); String range = request.getHeader(HttpHeaders.RANGE);
if (range != null && range.startsWith("bytes=")) {
String rangeSeparator = "-";
// 开始下载位置
long startByte = 0;
// 结束下载位置
long endByte = file.length() - 1;
// 如果是断点续传
if (range != null && range.contains("bytes=") && range.contains(rangeSeparator)) {
// 设置响应状态码为 206
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
range = range.substring(range.lastIndexOf("=") + 1).trim();
String[] ranges = range.split(rangeSeparator);
try { try {
// 判断 range 的类型 List<HttpRange> ranges = HttpRange.parseRanges(range);
if (ranges.length == 1) { // Multiple ranges are intentionally ignored; send the full representation.
// 类型一:bytes=-2343 if (ranges.size() == 1) {
if (range.startsWith(rangeSeparator)) { if (size == 0) throw new IllegalArgumentException("empty file");
endByte = Long.parseLong(ranges[0]); start = ranges.getFirst().getRangeStart(size);
end = ranges.getFirst().getRangeEnd(size);
if (start < 0 || start >= size || end < start)
throw new IllegalArgumentException("unsatisfiable range");
partial = true;
} }
// 类型二:bytes=2343- } catch (IllegalArgumentException e) {
else if (range.endsWith(rangeSeparator)) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
startByte = Long.parseLong(ranges[0]); response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + size);
response.setContentLengthLong(0);
return;
} }
} }
// 类型三:bytes=22-2343 long remaining = end - start + 1;
else if (ranges.length == 2) { response.setStatus(partial ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
startByte = Long.parseLong(ranges[0]);
endByte = Long.parseLong(ranges[1]);
}
} catch (NumberFormatException e) {
// 传参不规范,则直接返回所有内容
startByte = 0;
endByte = file.length() - 1;
}
} else {
// 没有 ranges 即全部一次性传输,需要用 200 状态码,这一行应该可以省掉,因为默认返回是 200 状态码
response.setStatus(HttpServletResponse.SC_OK);
}
//要下载的长度(endByte 为总长度 -1,这时候要加回去)
long contentLength = endByte - startByte + 1;
//文件类型
String contentType = request.getServletContext().getMimeType(fileName);
if (StrUtil.isEmpty(contentType)) {
contentType = "attachment";
}
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
response.setHeader(HttpHeaders.CONTENT_TYPE, contentType); String mime = request.getServletContext().getMimeType(file.getName());
// 这里文件名换你想要的,inline 表示浏览器可以直接使用 response.setContentType(mime == null ? "application/octet-stream" : mime);
// 参考资料:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentType + ";filename=\"" + URLUtil.encode(fileName) + "\""); ContentDisposition.inline().filename(file.getName(), StandardCharsets.UTF_8).build().toString());
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)); response.setContentLengthLong(remaining);
// [要下载的开始位置]-[结束位置]/[文件总大小] if (partial)
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + startByte + rangeSeparator + endByte + "/" + file.length()); response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + size);
if ("HEAD".equalsIgnoreCase(request.getMethod()))
BufferedOutputStream outputStream; return;
RandomAccessFile randomAccessFile = null; input.seek(start);
//已传送数据大小 BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream());
long transmitted = 0; byte[] buffer = new byte[8192];
try { while (remaining > 0) {
randomAccessFile = new RandomAccessFile(file, "r"); int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
outputStream = new BufferedOutputStream(response.getOutputStream()); if (count == -1)
byte[] buff = new byte[4096]; throw new EOFException("File changed during download");
int len = 0; output.write(buffer, 0, count);
randomAccessFile.seek(startByte); remaining -= count;
while ((transmitted + len) <= contentLength && (len = randomAccessFile.read(buff)) != -1) {
outputStream.write(buff, 0, len);
transmitted += len;
// 本地测试, 防止下载速度过快
// Thread.sleep(1);
} }
// 处理不足 buff.length 部分 output.flush();
if (transmitted < contentLength) {
len = randomAccessFile.read(buff, 0, (int) (contentLength - transmitted));
outputStream.write(buff, 0, len);
}
outputStream.flush();
response.flushBuffer(); response.flushBuffer();
randomAccessFile.close();
// log.trace("下载完毕: {}-{}, 已传输 {}", startByte, endByte, transmitted);
} catch (ClientAbortException e) { } catch (ClientAbortException e) {
// ignore 用户停止下载 // The client cancelled its download.
// log.trace("用户停止下载: {}-{}, 已传输 {}", startByte, endByte, transmitted);
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); log.warn("文件下载失败: {}", path, e);
} finally { if (!response.isCommitted()) {
try { response.reset();
if (randomAccessFile != null) { response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
randomAccessFile.close();
}
} catch (IOException e) {
e.printStackTrace();
} }
} }
} }
@@ -1,34 +1,43 @@
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 java.nio.file.*;
import org.apache.http.client.entity.EntityBuilder; import java.util.concurrent.TimeUnit;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.http.client.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.http.client.methods.HttpPost; import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.http.entity.ContentType; import org.apache.hc.client5.http.entity.EntityBuilder;
import org.apache.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
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;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper; import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
public class GalleryUtil { public class GalleryUtil {
private static final Logger log = LoggerFactory.getLogger(GalleryUtil.class);
static String POST = "post"; static String POST = "post";
static String GET = "get"; static String GET = "get";
@@ -36,7 +45,22 @@ public class GalleryUtil {
static String JSON = "json"; static String JSON = "json";
static HashMap<String, String> gid2MpvKey = new HashMap<>(); static ConcurrentHashMap<String, String> gid2MpvKey = new ConcurrentHashMap<>();
/** Reusable HTTP client —不要每次请求新建 */
private static final CloseableHttpClient httpClient = HttpClients.custom()
.setDefaultRequestConfig(RequestConfig.custom()
.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 */
private static String ehentaiCookie = "";
public static void setEhentaiCookie(String cookie) {
ehentaiCookie = cookie;
}
/** /**
@@ -118,7 +142,7 @@ public class GalleryUtil {
//如果目标分辨率不存在,抛出错误 //如果目标分辨率不存在,抛出错误
if(!gallery.getAvailableResolution().containsKey(targetResolution)){ if(!gallery.getAvailableResolution().containsKey(targetResolution)){
System.out.println(gallery.getAvailableResolution()); log.warn("目标分辨率不存在,可用分辨率: {}", gallery.getAvailableResolution());
throw new ResolutionNotMatchException(targetResolution); throw new ResolutionNotMatchException(targetResolution);
} }
@@ -137,7 +161,7 @@ public class GalleryUtil {
if(downloadDoc.select("#db > p:nth-child(2) > strong").text().startsWith("#")) if(downloadDoc.select("#db > p:nth-child(2) > strong").text().startsWith("#"))
gallery.setStatus("已提交"); gallery.setStatus("已提交");
else { else {
System.out.println(downloadDoc.select("#db")); log.warn("下载提交失败: {}", downloadDoc.select("#db"));
gallery.setStatus("提交失败"); gallery.setStatus("提交失败");
} }
@@ -172,11 +196,15 @@ public class GalleryUtil {
return imageKeyCaches; return imageKeyCaches;
} }
public static synchronized String getMpvKey(String url){ public static String getMpvKey(String url){
String gid = String.valueOf(parseGid(url)); String gid = String.valueOf(parseGid(url));
if(!gid2MpvKey.containsKey(gid)) String key = gid2MpvKey.get(gid);
if (key == null) {
// refreshMpvKey writes the cache itself; never call it inside computeIfAbsent.
refreshMpvKey(url); refreshMpvKey(url);
return gid2MpvKey.get(gid); key = gid2MpvKey.get(gid);
}
return key;
} }
public static void refreshMpvKey(String url) { public static void refreshMpvKey(String url) {
@@ -188,8 +216,8 @@ public class GalleryUtil {
try { try {
content = requests(mpvUrl, "get", header, null); content = requests(mpvUrl, "get", header, null);
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("刷新mpvKey失败, url: {}", url, e);
gid2MpvKey.put(parseGid(url) + "", null); gid2MpvKey.remove(parseGid(url) + "");
return; return;
} }
Document document = Jsoup.parse(content); Document document = Jsoup.parse(content);
@@ -197,7 +225,7 @@ public class GalleryUtil {
String[] scripts = script.html().split("\n"); String[] scripts = script.html().split("\n");
String mpvKey = scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", ""); String mpvKey = scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", "");
gid2MpvKey.put(parseGid(url) + "", mpvKey); gid2MpvKey.put(parseGid(url) + "", mpvKey);
System.out.println("刷新key:" + mpvKey); log.info("刷新key:{}", mpvKey);
} }
public static String getImageUrl(String mpvKey, ImageKeyCache imageKeyCache) { public static String getImageUrl(String mpvKey, ImageKeyCache imageKeyCache) {
@@ -218,22 +246,44 @@ public class GalleryUtil {
return null; return null;
return jsonNode.get("i").asText(); return jsonNode.get("i").asText();
}catch (Exception e){ }catch (Exception e){
System.out.println("获取imgurl失败:" + imageKeyCache.getGid() + ":" + imageKeyCache.getPage()); log.error("获取imgurl失败:{}:{}", imageKeyCache.getGid(), imageKeyCache.getPage(), e);
e.printStackTrace();
return null; return null;
} }
} }
public static String convertImg(String imagePath, String suffix){ public static String convertImg(String imagePath, String suffix){
Runtime rt = Runtime.getRuntime(); Path source = Path.of(imagePath);
Path target = source.resolveSibling(source.getFileName().toString().replaceFirst("\\Q" + suffix + "\\E$", ".avif"));
if (source.equals(target)) return imagePath;
Path temporary = null;
Process process = null;
try { try {
Process exec = rt.exec(new String[]{"convert", imagePath, imagePath.replace(suffix, ".avif")}); temporary = Files.createTempFile(target.toAbsolutePath().getParent(), ".convert-", ".avif");
exec.waitFor(); process = new ProcessBuilder("convert", source.toString(), temporary.toString())
boolean ignored = new File(imagePath).delete(); .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
return imagePath.replace(suffix, ".avif"); if (!process.waitFor(60, TimeUnit.SECONDS))
} catch (IOException| InterruptedException e) { throw new IOException("图片转换超时");
System.out.println("文件" + imagePath + "转换失败"); if (process.exitValue() != 0 || Files.size(temporary) == 0)
throw new IOException("图片转换失败");
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
Files.deleteIfExists(source);
return target.toString();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null; return null;
} catch (IOException e) {
log.warn("文件{}转换失败", imagePath, e);
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
if (temporary != null) {
try { Files.deleteIfExists(temporary); }
catch (IOException e) { log.warn("清理图片转换临时文件失败", e); }
}
} }
} }
@@ -263,7 +313,6 @@ public class GalleryUtil {
* @throws IOException 可能会抛出IO错误 * @throws IOException 可能会抛出IO错误
*/ */
public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException { public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse httpResponse; CloseableHttpResponse httpResponse;
if(headers == null) if(headers == null)
headers = new HashMap<>(); headers = new HashMap<>();
@@ -271,7 +320,7 @@ public class GalleryUtil {
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"); headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"); headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
if(url.contains("hentai")) { if(url.contains("hentai")) {
headers.put("Cookie", "ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1"); headers.put("Cookie", ehentaiCookie);
headers.put("Upgrade-Insecure-Requests", "1"); headers.put("Upgrade-Insecure-Requests", "1");
} }
@@ -301,24 +350,22 @@ public class GalleryUtil {
} }
httpResponse = httpClient.execute(httpPost); httpResponse = httpClient.execute(httpPost);
} }
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){ try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) {
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
String str; String str;
while ((str = reader.readLine()) != null) while ((str = reader.readLine()) != null)
stringBuilder.append(str).append("\n"); stringBuilder.append(str).append("\n");
} else{
System.out.println(url + ":" + statusCode);
} }
} else {
httpClient.close(); log.warn("{}:{}", url, statusCode);
httpResponse.close(); }
return stringBuilder.toString(); return stringBuilder.toString();
} }
}
public static Integer parseGid(String link){ public static Integer parseGid(String link){
try { try {
@@ -0,0 +1,67 @@
package com.lion.lionwebsite.Util;
import java.io.*;
import java.net.URI;
import java.net.URLConnection;
import java.nio.file.*;
import java.util.Locale;
import java.util.concurrent.Callable;
/** Downloads into private temporary files and publishes only completed images. */
public final class ImageFileCache {
private static final SingleFlight<Path, Path> downloads = new SingleFlight<>();
private ImageFileCache() { }
public static Path find(Path directory, String name) {
for (String suffix : new String[]{".avif", ".gif"}) {
Path path = directory.resolve(name + suffix);
if (Files.isRegularFile(path) && path.toFile().length() > 0)
return path;
}
return null;
}
public static Path get(Path directory, String name, Callable<String> sourceUrl) throws Exception {
Path key = directory.resolve(name).toAbsolutePath().normalize();
return downloads.run(key, () -> {
Path cached = find(directory, name);
if (cached != null) return cached;
Files.createDirectories(directory);
String url = sourceUrl.call();
if (url == null) throw new IOException("图片地址不存在");
URI source = new URI(url);
boolean gif = source.getPath().toLowerCase(Locale.ROOT).endsWith(".gif");
String suffix = gif ? ".gif" : ".img";
Path temporary = Files.createTempFile(directory, ".download-", suffix);
Path converted = null;
try {
URLConnection connection = source.toURL().openConnection();
connection.setConnectTimeout(5_000);
connection.setReadTimeout(15_000);
try (InputStream input = connection.getInputStream();
OutputStream output = Files.newOutputStream(temporary)) {
input.transferTo(output);
}
if (Files.size(temporary) == 0) throw new IOException("图片内容为空");
if (gif) {
converted = temporary;
} else {
String result = GalleryUtil.convertImg(temporary.toString(), suffix);
if (result == null) throw new IOException("图片转换失败");
converted = Path.of(result);
}
Path target = directory.resolve(name + (gif ? ".gif" : ".avif"));
try {
Files.move(converted, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(converted, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
} finally {
Files.deleteIfExists(temporary);
if (converted != null && !converted.equals(temporary)) Files.deleteIfExists(converted);
}
});
}
}
@@ -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){
@@ -56,12 +57,24 @@ public class Response {
return this; return this;
} }
public String getResult(){ public String getData(){
return result.get("data").asText(); JsonNode node = result.get("data");
return node == null ? null : node.asText();
} }
/**
* @deprecated Use {@link #getData()} instead. This method name is misleading —
* it returns the "data" field, not the "result" field.
*/
@Deprecated
public String getResult(){
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,30 @@
package com.lion.lionwebsite.Util;
import java.util.concurrent.*;
/** Concurrent callers for the same key share one in-flight operation, including its failure. */
public final class SingleFlight<K, V> {
private final ConcurrentHashMap<K, CompletableFuture<V>> running = new ConcurrentHashMap<>();
public V run(K key, Callable<V> operation) throws Exception {
CompletableFuture<V> mine = new CompletableFuture<>();
CompletableFuture<V> existing = running.putIfAbsent(key, mine);
if (existing != null) {
try { return existing.get(); }
catch (ExecutionException e) {
if (e.getCause() instanceof Exception cause) throw cause;
throw new IllegalStateException(e.getCause());
}
}
try {
V result = operation.call();
mine.complete(result);
return result;
} catch (Exception | Error e) {
mine.completeExceptionally(e);
throw e;
} finally {
running.remove(key, mine);
}
}
}
+26 -16
View File
@@ -25,19 +25,29 @@ spring:
enabled: true enabled: true
#personal-service: # Application secrets — override via environment variables or external config in production
# StoragePath: /storage/ gallery:
# cookie: "ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1"
#gallery-manage-service:
# target-path: /root/gallery/ remote:
# cache-size: 100 ip: "5.255.110.45"
#
#remote-service: local:
# ip: 5.255.110.45 dou-nai-clash: "https://aaaa.gay/link/{key}?client=clashmeta"
# dou-nai-v2ray: "https://aaaa.gay/link/{key}?client=v2"
#local-service:
# DouNaiClash: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta subscription:
# DouNaiV2ray: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2 cache-root: sub/accounts
# upstream:
#bot: # Use environment variables or an external config file in production.
# token: 5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA v2-url-template: "https://aaaa.gay/link/{key}?client=v2"
clash-url-template: "https://aaaa.gay/link/{key}?client=clashmeta"
high-multiplier-threshold: 2.0
refresh-interval-ms: 86400000
standby:
sync-enabled: "${SUBSCRIPTION_STANDBY_SYNC_ENABLED:false}"
sync-secret: "${SUBSCRIPTION_SYNC_SECRET:}"
retry-interval-ms: 60000
bot:
token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
@@ -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,57 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.*;
import com.lion.lionwebsite.Dao.cache.ImageCacheMapper;
import com.lion.lionwebsite.Domain.*;
import com.lion.lionwebsite.Util.GalleryUtil;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class GallerySubmissionTest {
@Test
void immediateCompletionSeesPersistedTask() throws Exception {
checkSubmission((byte) 0);
}
@Test
void timeoutRetainsTaskForRetry() throws Exception {
checkSubmission((byte) -1);
}
private void checkSubmission(byte ack) throws Exception {
GalleryMapper galleries = mock(GalleryMapper.class);
UserMapper users = mock(UserMapper.class);
RemoteService remote = mock(RemoteService.class);
CustomConfigurationMapper configuration = mock(CustomConfigurationMapper.class);
GalleryManageService service = new GalleryManageService(galleries, mock(CollectMapper.class), configuration,
users, mock(ShareFileMapper.class), mock(ImageCacheMapper.class), remote, mock(PushService.class));
User user = new User();
user.setId(7);
user.setUsername("test");
when(users.selectUserByAuthCode("test")).thenReturn(user);
AtomicReference<Gallery> saved = new AtomicReference<>();
when(galleries.selectGalleryByGid(123)).thenAnswer(call -> saved.get());
doAnswer(call -> { saved.set(call.getArgument(0)); return null; }).when(galleries).insertGallery(any());
Gallery gallery = new Gallery();
gallery.setGid(123);
gallery.setName("sample [123]");
gallery.setStatus("已提交");
when(remote.addGalleryToQueue(any())).thenAnswer(call -> {
assertNotNull(saved.get(), "node status must find a persisted record");
assertEquals(7, saved.get().getDownloader());
if (ack == 0) saved.get().setStatus("下载完成");
return ack;
});
try (var parser = mockStatic(GalleryUtil.class)) {
parser.when(() -> GalleryUtil.parse("https://example.org/g/123/key/", true, "original")).thenReturn(gallery);
String response = service.createTask("https://example.org/g/123/key/", "original", "test");
assertNotNull(saved.get());
assertTrue(response.contains(ack == 0 ? "success" : "任务已保存"));
assertEquals(ack == 0 ? "下载完成" : "已提交", saved.get().getStatus());
verify(galleries, never()).deleteGalleryByGid(any());
verify(galleries, times(1)).insertGallery(gallery);
}
}
}
@@ -0,0 +1,27 @@
package com.lion.lionwebsite.Service;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.*;
import java.util.HashMap;
import static org.junit.jupiter.api.Assertions.*;
class PersonalArchiveTest {
@Test void archiveContainsCompleteFilesAndCanBeOpenedImmediately(@TempDir Path root) throws Exception {
Path source = Files.createDirectory(root.resolve("source"));
Files.createDirectory(source.resolve("nested"));
for (int i = 0; i < 100; i++) Files.writeString(source.resolve("nested/" + i + ".txt"), "content-" + i);
Path archive = root.resolve("result.tar");
PersonalService.writeTar(source, archive);
var contents = new HashMap<String, String>();
try (var input = new TarArchiveInputStream(Files.newInputStream(archive))) {
org.apache.commons.compress.archivers.tar.TarArchiveEntry entry;
while ((entry = input.getNextTarEntry()) != null) {
if (!entry.isDirectory()) contents.put(entry.getName(), new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
}
}
assertEquals(100, contents.size());
for (int i = 0; i < 100; i++) assertEquals("content-" + i, contents.get("nested/" + i + ".txt"));
}
}
@@ -0,0 +1,62 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Message.*;
import io.netty.channel.*;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class RemoteServiceTest {
private static ResponseMessage response(int messageId, byte result) {
ResponseMessage message = new ResponseMessage();
message.setMessageId(messageId);
message.setResult(result);
return message;
}
private RemoteService service() {
return new RemoteService(mock(GalleryMapper.class), mock(PushService.class),
mock(WebSocketService.class), mock(SubscriptionStandbySnapshotService.class));
}
@Test void immediateResponseHasRegisteredWaiter() {
RemoteService service = service();
EmbeddedChannel channel = new EmbeddedChannel(new ChannelOutboundHandlerAdapter() {
@Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
AbstractMessage request = (AbstractMessage) msg;
ctx.fireChannelRead(response(request.messageId, (byte) 0));
promise.setSuccess();
}
}, service.new MyChannelInboundHandlerAdapter());
service.channel = channel;
try {
assertEquals(0, service.checkAvailability());
assertTrue(service.promiseHashMap.isEmpty());
} finally { service.shutdownResources(); channel.finishAndReleaseAll(); }
}
@Test void timeoutAndWriteFailureRemoveWaiters() {
RemoteService service = service();
EmbeddedChannel channel = new EmbeddedChannel(new ChannelOutboundHandlerAdapter() {
@Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
promise.setSuccess(); // no reply
}
});
service.channel = channel;
try {
assertEquals(-1, service.sendRequest(new AvailableCheckMessage(), 1, TimeUnit.MILLISECONDS));
assertTrue(service.promiseHashMap.isEmpty());
channel.pipeline().addLast(new ChannelOutboundHandlerAdapter() {
@Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
promise.setFailure(new IOException("test failure"));
}
});
assertEquals(-1, service.sendRequest(new AvailableCheckMessage(), 1, TimeUnit.SECONDS));
assertTrue(service.promiseHashMap.isEmpty());
} finally { service.shutdownResources(); channel.finishAndReleaseAll(); }
}
}
@@ -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,53 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SubscriptionRefreshServiceTest {
@Test
void stalledDownloadDoesNotHoldStateLockAndStaleResultIsDiscarded(@TempDir Path directory) throws Exception {
SubMapper mapper = mock(SubMapper.class);
SubscriptionAccount original = new SubscriptionAccount(1, "sample", "old", false, true,
null, null, null, null, 0, null, null);
SubscriptionAccount changed = new SubscriptionAccount(1, "sample", "new", false, true,
null, null, null, null, 0, null, null);
when(mapper.selectSubscriptionAccount(1)).thenReturn(original);
var coordinator = new SubscriptionStateCoordinator();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
var service = new SubscriptionRefreshService(mapper, coordinator) {
@Override List<String> download(String url) throws java.io.IOException {
started.countDown();
try {
if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("test timed out");
} catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new java.io.IOException(e); }
return url.contains("v2") ? List.of("bm9kZQ==") : List.of("proxies:");
}
};
service.v2UrlTemplate = "https://example.invalid/v2/{key}";
service.clashUrlTemplate = "https://example.invalid/clash/{key}";
service.cacheRoot = directory.toString();
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
Future<Boolean> refresh = worker.submit(() -> service.refresh(1));
assertTrue(started.await(2, TimeUnit.SECONDS));
var lock = coordinator.writeLock();
assertTrue(lock.tryLock(1, TimeUnit.SECONDS), "management must remain available during downloads");
try {
when(mapper.selectSubscriptionAccount(1)).thenReturn(changed);
service.invalidateCache(1);
} finally { lock.unlock(); }
release.countDown();
assertFalse(refresh.get(2, TimeUnit.SECONDS));
assertFalse(service.hasCompleteCache(1));
verify(mapper, never()).markSubscriptionRefreshSuccess(any());
} finally { release.countDown(); worker.shutdownNow(); }
}
}
@@ -0,0 +1,67 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Message.SubscriptionSnapshotMessage;
import com.lion.lionwebsite.Message.SubscriptionSnapshotPayload;
import com.lion.lionwebsite.Util.CustomUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Base64;
import java.util.zip.GZIPInputStream;
import static org.junit.jupiter.api.Assertions.*;
class SubscriptionStandbySnapshotServiceTest {
@Test
void buildsDeterministicSnapshotWithoutPlainPublicKeys(@TempDir Path directory) throws Exception {
SubscriptionAccount account = new SubscriptionAccount(1, "account", "upstream-secret", true, true,
null, null, null, null, 2, null, null);
ArrayList<SubscriptionAccount> accounts = new ArrayList<>(java.util.List.of(account));
ArrayList<SubBind> bindings = new ArrayList<>(java.util.List.of(
new SubBind("public-key-a", "user-a", 1, "account", true, true),
new SubBind("public-key-b", "user-b", 1, "account", true, true)));
SubMapper mapper = (SubMapper) Proxy.newProxyInstance(SubMapper.class.getClassLoader(),
new Class<?>[]{SubMapper.class}, (proxy, method, args) -> switch (method.getName()) {
case "selectAllSubscriptionAccounts" -> accounts;
case "selectAllSubBind" -> bindings;
default -> throw new UnsupportedOperationException(method.getName());
});
SubscriptionStateCoordinator coordinator = new SubscriptionStateCoordinator();
Path accountDirectory = directory.resolve("1");
Files.createDirectories(accountDirectory);
Path v2 = accountDirectory.resolve("v2ray.txt");
Path clash = accountDirectory.resolve("clash.yaml");
Files.writeString(v2, "v2-content", StandardCharsets.UTF_8);
Files.writeString(clash, "clash-content", StandardCharsets.UTF_8);
SubscriptionStandbySnapshotService service = new SubscriptionStandbySnapshotService(mapper, coordinator);
service.syncSecret = "test-sync-secret";
service.cacheRoot = directory.toString();
SubscriptionSnapshotMessage first = service.build();
SubscriptionSnapshotMessage second = service.build();
assertEquals(first.getRevision(), second.getRevision());
byte[] compressed = Base64.getDecoder().decode(first.getPayloadBase64());
byte[] json;
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
json = gzip.readAllBytes();
}
String rawPayload = new String(json, StandardCharsets.UTF_8);
assertFalse(rawPayload.contains("public-key-a"));
assertFalse(rawPayload.contains("public-key-b"));
assertFalse(rawPayload.contains("upstream-secret"));
SubscriptionSnapshotPayload payload = CustomUtil.objectMapper.readValue(json, SubscriptionSnapshotPayload.class);
assertEquals(1, payload.getAccounts().size());
assertEquals(2, payload.getBindings().size());
assertTrue(payload.getAccounts().getFirst().isFilterHighMultiplier());
}
}
@@ -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,62 @@
package com.lion.lionwebsite.Util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.nio.file.*;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
class FileDownloadTest {
@TempDir Path directory;
private MockHttpServletResponse download(String range, int size, String method) throws Exception {
byte[] bytes = new byte[size];
for (int i = 0; i < size; i++) bytes[i] = (byte) i;
Path file = directory.resolve("sample.bin");
Files.write(file, bytes);
MockHttpServletRequest request = new MockHttpServletRequest(method, "/file");
if (range != null) request.addHeader("Range", range);
MockHttpServletResponse response = new MockHttpServletResponse();
FileDownload.export(request, response, file.toString());
return response;
}
@Test void smallRangeDoesNotOverread() throws Exception {
var response = download("bytes=10-109", 10_000, "GET");
assertEquals(206, response.getStatus());
assertEquals(100, response.getContentAsByteArray().length);
assertEquals("bytes 10-109/10000", response.getHeader("Content-Range"));
assertEquals(10, response.getContentAsByteArray()[0]);
assertEquals(109, response.getContentAsByteArray()[99]);
}
@Test void supportsSuffixAndOpenEndedRanges() throws Exception {
var suffix = download("bytes=-10", 100, "GET");
assertEquals("bytes 90-99/100", suffix.getHeader("Content-Range"));
assertArrayEquals(download("bytes=90-", 100, "GET").getContentAsByteArray(), suffix.getContentAsByteArray());
assertEquals(10, suffix.getContentAsByteArray().length);
}
@Test void clampsEndAndRejectsInvalidRanges() throws Exception {
assertEquals(10, download("bytes=90-999", 100, "GET").getContentAsByteArray().length);
for (String range : Arrays.asList("bytes=100-", "bytes=9-2", "bytes=-0", "bytes=oops")) {
var response = download(range, 100, "GET");
assertEquals(416, response.getStatus(), range);
assertEquals("bytes */100", response.getHeader("Content-Range"));
assertEquals(0, response.getContentAsByteArray().length);
}
}
@Test void handlesFullEmptyHeadAndMultipleRanges() throws Exception {
var full = download(null, 100, "GET");
assertEquals(200, full.getStatus());
assertNull(full.getHeader("Content-Range"));
assertEquals(100, full.getContentAsByteArray().length);
assertEquals(0, download(null, 0, "GET").getContentAsByteArray().length);
assertEquals(416, download("bytes=0-", 0, "GET").getStatus());
assertEquals(0, download(null, 100, "HEAD").getContentAsByteArray().length);
assertEquals(200, download("bytes=0-1,5-6", 100, "GET").getStatus());
}
}
@@ -0,0 +1,38 @@
package com.lion.lionwebsite.Util;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class GalleryKeyCacheTest {
@Test void coldCacheAllowsRefreshToPopulateIt() {
String url = "https://example.org/g/987654321/key/";
GalleryUtil.gid2MpvKey.remove("987654321");
try (var methods = mockStatic(GalleryUtil.class)) {
methods.when(() -> GalleryUtil.parseGid(url)).thenReturn(987654321);
methods.when(() -> GalleryUtil.getMpvKey(url)).thenCallRealMethod();
methods.when(() -> GalleryUtil.refreshMpvKey(url)).thenAnswer(call -> {
GalleryUtil.gid2MpvKey.put("987654321", "cached-key");
return null;
});
assertEquals("cached-key", GalleryUtil.getMpvKey(url));
assertEquals("cached-key", GalleryUtil.getMpvKey(url));
methods.verify(() -> GalleryUtil.refreshMpvKey(url), times(1));
} finally { GalleryUtil.gid2MpvKey.remove("987654321"); }
}
@Test void failedRefreshRemovesOldKeyWithoutInsertingNull() {
String url = "https://example.org/g/987654321/key/";
GalleryUtil.gid2MpvKey.put("987654321", "old-key");
try (var methods = mockStatic(GalleryUtil.class)) {
methods.when(() -> GalleryUtil.parseGid(url)).thenReturn(987654321);
methods.when(() -> GalleryUtil.refreshMpvKey(url)).thenCallRealMethod();
methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
.thenThrow(new IOException("test failure"));
assertDoesNotThrow(() -> GalleryUtil.refreshMpvKey(url));
assertFalse(GalleryUtil.gid2MpvKey.containsKey("987654321"));
} finally { GalleryUtil.gid2MpvKey.remove("987654321"); }
}
}
@@ -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,63 @@
package com.lion.lionwebsite.Util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class ImageFileCacheTest {
@Test void publishesClosedGifAndReusesIt(@TempDir Path root) throws Exception {
Path source = root.resolve("source.gif");
byte[] content = "GIF89a test image".getBytes(java.nio.charset.StandardCharsets.UTF_8);
Files.write(source, content);
Path cache = root.resolve("cache");
Path result = ImageFileCache.get(cache, "1", () -> source.toUri().toString());
assertArrayEquals(content, Files.readAllBytes(result));
assertEquals(result, ImageFileCache.get(cache, "1", () -> { throw new AssertionError("cache must avoid source access"); }));
try (var files = Files.list(cache)) { assertEquals(1, files.count()); }
}
@Test void failedDownloadLeavesNoPublishedOrTemporaryFile(@TempDir Path root) throws Exception {
Path cache = root.resolve("cache");
assertThrows(Exception.class, () -> ImageFileCache.get(cache, "1", () -> root.resolve("missing.gif").toUri().toString()));
assertNull(ImageFileCache.find(cache, "1"));
try (var files = Files.list(cache)) { assertEquals(0, files.count()); }
Path source = root.resolve("retry.gif");
Files.writeString(source, "GIF89a retry");
assertNotNull(ImageFileCache.get(cache, "1", () -> source.toUri().toString()));
}
@Test void concurrentCallersShareTheSameOperation() throws Exception {
SingleFlight<String, String> flight = new SingleFlight<>();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
AtomicReference<Thread> followerThread = new AtomicReference<>();
ExecutorService workers = Executors.newFixedThreadPool(2);
try {
Future<String> leader = workers.submit(() -> flight.run("image", () -> {
calls.incrementAndGet(); entered.countDown();
if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test timed out");
return "completed";
}));
assertTrue(entered.await(2, TimeUnit.SECONDS));
Future<String> follower = workers.submit(() -> {
followerThread.set(Thread.currentThread());
return flight.run("image", () -> { calls.incrementAndGet(); return "duplicate"; });
});
assertTimeoutPreemptively(Duration.ofSeconds(2), () -> {
while (followerThread.get() == null || followerThread.get().getState() != Thread.State.WAITING) {
assertFalse(follower.isDone(), "follower must wait for the first operation");
Thread.sleep(1);
}
});
release.countDown();
assertEquals("completed", leader.get(2, TimeUnit.SECONDS));
assertEquals("completed", follower.get(2, TimeUnit.SECONDS));
assertEquals(1, calls.get());
} finally { release.countDown(); workers.shutdownNow(); }
}
}
@@ -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("下载失败"));
}
}