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
chuzhongzai 29f1532dea 注册上次新增消息类型的反射信息,新增测试接口 2026-01-23 21:04:31 +08:00
chuzhongzai b500691008 新增主动重连功能以及定时检测连接有效性 2026-01-20 22:28:35 +08:00
lion bcb7a7a6dd 添加LinkPreview类到预编译,防止可能出现的错误 2025-11-11 15:41:47 +08:00
chuzhongzai 8b99608193 监听唤醒端口允许漂移,防止唤醒端口被其他程序占用时无法实现唤醒 2025-08-31 14:11:28 +08:00
lion 3bdaddaead 调整初始化顺序,避免重连时有未完成的任务导致初始化失败;修复发送未完成任务时不带名字的bug 2025-08-29 16:15:59 +08:00
lion d7ba01be2d 子节点恢复时发送未完成任务 2025-03-22 23:47:21 +08:00
lion c1ff2938bf 增加cookie检测,失效时发送消息 2025-02-22 14:44:19 +08:00
lion 2df627eab4 更新cookie 2025-02-21 18:39:35 +08:00
lion 00187100f2 去掉访问日志;更新e站cookie 2024-11-22 19:48:00 +08:00
chuzhongzai e8cc4b1097 更新打包固件;流量倍率过滤改为2倍;修复缩略图加载失败;更新e站搜索参数 2024-11-03 15:02:49 +08:00
lion 0838434d64 修复过滤高倍率节点的bug 2024-10-19 14:47:50 +08:00
chuzhongzai 325b9b321f 在线预览相关图片由webp格式换成avif,优化部分代码 2024-09-16 16:40:00 +08:00
chuzhongzai 25cd6d73b6 新增发送消息接口 2024-08-20 17:09:24 +08:00
chuzhongzai 0cc583b910 升级springboot版本 2024-08-19 18:48:03 +08:00
chuzhongzai cca2980efa 重置未完成任务时包括压缩中任务 2024-06-08 03:11:52 +08:00
chuzhongzai 27ceed8f4a 访问域名时跳转到/index 2024-06-02 22:41:22 +08:00
chuzhongzai 78747c60eb 修复ip查询地址的处理 2024-03-04 22:05:58 +08:00
chuzhongzai c31b3ff5f0 ip地址信息由查询本地文件改为访问ip138 2024-01-14 16:23:29 +08:00
chuzhongzai 7057b28ff0 修改数据库路径;将并发控制由synchronize换成callable 2024-01-03 14:36:23 +08:00
chuzhongzai fd016c7809 去除前端文件,交给nginx托管;无法在线看时返回报错; 2023-12-29 15:39:26 +08:00
chuzhongzai 6f0049442f 去除个人订阅文件 2023-12-28 16:09:34 +08:00
chuzhongzai 1516226192 前端获取任务进度方式由轮询改为Websocket;去除无用任务状态; 2023-12-28 16:07:22 +08:00
chuzhongzai 54b1dd47a6 抽取图片格式转换方法;封面图加上缓存;更换缓存存放位置 2023-12-27 23:16:58 +08:00
chuzhongzai 07b5b1c376 删除多余代码 2023-12-27 18:02:40 +08:00
chuzhongzai 2c0ed4be58 去除远程图片查询相关代码;去除标签;优化部分代码 2023-12-27 17:41:40 +08:00
chuzhongzai 793ae12348 去除远程图片查询相关代码 2023-12-27 17:34:00 +08:00
chuzhongzai 97c1e91f44 去除下载模式,仅分成下载了或者未下载两种模式 2023-12-27 17:20:22 +08:00
chuzhongzai bb6a247381 对获取图片的接口进行并发控制,同时只允许一个;galleryForQuery新增gid字段;gallery新增thumb_link字段 2023-12-25 20:46:22 +08:00
chuzhongzai 5cb2b7005b 上线动态获取图片后端,使用mpvKey以及imgKey请求图片链接; 2023-12-24 21:04:53 +08:00
chuzhongzai 1db8cdf264 请求方法里使用body里的payload指定post请求时数据的提交方式;优化创建任务失败判断逻辑 2023-12-23 15:55:36 +08:00
chuzhongzai f451aaaf9c 去除本子更新;去除获取本子首页缩略图接口,改为获取页数为0的在线图片; 2023-12-23 15:34:39 +08:00
chuzhongzai 9df2b7994b 增加第二个数据库用于存放缓存相关数据;移除部分残留代码;规范化Request方法,更新UA;优化部分代码; 2023-12-23 01:10:23 +08:00
chuzhongzai e1afc3a6c5 去除链接伪装;更新前端,正式启用下载模式(仅预览,仅下载,全部);删除只能全部删除;下载前能够查看缩略图 2023-12-21 18:39:19 +08:00
chuzhongzai 2c08552986 更新前端,调整部分代码 2023-12-20 21:33:27 +08:00
chuzhongzai fd04d61885 使用AOT;去除thymeleaf;移动前端文件路径 2023-12-16 01:01:49 +08:00
chuzhongzai 34f8cb73b2 使用AOT;去除thymeleaf;移动前端文件路径 2023-12-16 01:01:32 +08:00
chuzhongzai a7bfe1df29 订阅更新记录新增地址 2023-12-08 14:58:24 +08:00
chuzhongzai b78939528a 修改验证消息;修复响应结果判断错误得问题 2023-12-04 16:09:09 +08:00
chuzhongzai a3b79fc0ea 新增订阅绑定;使用单例ObjectMapper; 2023-12-01 15:28:01 +08:00
chuzhongzai 39e4e606b0 修改依赖注入方式,改为构造函数注入;更新依赖; 2023-11-25 15:31:38 +08:00
chuzhongzai 8742e59ebe 修改文件大小转换,使其支持XiB单位;新增下载完成通知;新增任务状态:压缩中 2023-09-14 11:37:19 +08:00
chuzhongzai e2fce692bd 修复本子名称可能为空的bug;修改下载完成条件的判断 2023-09-10 12:31:56 +08:00
chuzhongzai 32d98b3d84 添加保活信息;添加tg机器人,推送关键事件; 2023-09-02 15:19:29 +08:00
111 changed files with 5667 additions and 2229 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
================================================================================
+71 -38
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.1.1</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>
@@ -14,26 +14,23 @@
<name>LionWebsite</name> <name>LionWebsite</name>
<description>LionWebsite</description> <description>LionWebsite</description>
<properties> <properties>
<java.version>17</java.version> <java.version>21</java.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId> <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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.2</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.46</version>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
@@ -44,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.2</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.11</version> <version>5.8.47</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -66,22 +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>
<groupId>org.im4java</groupId>
<artifactId>im4java</artifactId>
<version>1.4.0</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -92,24 +75,82 @@
<dependency> <dependency>
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId> <artifactId>commons-compress</artifactId>
<version>1.21</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>
<groupId>io.netty</groupId> <groupId>io.netty</groupId>
<artifactId>netty-all</artifactId> <artifactId>netty-all</artifactId>
<version>4.1.86.Final</version> </dependency>
<dependency>
<groupId>com.github.pengrad</groupId>
<artifactId>java-telegram-bot-api</artifactId>
<version>10.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency> </dependency>
</dependencies> </dependencies>
<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>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>1.1.8</version>
<configuration>
<imageName>lionwebsite</imageName>
<buildArgs>
<arg>-H:+ReportExceptionStackTraces</arg>
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.sqlite.util.ProcessRunner</arg>
</buildArgs>
<metadataRepository>
<enabled>true</enabled>
</metadataRepository>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
@@ -122,14 +163,6 @@
</excludes> </excludes>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>
+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;
@@ -0,0 +1,44 @@
package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Domain.*;
import com.lion.lionwebsite.Message.*;
import com.lion.lionwebsite.Util.GalleryUtil;
import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.model.*;
import com.pengrad.telegrambot.model.User;
import com.pengrad.telegrambot.response.SendResponse;
import com.zaxxer.hikari.HikariConfig;
import jakarta.annotation.PostConstruct;
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.Configuration;
@Configuration
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
PageNameCache.class, ShareFile.class, User.class,
SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class,
Chat.class, MessageEntity.class,
AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class,
IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, AvailableCheckMessage.class,
SubscriptionSnapshotMessage.class, SubscriptionSnapshotPayload.class, SubscriptionAccountSnapshot.class,
SubscriptionBindingSnapshot.class, LinkPreviewOptions.class})
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
public TelegramBot getTelegramBot(){
return new TelegramBot(botToken);
}
}
@@ -1,11 +1,9 @@
package com.lion.lionwebsite.Configuration; package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Domain.MaskDomain;
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 com.lion.lionwebsite.Util.CustomUtil; import lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource;
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;
@@ -13,14 +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 {
@Resource final TaskHandlerInterceptor taskHandlerInterceptor;
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");
} }
@@ -33,14 +31,4 @@ public class InterceptorConfiguration implements WebMvcConfigurer {
public HandlerInterceptor getHumanInterceptor(){ public HandlerInterceptor getHumanInterceptor(){
return new HumanInterceptor(); return new HumanInterceptor();
} }
@Bean
public CustomUtil getParameterUtil(){
CustomUtil customUtil = new CustomUtil();
MaskDomain[] maskDomains = new MaskDomain[2];
maskDomains[0] = new MaskDomain("exhentai.org", "element-plus.org");
maskDomains[1] = new MaskDomain("e-hentai.org", "element.org");
customUtil.setMaskDomains(maskDomains);
return customUtil;
}
} }
@@ -0,0 +1,268 @@
package com.lion.lionwebsite.Configuration;
import org.apache.commons.logging.LogFactory;
import org.apache.ibatis.annotations.DeleteProvider;
import org.apache.ibatis.annotations.InsertProvider;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.UpdateProvider;
import org.apache.ibatis.cache.decorators.FifoCache;
import org.apache.ibatis.cache.decorators.LruCache;
import org.apache.ibatis.cache.decorators.SoftCache;
import org.apache.ibatis.cache.decorators.WeakCache;
import org.apache.ibatis.cache.impl.PerpetualCache;
import org.apache.ibatis.javassist.util.proxy.ProxyFactory;
import org.apache.ibatis.javassist.util.proxy.RuntimeSupport;
import org.apache.ibatis.logging.Log;
import org.apache.ibatis.logging.commons.JakartaCommonsLoggingImpl;
import org.apache.ibatis.logging.jdk14.Jdk14LoggingImpl;
import org.apache.ibatis.logging.log4j2.Log4j2Impl;
import org.apache.ibatis.logging.nologging.NoLoggingImpl;
import org.apache.ibatis.logging.slf4j.Slf4jImpl;
import org.apache.ibatis.logging.stdout.StdOutImpl;
import org.apache.ibatis.reflection.TypeParameterResolver;
import org.apache.ibatis.scripting.defaults.RawLanguageDriver;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.mapper.MapperFactoryBean;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.beans.PropertyValue;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.config.ConstructorArgumentValues;
import org.springframework.beans.factory.support.MergedBeanDefinitionPostProcessor;
import org.springframework.beans.factory.support.RegisteredBean;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.core.ResolvableType;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(MyBatisNativeConfiguration.MyBaitsRuntimeHintsRegistrar.class)
public class MyBatisNativeConfiguration {
@Bean
MyBatisBeanFactoryInitializationAotProcessor myBatisBeanFactoryInitializationAotProcessor() {
return new MyBatisBeanFactoryInitializationAotProcessor();
}
@Bean
static MyBatisMapperFactoryBeanPostProcessor myBatisMapperFactoryBeanPostProcessor() {
return new MyBatisMapperFactoryBeanPostProcessor();
}
static class MyBaitsRuntimeHintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
Stream.of(RawLanguageDriver.class,
XMLLanguageDriver.class,
RuntimeSupport.class,
ProxyFactory.class,
Slf4jImpl.class,
Log.class,
JakartaCommonsLoggingImpl.class,
Log4j2Impl.class,
Jdk14LoggingImpl.class,
StdOutImpl.class,
NoLoggingImpl.class,
SqlSessionFactory.class,
PerpetualCache.class,
FifoCache.class,
LruCache.class,
SoftCache.class,
WeakCache.class,
SqlSessionFactoryBean.class,
ArrayList.class,
HashMap.class,
TreeSet.class,
HashSet.class
).forEach(x -> hints.reflection().registerType(x, MemberCategory.values()));
Stream.of(
"org/apache/ibatis/builder/xml/*.dtd",
"org/apache/ibatis/builder/xml/*.xsd"
).forEach(hints.resources()::registerPattern);
}
}
static class MyBatisBeanFactoryInitializationAotProcessor
implements BeanFactoryInitializationAotProcessor, BeanRegistrationExcludeFilter {
private final Set<Class<?>> excludeClasses = new HashSet<>();
MyBatisBeanFactoryInitializationAotProcessor() {
excludeClasses.add(MapperScannerConfigurer.class);
}
@Override public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) {
return excludeClasses.contains(registeredBean.getBeanClass());
}
@Override
public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
String[] beanNames = beanFactory.getBeanNamesForType(MapperFactoryBean.class);
if (beanNames.length == 0) {
return null;
}
return (context, code) -> {
RuntimeHints hints = context.getRuntimeHints();
for (String beanName : beanNames) {
BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName.substring(1));
PropertyValue mapperInterface = beanDefinition.getPropertyValues().getPropertyValue("mapperInterface");
if (mapperInterface != null && mapperInterface.getValue() != null) {
Class<?> mapperInterfaceType = (Class<?>) mapperInterface.getValue();
if (mapperInterfaceType != null) {
registerReflectionTypeIfNecessary(mapperInterfaceType, hints);
hints.proxies().registerJdkProxy(mapperInterfaceType);
hints.resources()
.registerPattern(mapperInterfaceType.getName().replace('.', '/').concat(".xml"));
registerMapperRelationships(mapperInterfaceType, hints);
}
}
}
};
}
private void registerMapperRelationships(Class<?> mapperInterfaceType, RuntimeHints hints) {
Method[] methods = ReflectionUtils.getAllDeclaredMethods(mapperInterfaceType);
for (Method method : methods) {
if (method.getDeclaringClass() != Object.class) {
ReflectionUtils.makeAccessible(method);
registerSqlProviderTypes(method, hints, SelectProvider.class, SelectProvider::value, SelectProvider::type);
registerSqlProviderTypes(method, hints, InsertProvider.class, InsertProvider::value, InsertProvider::type);
registerSqlProviderTypes(method, hints, UpdateProvider.class, UpdateProvider::value, UpdateProvider::type);
registerSqlProviderTypes(method, hints, DeleteProvider.class, DeleteProvider::value, DeleteProvider::type);
Class<?> returnType = MyBatisMapperTypeUtils.resolveReturnClass(mapperInterfaceType, method);
registerReflectionTypeIfNecessary(returnType, hints);
MyBatisMapperTypeUtils.resolveParameterClasses(mapperInterfaceType, method)
.forEach(x -> registerReflectionTypeIfNecessary(x, hints));
}
}
}
@SafeVarargs
private <T extends Annotation> void registerSqlProviderTypes(
Method method, RuntimeHints hints, Class<T> annotationType, Function<T, Class<?>>... providerTypeResolvers) {
for (T annotation : method.getAnnotationsByType(annotationType)) {
for (Function<T, Class<?>> providerTypeResolver : providerTypeResolvers) {
registerReflectionTypeIfNecessary(providerTypeResolver.apply(annotation), hints);
}
}
}
private void registerReflectionTypeIfNecessary(Class<?> type, RuntimeHints hints) {
if (!type.isPrimitive() && !type.getName().startsWith("java")) {
hints.reflection().registerType(type, MemberCategory.values());
}
}
}
static class MyBatisMapperTypeUtils {
private MyBatisMapperTypeUtils() {
// NOP
}
static Class<?> resolveReturnClass(Class<?> mapperInterface, Method method) {
Type resolvedReturnType = TypeParameterResolver.resolveReturnType(method, mapperInterface);
return typeToClass(resolvedReturnType, method.getReturnType());
}
static Set<Class<?>> resolveParameterClasses(Class<?> mapperInterface, Method method) {
return Stream.of(TypeParameterResolver.resolveParamTypes(method, mapperInterface))
.map(x -> typeToClass(x, x instanceof Class ? (Class<?>) x : Object.class)).collect(Collectors.toSet());
}
private static Class<?> typeToClass(Type src, Class<?> fallback) {
Class<?> result = null;
if (src instanceof Class<?>) {
if (((Class<?>) src).isArray()) {
result = ((Class<?>) src).getComponentType();
} else {
result = (Class<?>) src;
}
} else if (src instanceof ParameterizedType parameterizedType) {
int index = (parameterizedType.getRawType() instanceof Class
&& Map.class.isAssignableFrom((Class<?>) parameterizedType.getRawType())
&& parameterizedType.getActualTypeArguments().length > 1) ? 1 : 0;
Type actualType = parameterizedType.getActualTypeArguments()[index];
result = typeToClass(actualType, fallback);
}
if (result == null) {
result = fallback;
}
return result;
}
}
static class MyBatisMapperFactoryBeanPostProcessor implements MergedBeanDefinitionPostProcessor, BeanFactoryAware {
private static final org.apache.commons.logging.Log LOG = LogFactory.getLog(
MyBatisMapperFactoryBeanPostProcessor.class);
private static final String MAPPER_FACTORY_BEAN = "org.mybatis.spring.mapper.MapperFactoryBean";
private ConfigurableBeanFactory beanFactory;
@Override
public void setBeanFactory(BeanFactory beanFactory) {
this.beanFactory = (ConfigurableBeanFactory) beanFactory;
}
@Override
public void postProcessMergedBeanDefinition(RootBeanDefinition beanDefinition, Class<?> beanType, String beanName) {
if (ClassUtils.isPresent(MAPPER_FACTORY_BEAN, this.beanFactory.getBeanClassLoader())) {
resolveMapperFactoryBeanTypeIfNecessary(beanDefinition);
}
}
private void resolveMapperFactoryBeanTypeIfNecessary(RootBeanDefinition beanDefinition) {
if (!beanDefinition.hasBeanClass() || !MapperFactoryBean.class.isAssignableFrom(beanDefinition.getBeanClass())) {
return;
}
if (beanDefinition.getResolvableType().hasUnresolvableGenerics()) {
Class<?> mapperInterface = getMapperInterface(beanDefinition);
if (mapperInterface != null) {
// Exposes a generic type information to context for prevent early initializing
ConstructorArgumentValues constructorArgumentValues = new ConstructorArgumentValues();
constructorArgumentValues.addGenericArgumentValue(mapperInterface);
beanDefinition.setConstructorArgumentValues(constructorArgumentValues);
beanDefinition.setTargetType(ResolvableType.forClassWithGenerics(beanDefinition.getBeanClass(), mapperInterface));
}
}
}
private Class<?> getMapperInterface(RootBeanDefinition beanDefinition) {
try {
return (Class<?>) beanDefinition.getPropertyValues().get("mapperInterface");
}
catch (Exception e) {
LOG.debug("Fail getting mapper interface type.", e);
return null;
}
}
}
}
@@ -0,0 +1,51 @@
package com.lion.lionwebsite.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SqlConfiguration {
@Bean(name = "datasource-main")
@ConfigurationProperties(prefix = "spring.datasource-main")
public DataSource dataSourceMain() {
return DataSourceBuilder.create().build();
}
@Bean(name = "datasource-cache")
@ConfigurationProperties(prefix = "spring.datasource-cache")
public DataSource dataSourceCache() {
return DataSourceBuilder.create().build();
}
@Bean(name = "sqlSessionFactory-main")
public SqlSessionFactory sqlSessionFactoryMain(@Qualifier("datasource-main") DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
return sessionFactoryBean.getObject();
}
@Bean(name = "sqlSessionFactory-cache")
public SqlSessionFactory sqlSessionFactoryCache(@Qualifier("datasource-cache") DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
return sessionFactoryBean.getObject();
}
@Bean(name = "sqlSessionTemplate-main")
public SqlSessionTemplate sqlSessionTemplateMain(@Qualifier("sqlSessionFactory-main") SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean(name = "sqlSessionTemplate-cache")
public SqlSessionTemplate sqlSessionTemplateCache(@Qualifier("sqlSessionFactory-cache") SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
@@ -0,0 +1,21 @@
package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Service.WebSocketService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebsocketConfiguration implements WebSocketConfigurer {
final WebSocketService webSocketService;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketService, "/ws/").setAllowedOriginPatterns("*");
}
}
@@ -2,49 +2,46 @@ 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.UserServiceImpl; import com.lion.lionwebsite.Service.RemoteService;
import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Service.UserService;
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 lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.concurrent.Callable;
import java.util.List;
@RestController @RestController
@RequestMapping("/GalleryManage") @RequestMapping("/GalleryManage")
@Slf4j @Slf4j
@RequiredArgsConstructor
public class GalleryManageController { public class GalleryManageController {
@Resource final GalleryManageService galleryManageService;
GalleryManageService galleryManageService;
@Resource final CollectService collectService;
CollectService collectService;
@Resource final UserService userService;
CustomUtil customUtil;
@Resource final RemoteService remoteService;
UserServiceImpl userService;
@PostMapping("") @PostMapping("")
public String create_task(String link, String targetResolution, String AuthCode, public String create_task(String link, String targetResolution, String AuthCode){
@RequestParam(value = "tags", required = false) List<Integer> tags,
@RequestParam(value = "mode", defaultValue = "3", required = false)String mode){
if(link == null || targetResolution == null) if(link == null || targetResolution == null)
return Response._failure("参数不全"); return Response._failure("参数不全");
link = customUtil.restoreUrl(link); return galleryManageService.createTask(link, targetResolution, AuthCode);
if(link == null)
return Response._failure("链接错误");
return galleryManageService.createTask(link, targetResolution, AuthCode, tags, Byte.parseByte(mode));
} }
@PostMapping("/update") @PostMapping("/reconnect")
public String updateGallery(String link){ public String reconnect(){
return galleryManageService.updateGallery(link); return galleryManageService.reconnect();
}
@PostMapping("/test")
public String test(){
remoteService.checkAvailability();
return "";
} }
@GetMapping("") @GetMapping("")
@@ -53,40 +50,21 @@ public class GalleryManageController {
if(type == null) if(type == null)
return Response._failure("参数不全"); return Response._failure("参数不全");
switch (type) { return switch (type) {
case "link" -> { case "link" -> galleryManageService.selectTaskByLink(param);
param = customUtil.restoreUrl(param); case "gid" -> galleryManageService.selectTaskByGid(Integer.parseInt(param));
if (param == null) case "all" -> galleryManageService.selectAllGallery(userId);
return Response._failure("链接错误"); case "name" -> galleryManageService.selectGalleryByName(param);
return galleryManageService.selectTaskByLink(param); case "downloader" -> galleryManageService.selectGalleryByDownloader(AuthCode);
} default -> Response._failure("参数错误");
case "gid" -> { };
return galleryManageService.selectTaskByGid(Integer.parseInt(param));
}
case "all" -> {
return galleryManageService.selectAllGallery(userId);
}
case "name" -> {
return galleryManageService.selectGalleryByName(param);
}
case "undone" -> {
return galleryManageService.selectUnDoneGallery();
}
case "downloader" -> {
return galleryManageService.selectGalleryByDownloader(AuthCode);
}
default -> {
return Response._default();
}
}
} }
@DeleteMapping("") @DeleteMapping("")
public String deleteTask(Integer gid, String AuthCode, @RequestParam(value = "mode", defaultValue = "3", required = false)String mode){ public String deleteTask(Integer gid, String AuthCode){
if(gid == null) if(gid == null)
return Response._failure("参数不全"); return Response._failure("参数不全");
return galleryManageService.deleteGalleryByGid(gid, AuthCode);
return galleryManageService.deleteGalleryByGid(gid, AuthCode, Byte.parseByte(mode));
} }
@@ -105,27 +83,25 @@ public class GalleryManageController {
return galleryManageService.getWeekUsedAmount(); return galleryManageService.getWeekUsedAmount();
} }
@GetMapping("/thumbnail/{name}") @PostMapping("/cache")
public void getThumbnail(HttpServletRequest request, HttpServletResponse response, @PathVariable("name") String name){ public String cacheImageKeys(String url){
galleryManageService.getThumbnail(request, response, name.replace(".webp", "")); return galleryManageService.cacheImagesKey(url);
} }
@GetMapping("/onlineImage/{page}") @GetMapping("/onlineImage/{page}")
public void getOnlineImage(Integer gid, @PathVariable("page") Short page, HttpServletRequest request, HttpServletResponse response){ public Callable<?> getCacheImage(String gid, @PathVariable("page") int page, HttpServletRequest request, HttpServletResponse response){
galleryManageService.getOnlineImage(gid, page, request, response); return galleryManageService.getCachedImage(gid, page, request, response);
}
@PostMapping("/share")
public String shareGallery(Integer gid, Integer userId, Integer expireHour){
if(userId.equals(3))
return galleryManageService.shareGallery(gid, expireHour);
return Response._failure("非法访问");
} }
@PostMapping("/reset") @PostMapping("/reset")
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,28 +0,0 @@
package com.lion.lionwebsite.Controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class NavigationController {
@GetMapping("/personal/")
public String personal_index(){
return "self";
}
@GetMapping("/personal/mobile")
public String personal_mobile(){
return "selfMobile";
}
@GetMapping("/")
public String index(){
return "index";
}
@GetMapping("/mobile")
public String mobile(){
return "mobile";
}
}
@@ -1,13 +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.FileDownload;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import com.fasterxml.jackson.core.JsonProcessingException;
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 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;
@@ -20,22 +18,21 @@ import java.io.IOException;
@RestController @RestController
@Slf4j @Slf4j
@RequiredArgsConstructor
@RequestMapping("/personal") @RequestMapping("/personal")
public class PersonalController { public class PersonalController {
@Resource final PersonalService personalService;
PersonalServiceImpl personalService;
@Resource final LocalService localService;
LocalServiceImpl localService;
@GetMapping("/sub/self") @GetMapping("/")
public void sub(HttpServletResponse response, HttpServletRequest request){ public void index(HttpServletResponse resp) throws IOException {
FileDownload.export(request, response, "sub/sub.txt"); resp.sendRedirect("/index");
} }
@GetMapping("/files") @GetMapping("/files")
public String file(String path) throws IOException { public String file(String path){
return personalService.getFiles(path); return personalService.getFiles(path);
} }
@@ -90,7 +87,12 @@ public class PersonalController {
} }
@GetMapping("/ip") @GetMapping("/ip")
public String ip() throws JsonProcessingException { public String ip() {
return personalService.getIp(); return personalService.getIp();
} }
@PostMapping("/message2me")
public String message2me(String message) {
return personalService.message2me(message);
}
} }
@@ -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.RemoteService; import com.lion.lionwebsite.Service.RemoteService;
import com.lion.lionwebsite.Util.FileDownload; import com.lion.lionwebsite.Service.SubService;
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 lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -19,15 +20,23 @@ 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<>();
@Resource final PublicService publicService;
PublicServiceImpl publicService;
@Resource final RemoteService remoteService;
RemoteService remoteService;
final SubService subService;
final QueryService queryService;
@GetMapping("/")
public void index(HttpServletResponse resp) throws IOException {
resp.sendRedirect("/index");
}
@GetMapping("/ip") @GetMapping("/ip")
public void ip(HttpServletRequest request, String auth, HttpServletResponse response) throws IOException { public void ip(HttpServletRequest request, String auth, HttpServletResponse response) throws IOException {
@@ -42,32 +51,20 @@ public class PublicController {
response.getOutputStream().write(ip.getBytes(StandardCharsets.UTF_8)); response.getOutputStream().write(ip.getBytes(StandardCharsets.UTF_8));
} }
@GetMapping("/sub/{client}/{AuthCode}") @GetMapping("/sub/{client}/{key}")
public void publicSub(@PathVariable("AuthCode") String AuthCode, public void publicSub(@PathVariable("key") String key,
@PathVariable("client") String client, @PathVariable("client") String client,
HttpServletResponse response, HttpServletResponse response,
HttpServletRequest request) throws IOException { HttpServletRequest request) {
subService.updateSub(response, request, client, key);
if(AuthCode == null || client == null || !AuthCode.equals("covid"))
return;
switch (client){
case "v2ray" -> FileDownload.export(request, response, "sub/DouNaiV2ray.txt");
case "clash" -> FileDownload.export(request, response, "sub/DouNaiClash.txt");
default -> response.getOutputStream().write("client error".getBytes(StandardCharsets.UTF_8));
}
} }
@GetMapping("/GetFile/{path}") @GetMapping("/GetFile/{path}")
public void getFile(HttpServletRequest request, HttpServletResponse response, String ShareCode, @PathVariable("path") String path) throws IOException { public void getFile(HttpServletRequest request, HttpServletResponse response, String ShareCode, @PathVariable("path") String path) throws IOException {
synchronized (black_share_codes) { synchronized (black_share_codes) {
if (black_share_codes.contains(ShareCode)) { if (black_share_codes.contains(ShareCode))
String ip = request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr() : request.getHeader("X-Forwarded-For");
log.info("dispatch request ip:{} file:{}", ip, path);
return; return;
} }
}
log.info("ShareCode:{}", ShareCode); log.info("ShareCode:{}", ShareCode);
log.info("Path:{}", path); log.info("Path:{}", path);
@@ -77,7 +74,7 @@ public class PublicController {
black_share_codes.add(ShareCode); black_share_codes.add(ShareCode);
if(black_share_codes.size() > 100) if(black_share_codes.size() > 100)
black_share_codes.remove(0); black_share_codes.removeFirst();
} }
@PostMapping("/validate") @PostMapping("/validate")
@@ -97,8 +94,7 @@ public class PublicController {
} }
@GetMapping("/GalleryManage/ehThumbnail") @GetMapping("/GalleryManage/ehThumbnail")
public void getEhThumbnail(String path, HttpServletResponse response){ public void getEhThumbnail(String path, HttpServletRequest request, HttpServletResponse response){
publicService.getEhThumbnail(path, response); queryService.getEhThumbnail(path, request, response);
} }
} }
@@ -1,8 +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 jakarta.annotation.Resource; import lombok.RequiredArgsConstructor;
import jakarta.servlet.http.HttpServletResponse;
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;
@@ -10,20 +9,13 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/query") @RequestMapping("/query")
@RequiredArgsConstructor
public class QueryController { public class QueryController {
@Resource final QueryService queryService;
QueryService queryService;
@GetMapping("") @GetMapping("")
public String query(String keyword, String prev, String next){ public String query(String keyword, String prev, String next){
return queryService.query(keyword, prev, next); return queryService.query(keyword, prev, next);
} }
@GetMapping("/image")
public void image(HttpServletResponse response, String path){
queryService.image(response, path);
}
} }
@@ -0,0 +1,71 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.SubService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/personal/subBind/")
@RequiredArgsConstructor
public class SubController {
final SubService subService;
@PostMapping("")
public String addSubBind(String user, Integer accountId){
return subService.insertSubBind(user, accountId);
}
@PutMapping("")
public String resetKey(String user){
return subService.resetKey(user);
}
@GetMapping("all")
public String getAllSubBind(){
return subService.selectAllSubBind();
}
@GetMapping("allRecord")
public String getAllSubUpdateRecord(){
return subService.SelectAllSubUpdateRecord();
}
@DeleteMapping("")
public String deleteSubBind(String 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,44 +0,0 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.TagService;
import com.lion.lionwebsite.Util.Response;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/GalleryManage")
public class TagController {
@Resource
TagService tagService;
@PostMapping("/tag")
public String createTag(String tag){
return tagService.createTag(tag);
}
@PostMapping("/tagAndMark")
public String createTagAndMark(Integer gid, String tag){
return tagService.createTagAndMark(gid, tag);
}
@DeleteMapping("/tag")
public String deleteTag(Integer tid){
return tid == null ? Response._failure("参数错误") : tagService.deleteTag(tid);
}
@PostMapping("/mark")
public String markTag(Integer gid, Integer tid){
return tagService.markTag(gid, tid);
}
@PostMapping("/disMark")
public String disMarkTag(Integer gid, Integer tid){
return tagService.disMarkTag(gid, tid);
}
@GetMapping("/allTag")
public String allTag(){
return tagService.selectAllTag();
}
}
@@ -1,15 +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 {
@Resource final UserService userService;
UserServiceImpl userService;
@GetMapping("") @GetMapping("")
public String getAllUser(){ public String getAllUser(){
@@ -1,26 +0,0 @@
package com.lion.lionwebsite.Dao;
import com.lion.lionwebsite.Domain.CRC32Cache;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CRC32CacheMapper {
@Insert("insert into CRC32Cache (path, lastModifyTime, crc32) values (#{path}, #{lastModifyTime}, #{crc32})")
void insertCache(CRC32Cache crc32Cache);
@Select("select * from CRC32Cache where path=#{path}")
CRC32Cache selectCache(String path);
@Select("select * from CRC32Cache where path like '%' || #{name} || '%'")
CRC32Cache selectCacheByName(String name);
// @Update("update CRC32Cache set lastModifyTime=#{lastModifyTime}, crc32=#{crc32} where path=#{path}")
// void updateCache(CRC32Cache crc32Cache);
@Delete("delete from CRC32Cache where path=#{path}")
void deleteCacheByPath(String path);
}
@@ -1,22 +0,0 @@
package com.lion.lionwebsite.Dao;
import com.lion.lionwebsite.Domain.PageNameCache;
import org.apache.ibatis.annotations.*;
@Mapper
public interface PageNameCacheMapper {
@Insert("insert into pageName (gid, page, pageName) values (#{gid}, #{page}, #{pageName})")
void insertPageNameCache(PageNameCache pageNameCache);
@Select("select pageName from pageName where gid=#{gid} and page=#{page}")
String selectPageName(@Param("gid") int gid, @Param("page") short page);
@Delete("delete from pageName where gid=#{gid}")
void deletePageNameByGid(int gid);
@Delete("delete from pageName where gid=#{gid} and page=#{page}")
void deletePageName(PageNameCache pageNameCache);
}
@@ -1,60 +0,0 @@
package com.lion.lionwebsite.Dao;
import com.lion.lionwebsite.Domain.Tag;
import com.lion.lionwebsite.Domain.TagMark;
import org.apache.ibatis.annotations.*;
import java.util.ArrayList;
@Mapper
public interface TagMapper {
@Insert("insert into tag (tag) values (#{tag})")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
void insertTag(Tag tag);
// @Select("select id, tag, usage from tag where id=#{id}")
// Tag selectTagById(int id);
//
// @Select("select id from tag where tag=#{tag}")
// int selectTidByTag(String tag);
@Select("select count(id) from tag where tag=#{tag}")
int selectTagExistByTag(String tag);
@Select("select count(id) from tag where id=#{id}")
int selectTagExistById(int id);
@Select("select id, tag, usage from tag")
ArrayList<Tag> selectAllTag();
@Delete("delete from tag where id=#{id}")
void deleteTagById(int id);
@Select("select count(id) from galleryTag where tid=#{tid}")
int selectTagUsage(int tid);
@Select("select count(id) from galleryTag where gid=#{gid} and tid=#{tid}")
int selectIsMark(@Param("gid") int gid, @Param("tid") int tid);
@Select("select tid from galleryTag where gid=#{gid}")
ArrayList<Integer> selectTagByGid(int gid);
@Insert("insert into galleryTag (gid, tid) values (#{gid}, #{tid})")
int markTag(@Param("gid") int gid, @Param("tid") int tid);
@Delete("delete from galleryTag where gid=#{gid} and tid=#{tid}")
int disMarkTag(@Param("gid") int gid, @Param("tid") int tid);
@Select("select gid, tid from galleryTag")
ArrayList<TagMark> selectAllMark();
@Update("update tag set usage=usage+1 where id=#{tid}")
void incrTagUsage(int tid);
@Update("update tag set usage=usage-1 where id=#{tid}")
void decrTagUsage(int tid);
@Delete("delete from galleryTag where gid=#{gid}")
void disMarkTagByGid(int gid);
}
@@ -0,0 +1,24 @@
package com.lion.lionwebsite.Dao.cache;
import com.lion.lionwebsite.Domain.GidToKey;
import com.lion.lionwebsite.Domain.ImageKeyCache;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ImageCacheMapper {
@Insert("insert into ImageKeyCache values (#{gid}, #{imgkey}, #{page})")
void insertImageKeyCache(ImageKeyCache imageKeyCache);
@Insert("insert into gidToKey values (#{gid}, #{key}, #{pages})")
void insertGidToKey(GidToKey gidToKey);
@Select("select * from ImageKeyCache where gid=#{gid} and page=#{page}")
ImageKeyCache selectImageKeyCacheByGidAndPage(@Param("gid") String gid, @Param("page") int page);
@Select("select * from gidToKey where gid=#{gid}")
GidToKey selectKeyByGid(String gid);
}
@@ -1,4 +1,4 @@
package com.lion.lionwebsite.Dao; package com.lion.lionwebsite.Dao.normal;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
@@ -1,4 +1,4 @@
package com.lion.lionwebsite.Dao; package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.CustomConfiguration; import com.lion.lionwebsite.Domain.CustomConfiguration;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
@@ -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);
} }
@@ -1,4 +1,4 @@
package com.lion.lionwebsite.Dao; package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.Gallery; import com.lion.lionwebsite.Domain.Gallery;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
@@ -9,8 +9,8 @@ public interface GalleryMapper {
@Insert("insert into gallery" + @Insert("insert into gallery" +
" (gid, name, link, language, pages, status, fileSize, createTime, proceeding, resolution, displayFileSize, downloader, mode)\n" + " (gid, name, link, language, pages, status, fileSize, createTime, proceeding, resolution, displayFileSize, downloader, thumb_link, is_download)\n" +
" values (#{gid}, #{name}, #{link}, #{language}, #{pages}, #{status}, #{fileSize}, #{createTime}, #{proceeding}, #{resolution}, #{displayFileSize}, #{downloader}, #{mode})") " values (#{gid}, #{name}, #{link}, #{language}, #{pages}, #{status}, #{fileSize}, #{createTime}, #{proceeding}, #{resolution}, #{displayFileSize}, #{downloader}, #{thumb_link}, #{is_download})")
void insertGallery(Gallery gallery); void insertGallery(Gallery gallery);
@Select("select * from gallery where link=#{link}") @Select("select * from gallery where link=#{link}")
@@ -25,10 +25,10 @@ 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") @Select("select * from gallery order by createTime")
Gallery[] selectAllGallery(); Gallery[] selectAllGallery();
@Select("select * from gallery where name like #{name} limit 1") @Select("select * from gallery where name like #{name} limit 1")
@@ -38,7 +38,7 @@ public interface GalleryMapper {
@Update(""" @Update("""
update gallery set name=#{name}, link=#{link}, language=#{language}, update gallery set name=#{name}, link=#{link}, language=#{language},
pages=#{pages}, status=#{status}, fileSize=#{fileSize}, createTime=#{createTime}, pages=#{pages}, status=#{status}, fileSize=#{fileSize}, createTime=#{createTime},
proceeding=#{proceeding}, resolution=#{resolution}, displayFileSize=#{displayFileSize}, mode=#{mode} proceeding=#{proceeding}, resolution=#{resolution}, displayFileSize=#{displayFileSize}, is_download=#{is_download}
where gid=#{gid}""") where gid=#{gid}""")
void updateGallery(Gallery gallery); void updateGallery(Gallery gallery);
@@ -1,4 +1,4 @@
package com.lion.lionwebsite.Dao; package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.ShareFile; import com.lion.lionwebsite.Domain.ShareFile;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
@@ -0,0 +1,81 @@
package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import org.apache.ibatis.annotations.*;
import java.util.ArrayList;
@Mapper
public interface SubMapper {
@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);
@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();
@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);
@Select("select count(key) from sub_bind where key=#{key}")
boolean selectSubBindExist(String key);
@Select("select count(*) from sub_bind where user=#{user}")
int countSubBindByUser(String user);
@Update("update sub_bind set subscription_account_id=#{accountId} where user=#{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}")
void deleteSubBind(String user);
@Insert("insert into sub_update_record (user, ip, UA, time, location) values (#{user}, #{ip}, #{UA}, #{time}, #{location})")
void insertSubUpdateRecord(SubUpdateRecord subUpdateRecord);
@Select("select * from sub_update_record order by time desc")
ArrayList<SubUpdateRecord> selectAllSubUpdateRecord();
@Delete("delete from sub_update_record where user=#{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}")
void deleteSubUpdateRecordById(int id);
}
@@ -1,4 +1,4 @@
package com.lion.lionwebsite.Dao; package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.User; import com.lion.lionwebsite.Domain.User;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
@@ -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();
@@ -1,14 +0,0 @@
package com.lion.lionwebsite.Domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CRC32Cache {
String path;
long lastModifyTime;
String crc32;
}
@@ -13,22 +13,22 @@ import java.util.Map;
@Data @Data
public class Gallery { public class Gallery {
@JsonProperty("name") @JsonProperty("name")
private String name; //本子名字 private String name; //图片名字
@JsonProperty("gid") @JsonProperty("gid")
private int gid; //gid private int gid; //gid
@JsonProperty("link") @JsonProperty("link")
private String link; //本子链接 private String link; //图片链接
@JsonProperty("language") @JsonProperty("language")
private String language; //本子语言 private String language; //图片语言
@JsonProperty("pages") @JsonProperty("pages")
private int pages; //本子页数 private int pages; //图片页数
@JsonProperty("status") @JsonProperty("status")
private String status; //本子当前状态 private String status; //图片当前状态
@JsonIgnore @JsonIgnore
private long fileSize; //文件大小 private long fileSize; //文件大小
@@ -64,7 +64,11 @@ public class Gallery {
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
private Map<String, String> availableResolution; //可选分辨率 private Map<String, String> availableResolution; //可选分辨率
@JsonProperty("mode") @JsonProperty("isDownload")
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
private byte mode; //rd 可读可下载 r可读 d可下载 private boolean is_download; //0 仅查看 1 仅下载源文件 2 仅存储预览图 3 全部
@JsonProperty("thumb_link")
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private String thumb_link;
} }
@@ -6,6 +6,9 @@ import lombok.Data;
@Data @Data
public class GalleryForQuery { public class GalleryForQuery {
@JsonProperty("gid")
String gid;
@JsonProperty("name") @JsonProperty("name")
String name; String name;
@@ -6,33 +6,20 @@ import lombok.Data;
@Data @Data
public class GalleryTask { public class GalleryTask {
public static byte DOWNLOADING = 1; public static byte DOWNLOADING = 1;
public static byte DOWNLOAD_COMPLETE = 2; public static byte DOWNLOAD_COMPLETE = 2;
public static byte COMPRESSING = 3;
public static byte DOWNLOAD_QUEUED = 3;
public static byte COMPRESS_COMPLETE = 4; public static byte COMPRESS_COMPLETE = 4;
public static byte DOWNLOAD_ALL = 3;
public static byte DOWNLOAD_PREVIEW = 2;
public static byte DOWNLOAD_SOURCE = 1;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
private String name; private String name;
private int gid; private int gid;
private int pages;
private byte status; private byte status;
private int proceeding; private int proceeding;
private byte type;
@JsonIgnore @JsonIgnore
private String path; private String path;
} }
@@ -0,0 +1,14 @@
package com.lion.lionwebsite.Domain;
import lombok.Data;
@Data
public class GidToKey {
String gid;
String key;
int pages;
public String toUrl(){
return "https://exhentai.org/g/" + gid + "/" + key;
}
}
@@ -0,0 +1,10 @@
package com.lion.lionwebsite.Domain;
import lombok.Data;
@Data
public class ImageKeyCache {
String gid;
int page;
String imgkey;
}
@@ -1,11 +0,0 @@
package com.lion.lionwebsite.Domain;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class MaskDomain {
String raw;
String mask;
}
@@ -0,0 +1,17 @@
package com.lion.lionwebsite.Domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SubBind {
String key;
String user;
Integer subscriptionAccountId;
String subscriptionAccountName;
boolean subscriptionAccountEnabled;
boolean filterHighMultiplier;
}
@@ -4,11 +4,16 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.Date;
@Data @Data
@NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class Tag { @NoArgsConstructor
public class SubUpdateRecord {
int id; int id;
String tag; String user;
int usage; String ip;
String UA;
Date time;
String location;
} }
@@ -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;
}
@@ -1,11 +0,0 @@
package com.lion.lionwebsite.Domain;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class TagMark {
int gid;
int tid;
}
@@ -1,19 +1,18 @@
package com.lion.lionwebsite.Filter; package com.lion.lionwebsite.Filter;
import com.lion.lionwebsite.Dao.UserMapper; import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.CustomUtil;
import jakarta.annotation.Resource;
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 {
@Resource final UserMapper userMapper;
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,42 +4,14 @@ 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 org.springframework.scheduling.annotation.Scheduled; import lombok.extern.slf4j.Slf4j;
import java.io.FileWriter;
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 {
FileWriter writer;
AdaptorFilter(){
Calendar calendar = Calendar.getInstance();
try {
writer = new FileWriter(String.format("log/AccessLog_%s-%s-%s.log",
calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH)));
}catch (IOException e){
e.printStackTrace();
}
}
@Scheduled(cron = "0 0 0 * * *")
void changeDate(){
Calendar calendar = Calendar.getInstance();
try {
if(writer != null)
writer.close();
writer = new FileWriter(String.format("log/AccessLog_%s-%s-%s.log",
calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH)));
}catch (IOException e){
e.printStackTrace();
}
}
@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 {
HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletRequest request = (HttpServletRequest) servletRequest;
@@ -56,11 +28,8 @@ public class AdaptorFilter implements Filter {
Calendar calendar = Calendar.getInstance(); Calendar calendar = Calendar.getInstance();
String now = String.format("%s:%s:%s", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND)); String now = 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);
writer.write(String.format("%s ip:%s \tpath:%s \tAuthCode:%s ua:%s\n", now, ip, ServletPath, AuthCode, UserAgent));
writer.flush();
//如果是验证,则直接跳转 //如果是验证,则直接跳转
if(ServletPath.equals("/validate")) if(ServletPath.equals("/validate"))
@@ -1,25 +1,30 @@
package com.lion.lionwebsite.Interceptor; package com.lion.lionwebsite.Interceptor;
import com.lion.lionwebsite.Dao.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.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 = null; String[] AuthCodes;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){ @PostConstruct
if(AuthCodes == null) void init() {
AuthCodes = userMapper.selectAllAuthCode(); AuthCodes = userMapper.selectAllAuthCode();
}
@Override
public boolean preHandle(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler){
String auth = request.getParameter("AuthCode"); String auth = request.getParameter("AuthCode");
if(auth != null) if(auth != null)
@@ -1,14 +1,20 @@
package com.lion.lionwebsite; package com.lion.lionwebsite;
import org.mybatis.spring.annotation.MapperScan;
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
@EnableScheduling @EnableScheduling
@ServletComponentScan(value = "com.lion.lionwebsite.Filter") @ServletComponentScan(value = "com.lion.lionwebsite.Filter")
@MapperScans({
@MapperScan(basePackages = "com.lion.lionwebsite.Dao.normal", sqlSessionTemplateRef = "sqlSessionTemplate-main", sqlSessionFactoryRef = "sqlSessionFactory-main"),
@MapperScan(basePackages = "com.lion.lionwebsite.Dao.cache", sqlSessionTemplateRef = "sqlSessionTemplate-cache", sqlSessionFactoryRef = "sqlSessionFactory-cache")
})
public class LionWebsiteApplication { public class LionWebsiteApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -1,5 +1,8 @@
package com.lion.lionwebsite.Message; package com.lion.lionwebsite.Message;
import lombok.Data;
@Data
public class AbstractMessage { public class AbstractMessage {
public static final byte DOWNLOAD_POST_MESSAGE = 1; public static final byte DOWNLOAD_POST_MESSAGE = 1;
@@ -10,12 +13,14 @@ public class AbstractMessage {
public static final byte RESPONSE_MESSAGE = 0; public static final byte RESPONSE_MESSAGE = 0;
public static final byte UPDATE_GALLERY_MESSAGE = 4;
public static final byte GALLERY_PAGE_QUERY_MESSAGE = 5;
public static final byte IDENTITY_MESSAGE = 6; public static final byte IDENTITY_MESSAGE = 6;
public static final byte GALLERY_REQUEST_MESSAGE = 101;
public static final byte MAINTAIN_MESSAGE = 7;
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;
@@ -0,0 +1,10 @@
package com.lion.lionwebsite.Message;
import lombok.Data;
@Data
public class AvailableCheckMessage extends AbstractMessage{
{
messageType = AVAILABLE_CHECK_MESSAGE;
}
}
@@ -7,14 +7,5 @@ public class DeleteGalleryMessage extends AbstractMessage{
{ {
messageType = DELETE_GALLERY_MESSAGE; messageType = DELETE_GALLERY_MESSAGE;
} }
public static final byte DELETE_ALL = 3;
public static final byte DELETE_PREVIEW = 2;
public static final byte DELETE_SOURCE = 1;
byte deleteType;
String galleryName; String galleryName;
} }
@@ -1,21 +0,0 @@
package com.lion.lionwebsite.Message;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
@Data
public class GalleryPageQueryMessage extends AbstractMessage{
{
messageType = GALLERY_PAGE_QUERY_MESSAGE;
}
@JsonInclude(JsonInclude.Include.NON_NULL)
String name;
int page;
@JsonInclude(JsonInclude.Include.NON_NULL)
String pageName;
byte result;
}
@@ -1,25 +0,0 @@
package com.lion.lionwebsite.Message;
import lombok.Data;
@Data
//请求预览/压缩包
public class GalleryRequestMessage extends AbstractMessage{
public static final byte SOURCE = 1;
public static final byte PREVIEW = 2;
public static final byte COMPRESS_SOURCE = 3;
{
messageType = GALLERY_REQUEST_MESSAGE;
}
String galleryName;
byte type;
short page;
short port;
}
@@ -1,10 +1,15 @@
package com.lion.lionwebsite.Message; package com.lion.lionwebsite.Message;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
@Data @Data
@AllArgsConstructor
@NoArgsConstructor
public class IdentityMessage extends AbstractMessage{ public class IdentityMessage extends AbstractMessage{
{ {
messageType = IDENTITY_MESSAGE; messageType = IDENTITY_MESSAGE;
} }
String identity;
} }
@@ -0,0 +1,7 @@
package com.lion.lionwebsite.Message;
public class MaintainMessage extends AbstractMessage{
{
messageType = MAINTAIN_MESSAGE;
}
}
@@ -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;
@@ -39,12 +39,12 @@ public class MessageCodec extends ByteToMessageCodec<AbstractMessage> {
AbstractMessage abstractMessage = switch (messageType){ AbstractMessage abstractMessage = switch (messageType){
case AbstractMessage.DOWNLOAD_POST_MESSAGE -> objectMapper.readValue(metadata, DownloadPostMessage.class); case AbstractMessage.DOWNLOAD_POST_MESSAGE -> objectMapper.readValue(metadata, DownloadPostMessage.class);
case AbstractMessage.DOWNLOAD_STATUS_MESSAGE -> objectMapper.readValue(metadata, DownloadStatusMessage.class); case AbstractMessage.DOWNLOAD_STATUS_MESSAGE -> objectMapper.readValue(metadata, DownloadStatusMessage.class);
case AbstractMessage.GALLERY_REQUEST_MESSAGE -> objectMapper.readValue(metadata, GalleryRequestMessage.class);
case AbstractMessage.RESPONSE_MESSAGE -> objectMapper.readValue(metadata, ResponseMessage.class); case AbstractMessage.RESPONSE_MESSAGE -> objectMapper.readValue(metadata, ResponseMessage.class);
case AbstractMessage.UPDATE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, UpdateGalleryMessage.class);
case AbstractMessage.DELETE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, DeleteGalleryMessage.class); case AbstractMessage.DELETE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, DeleteGalleryMessage.class);
case AbstractMessage.GALLERY_PAGE_QUERY_MESSAGE -> objectMapper.readValue(metadata, GalleryPageQueryMessage.class);
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.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<>();
}
@@ -1,15 +0,0 @@
package com.lion.lionwebsite.Message;
import com.lion.lionwebsite.Domain.GalleryTask;
import lombok.Data;
@Data
public class UpdateGalleryMessage extends AbstractMessage{
{
messageType = UPDATE_GALLERY_MESSAGE;
}
GalleryTask galleryTask;
}
@@ -1,16 +1,14 @@
package com.lion.lionwebsite.Service; package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.CollectMapper; import com.lion.lionwebsite.Dao.normal.CollectMapper;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import jakarta.annotation.Resource;
import lombok.Data; import lombok.Data;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@Data @Data
public class CollectService { public class CollectService {
@Resource final CollectMapper collectMapper;
CollectMapper collectMapper;
public String collectGallery(int gid, int collector){ public String collectGallery(int gid, int collector){
Response response = Response.generateResponse(); Response response = Response.generateResponse();
@@ -26,7 +24,7 @@ public class CollectService {
public String disCollectGallery(int gid, int collector){ public String disCollectGallery(int gid, int collector){
Response response = Response.generateResponse(); Response response = Response.generateResponse();
if(collectMapper.isCollect(gid, collector) == 0) { //没有收藏 if(collectMapper.isCollect(gid, collector) == 0) { //没有收藏
response.failure("没有收藏该本子"); response.failure("没有收藏该图片");
}else{ }else{
collectMapper.disCollect(gid, collector); collectMapper.disCollect(gid, collector);
response.success("取消收藏成功"); response.success("取消收藏成功");
@@ -1,70 +1,52 @@
package com.lion.lionwebsite.Service; package com.lion.lionwebsite.Service;
import cn.hutool.core.io.FileUtil; import com.lion.lionwebsite.Dao.cache.ImageCacheMapper;
import cn.hutool.core.util.RandomUtil; import com.lion.lionwebsite.Dao.normal.*;
import com.lion.lionwebsite.Dao.*;
import com.lion.lionwebsite.Domain.*; import com.lion.lionwebsite.Domain.*;
import com.lion.lionwebsite.Exception.ResolutionNotMatchException; 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.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;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.*; import java.io.*;
import java.time.LocalDateTime; import java.net.URI;
import java.time.ZoneId;
import java.util.*; import java.util.*;
import java.util.concurrent.Callable;
import static com.lion.lionwebsite.Util.CustomUtil.dateTimeFormatter; import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour; import static com.lion.lionwebsite.Util.GalleryUtil.*;
@Service @Service
@Data @Data
@Slf4j @Slf4j
@ConfigurationProperties(prefix = "gallery-manage-service")
public class GalleryManageService { public class GalleryManageService {
String TargetPath; String cachePath = "/storage/galleryCache/onlineImages/";
int cacheSize; final GalleryMapper galleryMapper;
Map<Integer, String> gid2name_cache = new HashMap<>(); final CollectMapper collectMapper;
Map<Integer, Integer> gid2page_cache = new HashMap<>(); final CustomConfigurationMapper configurationMapper;
Map<String, String> page2pageNameCache = new HashMap<>(); final UserMapper userMapper;
@Resource final ShareFileMapper shareFileMapper;
GalleryMapper galleryMapper;
@Resource final ImageCacheMapper imageCacheMapper;
CollectMapper collectMapper;
@Resource final RemoteService remoteService;
CustomConfigurationMapper configurationMapper;
@Resource final PushService pushService;
UserMapper userMapper;
@Resource
ShareFileMapper shareFileMapper;
@Resource
TagMapper tagMapper;
@Resource
PageNameCacheMapper pageNameCacheMapper;
@Resource
RemoteService remoteService;
/** /**
* 创建任务 * 创建任务
@@ -73,75 +55,117 @@ public class GalleryManageService {
* @param AuthCode 授权码(用于记录是谁下载的) * @param AuthCode 授权码(用于记录是谁下载的)
* @return 提交结果 * @return 提交结果
*/ */
public String createTask(String link, String targetResolution, String AuthCode, List<Integer> tidS, byte mode){ public String createTask(String link, String targetResolution, String AuthCode) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
User user = userMapper.selectUserByAuthCode(AuthCode);
// return Response._failure("调试中,请勿提交任务"); // return Response._failure("调试中,请勿提交任务");
if(remoteService.isDead()) if (user == null) {
return Response._failure("节点挂了,找狮子处理"); response.failure("授权码无效");
int gid; return response.toJSONString();
try { }
gid = Integer.parseInt(link.split("/")[4]);
}catch (NumberFormatException e){ // 段数不足会先抛 ArrayIndexOutOfBoundsException,非数字段抛 NumberFormatException;
// 只捕后者会让畸形链接穿透为 500(本项目无 @ControllerAdvice)。
Integer parsedGid = parseGidFromLink(link);
if (parsedGid == null) {
response.failure("链接错误"); response.failure("链接错误");
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;
//判断数据库中是否有这个任务,有则返回状态 //判断数据库中是否有这个任务,有则返回状态
if((gallery = galleryMapper.selectGalleryByGid(gid)) != null){ if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) {
response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务"); response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务");
pushService.taskCreateReport(user.getUsername(), gallery.getName(), response);
return response.toJSONString(); return response.toJSONString();
} }
//尝试下载本子,返回结果 //尝试下载图片,返回结果
try { try {
gallery = GalleryUtil.parse(link, true, targetResolution); gallery = GalleryUtil.parse(link, true, targetResolution);
if(gallery == null){ if (gallery == null || !gallery.getStatus().equals("已提交")) {
log.error("创建任务: {},解析失败", link); log.error("创建任务失败: {},", link);
return Response._failure("任务解析失败,未知原因,请检查链接是否正常"); response.failure("提交任务失败,未知原因,请检查链接是否正常");
}else{ pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString();
} else {
taskName = gallery.getName();
log.info("创建任务: {} 目标分辨率:{}", link, targetResolution); log.info("创建任务: {} 目标分辨率:{}", link, targetResolution);
if(remoteService.addGalleryToQueue(gallery, mode) != 0){ // Persist before dispatch: the node sends its current status before its ACK.
log.error("传送任务{}失败, 未知原因", gallery.getName()); gallery.setDownloader(user.getId());
return Response._failure("任务传送失败,未知原因"); gallery.set_download(true);
}
}
}catch (ResolutionNotMatchException e){
e.printStackTrace();
response.failure("提交失败,分辨率不存在");
return response.toJSONString();
}catch (IOException e){
e.printStackTrace();
response.failure("IO错误,可能是网络波动");
return response.toJSONString();
}
//处理下载结果,将任务插入数据库并且更新每周用量
if(gallery.getStatus().equals("已提交")) {
response.success(gallery.toString());
gallery.setDownloader(userMapper.selectUserByAuthCode(AuthCode).getId());
gallery.setMode(mode);
galleryMapper.insertGallery(gallery); galleryMapper.insertGallery(gallery);
configurationMapper.incrementConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, gallery.getFileSize());
if (remoteService.addGalleryToQueue(gallery) != 0) {
log.error("传送任务{}失败, 未知原因", gallery.getName());
response.failure("任务已保存,但节点未确认接收;请刷新任务列表后重试");
pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString();
}
}
} catch (ResolutionNotMatchException e) {
response.failure("提交失败,分辨率不存在");
pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString();
} catch (IOException e) {
log.error(e.getMessage());
response.failure("IO错误,可能是网络波动");
pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString();
}
long usedAmount = Long.parseLong(configurationMapper.selectConfiguration(CustomConfiguration.WEEK_USED_AMOUNT).getValue()); // Do not overwrite an immediate node status with the original submitted state.
usedAmount += gallery.getFileSize(); Gallery current = galleryMapper.selectGalleryByGid(gallery.getGid());
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, String.valueOf(usedAmount)); response.success((current == null ? gallery : current).toString());
if(tidS != null) pushService.taskCreateReport(user.getUsername(), taskName, response);
for (Integer tid : tidS)
if(tagMapper.selectTagExistById(tid) > 0)
tagMapper.markTag(gallery.getGid(), tid);
}
else{
response.failure("提交失败,未知原因");
galleryMapper.deleteGalleryByGid(gallery.getGid());
}
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 重连结果
*/
public String reconnect(){
Response response = Response.generateResponse();
return switch (remoteService.reconnect()) {
case 0 -> response.success("重连成功").toJSONString();
case -1 -> response.failure("重连失败").toJSONString();
case -2 -> response.failure("当前未连接").toJSONString();
default -> response.failure("未知错误").toJSONString();
};
}
/**
* 根据链接查询图片
* @param link 链接 * @param link 链接
* @return 查询结果 * @return 查询结果
*/ */
@@ -149,7 +173,7 @@ public class GalleryManageService {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Integer gid = parseGid(link); Integer gid = parseGid(link);
Gallery gallery; Gallery gallery;
if(gid != null) if (gid != null)
gallery = galleryMapper.selectGalleryByGid(gid); gallery = galleryMapper.selectGalleryByGid(gid);
else { else {
response.failure("链接错误"); response.failure("链接错误");
@@ -158,15 +182,15 @@ public class GalleryManageService {
log.info("查询{}", link); log.info("查询{}", link);
//判断数据库中是否有这个任务,如果有则返回,如果没有则直接查询 //判断数据库中是否有这个任务,如果有则返回,如果没有则直接查询
if(gallery == null) if (gallery == null)
try{ try {
gallery = GalleryUtil.parse(link, false, null); gallery = GalleryUtil.parse(link, false, null);
if(gallery != null) if (gallery != null)
response.success(new ObjectMapper().valueToTree(gallery).toString()); response.success(new ObjectMapper().valueToTree(gallery).toString());
else else
response.failure("查询失败"); response.failure("查询失败");
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); log.info(e.getMessage());
response.failure("查询失败"); response.failure("查询失败");
} }
@@ -177,16 +201,17 @@ public class GalleryManageService {
} }
/** /**
* 通过gid查询本子 * 通过gid查询图片
*
* @param gid gid * @param gid gid
* @return 查询结果 * @return 查询结果
*/ */
public String selectTaskByGid(int gid){ public String selectTaskByGid(int gid) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Gallery gallery = galleryMapper.selectGalleryByGid(gid); Gallery gallery = galleryMapper.selectGalleryByGid(gid);
if(gallery == null) if (gallery == null)
response.failure("未找到该本子,请使用链接"); response.failure("未找到该图片,请使用链接");
else else
response.success(gallery.toString()); response.success(gallery.toString());
@@ -195,31 +220,32 @@ public class GalleryManageService {
} }
/** /**
* 查询所有本子 * 查询所有图片
*
* @return 查询结果 * @return 查询结果
*/ */
public String selectAllGallery(int userId) { public String selectAllGallery(int userId) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Gallery[] galleries = galleryMapper.selectAllGallery(); Gallery[] galleries = galleryMapper.selectAllGallery();
if(galleries == null){ if (galleries == null) {
response.failure("没有找到本子"); response.failure("没有找到图片");
return response.toJSONString(); return response.toJSONString();
} }
ArrayList<Integer> galleryIds = collectMapper.selectGidByCollector(userId); ArrayList<Integer> galleryIds = collectMapper.selectGidByCollector(userId);
Iterator<Integer> idIterator; Iterator<Integer> idIterator;
if(!galleryIds.isEmpty()) //如果该用户收藏了本子 if (!galleryIds.isEmpty()) //如果该用户收藏了图片
galleryLoop: for (Gallery gallery : galleries) { //遍历本子 galleryLoop:for (Gallery gallery : galleries) { //遍历图片
idIterator = galleryIds.iterator(); idIterator = galleryIds.iterator();
while (idIterator.hasNext()){ //遍历收藏的gid while (idIterator.hasNext()) { //遍历收藏的gid
Integer id = idIterator.next(); Integer id = idIterator.next();
if(id.equals(gallery.getGid())){ //如果找到对应的gid,修改对应本子的属性,删除当前gid,判断是否需要跳出或者结束循环 if (id.equals(gallery.getGid())) { //如果找到对应的gid,修改对应图片的属性,删除当前gid,判断是否需要跳出或者结束循环
gallery.setCollect(true); gallery.setCollect(true);
idIterator.remove(); idIterator.remove();
if(galleryIds.isEmpty()) if (galleryIds.isEmpty())
break galleryLoop; break galleryLoop;
else else
continue galleryLoop; continue galleryLoop;
@@ -227,45 +253,13 @@ public class GalleryManageService {
} }
} }
HashMap<Integer, ArrayList<Integer>> tagsMap = new HashMap<>();
ArrayList<TagMark> tags = tagMapper.selectAllMark();
for (TagMark tagMark : tags) { //从数据库取出所有标记,然后筛选出 gid -> tids ,后面加个缓存
tagsMap.computeIfAbsent(tagMark.getGid(), k -> new ArrayList<>());
tagsMap.get(tagMark.getGid()).add(tagMark.getTid());
}
ArrayList<Integer> temp;
for (Gallery gallery : galleries) {
if((temp = tagsMap.get(gallery.getGid())) != null) {
gallery.setTags(new ArrayList<>());
gallery.getTags().addAll(temp);
}
}
response.success(new ObjectMapper().valueToTree(galleries).toString()); response.success(new ObjectMapper().valueToTree(galleries).toString());
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 查询未完成的本子 * 通过图片名查询图片
* @return 查询结果 *
*/
public String selectUnDoneGallery() {
Response response = Response.generateResponse();
Gallery[] galleries = galleryMapper.selectUnDoneGalleries();
if(galleries.length > 0)
response.success(new ObjectMapper().valueToTree(galleries).toString());
else
response.failure();
return response.toJSONString();
}
/**
* 通过本子名查询本子
* @param name 名字 * @param name 名字
* @return 查询结果 * @return 查询结果
*/ */
@@ -273,193 +267,76 @@ public class GalleryManageService {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Gallery gallery = galleryMapper.selectGalleryByName("%" + name + "%"); Gallery gallery = galleryMapper.selectGalleryByName("%" + name + "%");
if(gallery != null) if (gallery != null)
response.success(new ObjectMapper().valueToTree(gallery).toString()); response.success(new ObjectMapper().valueToTree(gallery).toString());
else else
response.failure("没有找到该名字的本子"); response.failure("没有找到该名字的图片");
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 查询用户下载的本子 * 查询用户下载的图片
*
* @param AuthCode 授权码(用于查询用户名) * @param AuthCode 授权码(用于查询用户名)
* @return 查询结果 * @return 查询结果
*/ */
public String selectGalleryByDownloader(String AuthCode){ public String selectGalleryByDownloader(String AuthCode) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Gallery[] galleries = galleryMapper.selectGalleryByDownloader(userMapper.selectUserByAuthCode(AuthCode).getId()); Gallery[] galleries = galleryMapper.selectGalleryByDownloader(userMapper.selectUserByAuthCode(AuthCode).getId());
if(galleries.length > 0) if (galleries.length > 0)
response.success(new ObjectMapper().valueToTree(galleries).toString()); response.success(new ObjectMapper().valueToTree(galleries).toString());
else else
response.failure("您未下载本子"); response.failure("您未下载图片");
return response.toJSONString(); return response.toJSONString();
} }
// /**
// * 获取本子图片的名字
// * @param gid gid
// * @return 名字数组
// */
// public String selectOnlineFilename(Integer gid){
// Response response = Response.generateResponse();
// String path;
//
// if((path = gid2name_cache.get(gid)) == null) {
// Gallery gallery = galleryMapper.selectGalleryByGid(gid);
//
// if (gallery == null) {
// response.failure("本子不存在");
// return response.toJSONString();
// }
//
// path = TargetPath + gallery.getName();
// gid2name_cache.put(gid, path);
// if(gid2name_cache.size() > cacheSize)
// gid2name_cache.remove(gid2name_cache.keySet().iterator().next());
// }
//
// File file = new File(path);
// if(!file.isDirectory()){
// response.failure("本子文件已被删除");
// return response.toJSONString();
// }
//
// File[] files = file.listFiles(pathname -> pathname.getName().endsWith(".webp") && !pathname.getName().startsWith("thumbnail"));
// if(files == null || files.length == 0){
// response.failure("本子文件丢失");
// return response.toJSONString();
// }
//
// ArrayList<String> images = new ArrayList<>();
// for(File image: files){
// images.add(image.getName().replace(".webp", ""));
// }
// //字符串长度相等时比较字典顺序,不相等时比较长度
// images.sort((s1, s2) ->
// s1.length() == s2.length() ? s1.compareTo(s2): s1.length() - s2.length()
// );
//
// response.success(new ObjectMapper().valueToTree(images).toString());
// return response.toJSONString();
// }
/** /**
* 删除本子以及对应的文件(如果存在的话) * 删除图片以及对应的文件(如果存在的话)
*
* @param gid gid * @param gid gid
* @return 删除结果 * @return 删除结果
*/ */
public String deleteGalleryByGid(Integer gid, String AuthCode, byte mode) { public String deleteGalleryByGid(Integer gid, String AuthCode) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
String deleteType;
Gallery gallery = galleryMapper.selectGalleryByGid(gid); Gallery gallery = galleryMapper.selectGalleryByGid(gid);
User user = userMapper.selectUserByAuthCode(AuthCode); User user = userMapper.selectUserByAuthCode(AuthCode);
if(gallery == null){ if (gallery == null) {
response.failure("删除失败,该本子不存在"); response.failure("删除失败,该图片不存在");
return response.toJSONString();
}
if (user == null) {
response.failure("删除失败,授权码无效");
return response.toJSONString(); return response.toJSONString();
} }
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid()); ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
if(!(collector.isEmpty() || collector.size() == 1 && collector.get(0).equals(user.getId()) //判断收藏 // 拒绝条件与提示文案一致:被别人收藏,或者请求者不是下载人。
&& gallery.getDownloader() == user.getId())) //判断下载 // 注意不能写成 collector.isEmpty() || ...:那样在「无任何收藏」时会短路放行,
response.failure("删除失败,该本子已被别人收藏或你不是下载人"); // 从而完全跳过下载者校验,导致任何有效授权码都能删除他人任务。
boolean collectedByOthers = collector.stream().anyMatch(id -> id != user.getId());
else{ boolean isDownloader = gallery.getDownloader() == user.getId();
if((gallery.getMode() & mode) == 0) { //不能删除对应的,因为对应的没有 if (collectedByOthers || !isDownloader) {
if (mode == 1) response.failure("删除失败,该图片已被别人收藏或你不是下载人");
response.failure("无法删除该本子的源文件,不存在"); log.info("拒绝删除 gid={}:collectedByOthers={} isDownloader={}", gid, collectedByOthers, isDownloader);
else if (mode == 2) return response.toJSONString();
response.failure("无法删除该本子的预览图,不存在");
} else {
log.info("删除本子{}, mode:{}, delete_mode:{}", gallery.getName(), gallery.getMode(), mode);
gallery.setMode((byte) (gallery.getMode() ^ mode));
if(gallery.getMode() == 0) { //删整个本子
galleryMapper.deleteGalleryByGid(gallery.getGid()); //删除本子记录
ArrayList<Integer> tidS = tagMapper.selectTagByGid(gallery.getGid());
for (Integer tid : tidS)
tagMapper.decrTagUsage(tid);
tagMapper.disMarkTagByGid(gallery.getGid()); //删除本子标记
gid2name_cache.remove(gallery.getGid()); //删除本子缓存
page2pageNameCache.remove(gallery.getName()); //移除页数缓存
pageNameCacheMapper.deletePageNameByGid(gid);
if(new File(TargetPath + "/" + gallery.getName()).isDirectory()) //删除本地缓存
FileUtil.del(TargetPath + "/" + gallery.getName());
deleteType = "全部";
}else{
if(mode == 2 && new File(TargetPath + "/" + gallery.getName()).isDirectory()) { //删除预览缓存
FileUtil.del(TargetPath + "/" + gallery.getName());
gid2name_cache.remove(gallery.getGid()); //删除本子缓存
page2pageNameCache.remove(gallery.getName()); //移除页数缓存
} }
galleryMapper.updateGallery(gallery); // 通过授权后才落库并通知节点,避免被拒请求仍删除节点文件。
deleteType = (mode == 1? "源文件": "预览图"); //用于提示 log.info("删除图片{}", gallery.getName());
} galleryMapper.deleteGalleryByGid(gallery.getGid());
switch (remoteService.deleteGallery(gallery, mode)){ switch (remoteService.deleteGallery(gallery)) {
case ErrorCode.IO_ERROR -> response.failure("本子:" + gallery.getName() + deleteType + "删除失败,IO错误"); case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
case ErrorCode.FILE_NOT_FOUND -> response.failure("本子:" + gallery.getName() + deleteType + "删除失败,文件不存在"); 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();
}
/**
* 更新本子
* @param link 目标链接
* @return 更新结果
*/
public String updateGallery(String link){
Response response = Response.generateResponse();
Integer gid = parseGid(link);
if(gid == null) {
response.failure("链接格式出错");
return response.toJSONString();
}
Gallery oldGallery = galleryMapper.selectGalleryByGid(gid);
if(oldGallery == null) {
response.failure("数据库不存在该本子,请提交下载");
return response.toJSONString();
}
try {
String newLink = GalleryUtil.queryUpdateLink(oldGallery.getLink());
if(newLink == null)
response.failure("本子没有更新");
else{
Gallery newGallery = GalleryUtil.parse(newLink, true, oldGallery.getResolution());
if(newGallery != null) {
galleryMapper.deleteGalleryByGid(oldGallery.getGid());
remoteService.updateGallery(newGallery, oldGallery.getMode());
newGallery.setDownloader(oldGallery.getDownloader());
newGallery.setCollector(oldGallery.getCollector());
long usedAmount = Long.parseLong(configurationMapper.selectConfiguration(CustomConfiguration.WEEK_USED_AMOUNT).getValue());
usedAmount += newGallery.getFileSize();
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, String.valueOf(usedAmount));
galleryMapper.insertGallery(newGallery);
response.success("提交更新成功,更新页数:" + newGallery.getPages());
}
else {
response.failure("存在更新,但更新失败");
}
}
}catch (Exception e){
response.failure("获取更新失败");
}
return response.toJSONString(); return response.toJSONString();
} }
@@ -473,126 +350,86 @@ 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();
} }
public String cacheImagesKey(String url) {
/**
* 获取缩略图 (直接按照名字路径获取,后期可能会加入缓存)
* @param request 请求对象
* @param response 响应对象
* @param name 本子名字
*/
public void getThumbnail(HttpServletRequest request, HttpServletResponse response, String name){
File thumbnail = new File(TargetPath + name, "thumbnail.webp");
if(!thumbnail.exists())
if(remoteService.cachePreview(name, (short) 0, thumbnail) != 0){
fourZeroFour(response);
return;
}
FileDownload.export(request, response, thumbnail.getAbsolutePath());
}
/**
* 获取在线图片
* @param gid gid
* @param page 文件名
* @param request 请求对象
* @param response 响应对象
*/
public void getOnlineImage(Integer gid, Short page, HttpServletRequest request, HttpServletResponse response){
String name;
//本子名缓存
if((name = gid2name_cache.get(gid)) == null) //内存
if((name = galleryMapper.selectGalleryNameByGid(gid)) == null) { //数据库
fourZeroFour(response);
return;
}
else
gid2name_cache.put(gid, name);
//页数缓存
Integer real_page;
if((real_page = gid2page_cache.get(gid)) == null)
real_page = galleryMapper.selectGalleryByGid(gid).getPages();
//判断页数是否超出
if(real_page < page || page < 0){
fourZeroFour(response);
return;
}
//页名缓存
String pageName;
if((pageName = page2pageNameCache.get(gid + String.valueOf(page))) == null) //内存
if((pageName = pageNameCacheMapper.selectPageName(gid, page)) == null) //数据库
if ((pageName = remoteService.queryPageName(name, page)) == null) { //远程查询
fourZeroFour(response);
return;
}
//插入数据库
else
pageNameCacheMapper.insertPageNameCache(new PageNameCache(gid, page, pageName));
//插入缓存
else
page2pageNameCache.put(gid + String.valueOf(page), pageName);
//图片缓存
File file = new File(TargetPath, name + "/" + pageName);
if(!file.exists()) //硬盘
if(remoteService.cachePreview(name, page, file) != 0) { //远程
fourZeroFour(response);
return;
}
FileDownload.export(request, response, file.getAbsolutePath());
}
public String shareGallery(Integer gid, Integer expireHour){
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Gallery gallery = galleryMapper.selectGalleryByGid(gid); String gid = String.valueOf(GalleryUtil.parseGid(url));
Map<String, String> jsonObject = new HashMap<>(); GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
if(gallery == null){ //已缓存过,直接返回
response.failure("本子不存在"); if(gidToKey != null) {
return response.success(objectMapper.valueToTree(gidToKey)).toJSONString();
}
try {
gidToKey = new GidToKey();
gidToKey.setGid(gid);
gidToKey.setKey(url.split("/")[5].strip());
ArrayList<ImageKeyCache> imageKeyCaches = GalleryUtil.parseImageKeys(url);
if(imageKeyCaches == null)
return response.failure("该图片已下架或已被删除").toJSONString();
gidToKey.setPages(imageKeyCaches.size());
imageCacheMapper.insertGidToKey(gidToKey);
for (ImageKeyCache imageKeyCache : imageKeyCaches)
imageCacheMapper.insertImageKeyCache(imageKeyCache);
response.success(objectMapper.valueToTree(gidToKey));
}catch (IOException e){
log.error(e.getMessage());
response.failure("网络波动或其他异常");
}
return response.toJSONString(); return response.toJSONString();
} }
File file = new File(TargetPath + gallery.getName() + ".zip"); public Callable<?> getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) {
Path directory = Path.of(cachePath, gid);
if(!file.isFile()){ String name = String.valueOf(page);
response.failure("本子文件不存在"); Path cached = ImageFileCache.find(directory, name);
return response.toJSONString(); if (cached != null) {
FileDownload.export(request, response, cached.toString());
return null;
} }
return () -> {
ShareFile shareFile = shareFileMapper.selectShareFileByFilePath(file.getAbsolutePath()); if (response.isCommitted()) return null;
if(shareFile != null){ try {
jsonObject.put("shareCode", shareFile.getShareCode()); Path image = ImageFileCache.get(directory, name, () -> {
jsonObject.put("expireTime", dateTimeFormatter.format(LocalDateTime.ofInstant(shareFile.getExpireTime().toInstant(), ZoneId.systemDefault()))); GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
response.success(new ObjectMapper().valueToTree(jsonObject).toString()); ImageKeyCache imageKey = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page);
return response.toJSONString(); if (gidToKey == null || imageKey == null)
throw new IOException("图片索引不存在");
for (int attempt = 0; attempt < 2; attempt++) {
String url = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKey);
if (url != null) return url;
GalleryUtil.refreshMpvKey(gidToKey.toUrl());
} }
throw new IOException("无法获取图片地址");
String ShareCode = RandomUtil.randomString(8); });
Calendar expireTime = Calendar.getInstance(); FileDownload.export(request, response, image.toString());
} catch (InterruptedException e) {
expireTime.add(Calendar.HOUR, expireHour); Thread.currentThread().interrupt();
shareFileMapper.insertShareFile(ShareCode, file.getAbsolutePath(), expireTime.getTime()); if (!response.isCommitted()) response.sendError(503);
jsonObject.put("shareCode", ShareCode); } catch (Exception e) {
jsonObject.put("expireTime", dateTimeFormatter.format(expireTime.getTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime())); log.warn("获取在线图片失败: gid={} page={} errorType={}", gid, page, e.getClass().getSimpleName());
response.success(new ObjectMapper().valueToTree(jsonObject).toString()); if (!response.isCommitted()) response.sendError(404);
}
return response.toJSONString(); return null;
};
} }
public String resetUndone(){ public String resetUndone(){
@@ -605,22 +442,29 @@ public class GalleryManageService {
Gallery[] galleries = galleryMapper.selectUnDoneGalleries(); Gallery[] galleries = galleryMapper.selectUnDoneGalleries();
if(galleries != null && galleries.length != 0) { if(galleries != null && galleries.length != 0) {
log.info("发送未下载完成本子至节点,{}本", galleries.length); log.info("发送未下载完成图片至节点,{}本", galleries.length);
for (Gallery gallery : galleries) for (Gallery gallery : galleries)
remoteService.addGalleryToQueue(gallery, gallery.getMode()); remoteService.addGalleryToQueue(gallery);
response.success(String.format("发送未下载完成本子至节点,%s本", galleries.length)); response.success(String.format("发送未下载完成图片至节点,%s本", galleries.length));
}else{ }else{
response.failure("当前没有未下载完成的本子"); response.failure("当前没有未下载完成的图片");
} }
return response.toJSONString(); return response.toJSONString();
} }
public static Integer parseGid(String link){ public String retryGallery(int gid){
try { Gallery gallery = galleryMapper.selectGalleryByGid(gid);
return Integer.parseInt(link.split("/g/")[1].split("/")[0]); if(gallery == null)
}catch (IndexOutOfBoundsException e){ return Response._failure("任务不存在");
return null; 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());
} }
} }
@@ -1,19 +1,20 @@
package com.lion.lionwebsite.Service; package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.CustomConfigurationMapper; import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.GalleryMapper; import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Dao.ShareFileMapper; import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration; import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile; import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.CustomUtil;
import jakarta.annotation.Resource; 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.springframework.boot.context.properties.ConfigurationProperties; 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;
@@ -25,25 +26,59 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.nio.file.FileVisitResult.CONTINUE; import static java.nio.file.FileVisitResult.CONTINUE;
@Service @Service
@ConfigurationProperties(prefix = "local-service")
@Data @Data
public class LocalServiceImpl{ @Slf4j
String fires; public class LocalService{
@Value("${local.dou-nai-clash:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta}")
String DouNaiClash; String DouNaiClash;
@Value("${local.dou-nai-v2ray:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2}")
String DouNaiV2ray; String DouNaiV2ray;
@Resource private static final CloseableHttpClient httpClient = HttpClients.createDefault();
CustomConfigurationMapper configurationMapper;
@Resource final CustomConfigurationMapper configurationMapper;
ShareFileMapper shareFIleMapper;
@Resource final ShareFileMapper shareFileMapper;
GalleryMapper galleryMapper;
final GalleryMapper galleryMapper;
final PushService pushService;
final RemoteService remoteService;
final SubscriptionRefreshService subscriptionRefreshService;
/**
* 检查连接是否有效,如果无效自动重连
*/
@Scheduled(cron = "0 0/30 * * * *")
public void CheckConnectionAvailability(){
if (remoteService.isDead()){
remoteService.initChannel();
pushService.sendToMe("主动检测连接已断开,自动进行重连");
log.warn("主动检测连接已断开,自动进行重连");
return;
}
// -1 为对方没有返回
if (remoteService.checkAvailability() == -1) {
String result = switch (remoteService.reconnect()){
case 0 -> "重连成功";
case -1 -> "重连失败";
case -2 -> "当前未连接,不进行重连";
default -> "未知错误";
};
pushService.sendToMe("主动检测连接无数据返回,自动进行重连:" + result);
log.warn("主动检测连接无数据返回,自动进行重连:{}", result);
}
}
/** /**
* 每周周一四点重置额度 * 每周周一四点重置额度
@@ -54,6 +89,20 @@ public class LocalServiceImpl{
configurationMapper.updateConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME, CustomUtil.now()); configurationMapper.updateConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME, CustomUtil.now());
} }
/**
* 每天凌晨测试e-hentai cookie是否过期
*/
@Scheduled(cron = "0 0 0 * * *")
public void verifyCookie(){
try {
String content = GalleryUtil.requests("https://exhentai.org", "GET", null, null);
if(content.trim().isEmpty())
pushService.sendToMe("cookie过期");
} catch (IOException e) {
pushService.sendToMe("检测cookie异常:" + e.getMessage());
}
}
/** /**
* 定时更新订阅 * 定时更新订阅
* @throws IOException 下载以及保存异常 * @throws IOException 下载以及保存异常
@@ -67,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);
@@ -79,8 +136,6 @@ public class LocalServiceImpl{
return false; return false;
} }
File mixin = new File("sub/sub.txt");
File firesFile = new File("sub/fires.txt");
File DouNaiClashFile = new File("sub/DouNaiClash.txt"); File DouNaiClashFile = new File("sub/DouNaiClash.txt");
File DouNaiV2rayFile = new File("sub/DouNaiV2ray.txt"); File DouNaiV2rayFile = new File("sub/DouNaiV2ray.txt");
File directory = new File("sub"); File directory = new File("sub");
@@ -88,33 +143,29 @@ public class LocalServiceImpl{
if(!directory.isDirectory()) if(!directory.isDirectory())
Files.createDirectory(Paths.get("sub")); Files.createDirectory(Paths.get("sub"));
List<String> fires_profile = null; List<String> DouNaiClash_profile;
List<String> DouNaiClash_profile = null;
OutputStream outputStream;
//下载薯条订阅
try(FileWriter writer = new FileWriter(firesFile)) {
fires_profile = Get(fires);
for (String i : fires_profile)
writer.write(i + "\n");
System.out.println("load fires complete");
}catch (IOException e) {
e.printStackTrace();
System.out.println("load fires failure");
}
//下载豆奶v2ray订阅 //下载豆奶v2ray订阅
try(FileWriter writer = new FileWriter(DouNaiV2rayFile)) { try(FileWriter writer = new FileWriter(DouNaiV2rayFile)) {
String DouNaiV2rayRaw = Get(DouNaiV2ray).get(0); String DouNaiV2rayRaw = Get(DouNaiV2ray).getFirst();
String[] v2rayPlain = new String(Base64.getDecoder().decode(DouNaiV2rayRaw)).split("\n"); String[] v2rayPlain = new String(Base64.getDecoder().decode(DouNaiV2rayRaw)).split("\n");
StringBuilder stringBuilder = new StringBuilder(); StringBuilder stringBuilder = new StringBuilder();
Pattern pattern = Pattern.compile("-?\\d+(\\.\\d+)?");
//过滤高倍率节点 //过滤高倍率节点
for(String node: v2rayPlain){ for(String node: v2rayPlain){
String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8); String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8);
if(name.startsWith("①") && name.contains("流量")){ if(name.contains("流量")){
float ratio = Float.parseFloat(name.substring(name.indexOf("(") + 1, name.indexOf(")")).replace("倍流量", "")); Matcher matcher = pattern.matcher(name.substring(name.indexOf("(") + 1, name.indexOf(")")));
if(ratio <= 1)
if (matcher.find()) {
// 将匹配到的数字添加到列表中
float ratio = Float.parseFloat(matcher.group());
if(ratio <= 2) {
stringBuilder.append(node).append("\n");
continue;
}
}
stringBuilder.append(node).append("\n"); stringBuilder.append(node).append("\n");
} }
else{ else{
@@ -123,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订阅
@@ -155,52 +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");
} }
//处理合并
assert DouNaiClash_profile != null;
int side_start = DouNaiClash_profile.indexOf("proxies:");
int side_end = DouNaiClash_profile.indexOf("proxy-groups:");
List<String> side_proxy = new ArrayList<>();
List<String> side_name = new ArrayList<>();
for (int i = side_start + 1; i < side_end; i++) {
side_proxy.add(DouNaiClash_profile.get(i));
if (DouNaiClash_profile.get(i).contains("name"))
side_name.add(DouNaiClash_profile.get(i).replace("name:", ""));
}
List<String> final_profile = new ArrayList<>();
assert fires_profile != null;
for (String i : fires_profile) {
if (i.equals("proxy-groups:"))
final_profile.addAll(side_proxy);
final_profile.add(i);
if (i.equals("proxy-groups:")) {
final_profile.add(" -");
final_profile.add(" name: e站流量");
final_profile.add(" type: select");
final_profile.add(" proxies:");
for (String x : side_name)
final_profile.add(" " + x);
}
if (i.equals("rules:"))
final_profile.add(" - DOMAIN-KEYWORD,hentai,e站流量");
}
outputStream = Files.newOutputStream(mixin.toPath());
for(String i: final_profile)
outputStream.write((i + "\n").getBytes(StandardCharsets.UTF_8));
outputStream.close();
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;
*/
} }
/** /**
@@ -210,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) {
@@ -227,7 +238,6 @@ public class LocalServiceImpl{
temp.add(str); temp.add(str);
} }
httpClient.close();
httpResponse.close(); httpResponse.close();
return temp; return temp;
} }
@@ -237,7 +247,7 @@ public class LocalServiceImpl{
*/ */
@Scheduled(cron = "0 0 4 * * *") @Scheduled(cron = "0 0 4 * * *")
public void checkShareCode(){ public void checkShareCode(){
ShareFile[] shareFiles = shareFIleMapper.selectAllShareFile(); ShareFile[] shareFiles = shareFileMapper.selectAllShareFile();
Calendar now; Calendar now;
Calendar expireTime; Calendar expireTime;
for(ShareFile shareFile: shareFiles){ for(ShareFile shareFile: shareFiles){
@@ -245,7 +255,7 @@ public class LocalServiceImpl{
expireTime = Calendar.getInstance(); expireTime = Calendar.getInstance();
expireTime.setTime(shareFile.getExpireTime()); expireTime.setTime(shareFile.getExpireTime());
if(now.after(expireTime)) if(now.after(expireTime))
shareFIleMapper.deleteShareFile(shareFile.getShareCode()); shareFileMapper.deleteShareFile(shareFile.getShareCode());
} }
} }
@@ -286,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");
} }
} }
} }
@@ -3,32 +3,31 @@ package com.lion.lionwebsite.Service;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.lion.lionwebsite.Dao.CustomConfigurationMapper; import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.ShareFileMapper; import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Dao.UserMapper; import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration; import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile; import com.lion.lionwebsite.Domain.ShareFile;
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.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;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.tomcat.util.http.fileupload.IOUtils; import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; 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;
@@ -43,30 +42,23 @@ import java.util.concurrent.Executors;
@Service @Service
@Data @Data
@ConfigurationProperties(prefix = "personal-service")
@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; String StoragePath = "/storage/";
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter(); DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
ExecutorService compressThreadPool; ExecutorService compressThreadPool = Executors.newFixedThreadPool(1);
PersonalServiceImpl(){ final PushService pushService;
compressThreadPool = Executors.newFixedThreadPool(1);
}
/** /**
* 获取文件列表,同时带上分享码以及过期时间 * 获取文件列表,同时带上分享码以及过期时间
@@ -146,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);
} }
} }
@@ -178,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
@@ -312,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) {
ArchiveEntry 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 } catch (IOException e) {
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { log.error("打包失败", e);
ArchiveEntry entry = new TarArchiveEntry(file.toFile(), dirPath.relativize(file).toString()); } finally {
aos.putArchiveEntry(entry); try { Files.deleteIfExists(temporary); }
IOUtils.copy(Files.newInputStream(file.toFile().toPath()), aos); catch (IOException e) { log.warn("清理打包临时文件失败", e); }
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){
e.printStackTrace();
log.info("打包失败,删除文件结果:" + new File(finalPath + ".tar***undone").delete());
} }
}); });
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 目标路径
@@ -362,4 +366,10 @@ public class PersonalServiceImpl{
return response.toJSONString(); return response.toJSONString();
} }
public String message2me(String message){
Response response = Response.generateResponse();
pushService.sendToMe(message);
return response.success().toJSONString();
}
} }
@@ -1,9 +1,9 @@
package com.lion.lionwebsite.Service; package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.CustomConfigurationMapper; import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.ShareFileMapper; import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Dao.UserMapper; import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration; import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile; import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Domain.User; import com.lion.lionwebsite.Domain.User;
@@ -12,38 +12,28 @@ 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.ServletOutputStream;
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;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Calendar; import java.util.Calendar;
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
@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;
/** /**
* 记录家里ip地址 * 记录家里ip地址
@@ -68,7 +58,7 @@ public class PublicServiceImpl {
if(ShareCode == null) { if(ShareCode == null) {
response.failure("ShareCode invalid"); response.failure("ShareCode invalid");
} else { } else {
ShareFile shareFile = shareFIleMapper.selectShareFileByShareCode(ShareCode); ShareFile shareFile = shareFileMapper.selectShareFileByShareCode(ShareCode);
Calendar ExpireTime = Calendar.getInstance(); Calendar ExpireTime = Calendar.getInstance();
Calendar now = Calendar.getInstance(); Calendar now = Calendar.getInstance();
@@ -78,7 +68,7 @@ public class PublicServiceImpl {
FileDownload.export(httpRequest, httpResponse, shareFile.getFilePath()); FileDownload.export(httpRequest, httpResponse, shareFile.getFilePath());
return true; return true;
} else { } else {
shareFIleMapper.deleteShareFile(shareFile.getShareCode()); shareFileMapper.deleteShareFile(shareFile.getShareCode());
response.failure("ShareCode is expired or File is not exist"); response.failure("ShareCode is expired or File is not exist");
} }
} else } else
@@ -108,54 +98,4 @@ public class PublicServiceImpl {
public User getUserId(String AuthCode){ public User getUserId(String AuthCode){
return userMapper.selectUserByAuthCode(AuthCode); return userMapper.selectUserByAuthCode(AuthCode);
} }
public void getEhThumbnail(String path, HttpServletResponse response){
String url;
if(!path.contains("/")){
fourZeroFour(response);
return;
}
url = "https://ehgt.org/" + path;
try{
byte[] imageBytes = getImageBytesFromUrl(url);
ServletOutputStream outputStream = response.getOutputStream();
outputStream.write(imageBytes);
outputStream.close();
}catch (IOException e){
e.printStackTrace();
try {
response.sendError(503);
}catch (IOException ex){
ex.printStackTrace();
}
}
}
public static byte[] getImageBytesFromUrl(String imageUrl) throws IOException {
URL url = new URL(imageUrl);
InputStream inputStream = null;
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
// 打开URL连接
inputStream = url.openStream();
byte[] buffer = new byte[1024];
int bytesRead;
// 从输入流读取数据并写入输出流
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
} finally {
// 关闭流
if (inputStream != null) {
inputStream.close();
}
outputStream.close();
}
// 返回图片的字节数组
return outputStream.toByteArray();
}
} }
@@ -0,0 +1,52 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Domain.Gallery;
import com.lion.lionwebsite.Util.Response;
import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.request.SendMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Service
@Slf4j
@RequiredArgsConstructor
public class PushService {
long self = 686839482;
private static final DateTimeFormatter COMPLETION_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
final TelegramBot bot;
public void taskCreateReport(String username, String taskName, Response response){
if(response.isSuccess())
sendToMe(String.format("用户%s提交下载任务:%s", username, taskName));
else
sendToMe(String.format("用户%s提交下载任务:%s,下载失败:%s", username, taskName, response.get("data")));
}
public void downloadComplete(Gallery gallery){
String completionTime = COMPLETION_TIME_FORMATTER.format(Instant.now());
sendToMe(String.format("任务下载完成:%s\n完成时间:%s", gallery.getName(), completionTime));
}
public void storageNodeOnline(){
sendToMe("存储节点上线");
}
public void storageNodeOffline(){
sendToMe("存储节点掉线");
}
public void sendToMe(String text){
log.info(text);
SendMessage sendMessage = new SendMessage(self, text);
bot.execute(sendMessage);
}
}
@@ -1,15 +1,16 @@
package com.lion.lionwebsite.Service; 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.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.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
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;
@@ -17,18 +18,22 @@ import org.jsoup.select.Elements;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.*; import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*; import java.util.*;
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
@Service @Service
@Slf4j @Slf4j
public class QueryService { public class QueryService {
String CachePath = "/storage/hentaiCache/"; String CachePath = "/storage/galleryCache/thumbnails/";
public String query(String keyword, String prev, String next) { public String query(String keyword, String prev, String next) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
String get; String result;
String param = "?f_search=" + keyword.replace(" ", "+"); String param = "?f_search=" + keyword.replace(" ", "+") + "&f_sft=on&f_sfu=on&f_sfl=on";
if(prev != null) if(prev != null)
param += "&prev=" + prev; param += "&prev=" + prev;
@@ -36,17 +41,16 @@ public class QueryService {
param += "&next=" + next; param += "&next=" + next;
try{ try{
get = requests("https://exhentai.org/" + param, false, 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();
} }
Document parse = Jsoup.parse(get); Document parse = Jsoup.parse(result);
Elements elements = parse.select("body > div.ido > div:nth-child(2) > table > tbody > tr"); Elements elements = parse.select("body > div.ido > div:nth-child(2) > table > tbody > tr");
ArrayList<GalleryForQuery> galleries = new ArrayList<>(); ArrayList<GalleryForQuery> galleries = new ArrayList<>();
ListIterator<Element> elementListIterator = elements.listIterator(); ListIterator<Element> elementListIterator = elements.listIterator();
try { try {
@@ -67,13 +71,12 @@ public class QueryService {
gallery.setThumbnailUrl(src); //thumbnailSrc gallery.setThumbnailUrl(src); //thumbnailSrc
gallery.setUploadTime(element.child(1).select("div > div [onclick]").get(1).text()); //uploadTime gallery.setUploadTime(element.child(1).select("div > div [onclick]").get(1).text()); //uploadTime
gallery.setLink(element.child(2).child(0).attr("href")); //link gallery.setLink(element.child(2).child(0).attr("href")); //link
gallery.setGid(GalleryUtil.parseGid(gallery.getLink()) + ""); //gid
gallery.setName(element.child(2).child(0).child(0).text()); //name gallery.setName(element.child(2).child(0).child(0).text()); //name
gallery.setPage(Integer.parseInt(element.child(3).child(1).text().split(" ")[0])); //page gallery.setPage(Integer.parseInt(element.child(3).child(1).text().split(" ")[0])); //page
galleries.add(gallery); galleries.add(gallery);
} }
response.success(new ObjectMapper().valueToTree(galleries).toString()); response.success(new ObjectMapper().valueToTree(galleries).toString());
Elements nextLink = parse.select("#unext"); Elements nextLink = parse.select("#unext");
if(nextLink.hasAttr("href")) if(nextLink.hasAttr("href"))
@@ -94,81 +97,24 @@ public class QueryService {
return response.toJSONString(); return response.toJSONString();
} }
public void image(HttpServletResponse response, String path){ public void getEhThumbnail(String path, HttpServletRequest request, HttpServletResponse response){
String imageName = path.substring(path.lastIndexOf("/") + 1); if(!path.contains("/")){
File image = new File(CachePath, imageName); fourZeroFour(response);
if(image.isFile()){ //hit cache return;
log.info("hit cache:{}", imageName);
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(image));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(response.getOutputStream())){
bufferedInputStream.transferTo(bufferedOutputStream);
}catch (IOException e){
e.printStackTrace();
} }
} String fileName = path.substring(path.lastIndexOf("/") + 1);
else{ // transfer image and save it as cache String suffix = fileName.substring(fileName.lastIndexOf("."));
log.info("miss cache:{}", imageName); fileName = fileName.substring(0, fileName.lastIndexOf("."));
String sourceUrl = "https://ehgt.org/" + path;
try { try {
requests("https://s.exhentai.org" + path, true, response, new FileOutputStream(image)); Path image = ImageFileCache.get(Path.of(CachePath), fileName, () -> sourceUrl);
}catch (IOException e){ FileDownload.export(request, response, image.toString());
e.printStackTrace(); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
response.setStatus(503);
} catch (Exception e) {
log.warn("获取缩略图失败: errorType={}", e.getClass().getSimpleName());
if (!response.isCommitted()) response.setStatus(404);
} }
} }
}
public String requests(String url, boolean isDirect, HttpServletResponse response, OutputStream local) throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse httpResponse;
HashMap<String, String> headers = new HashMap<>();
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36");
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
headers.put("Upgrade-Insecure-Requests", "1");
headers.put("Cookie", "ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; igneous=cea2e08fb; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1");
HttpGet httpGet = new HttpGet(url);
for (Map.Entry<String, String> header: headers.entrySet()){
httpGet.addHeader(header.getKey(), header.getValue());
}
httpResponse = httpClient.execute(httpGet);
HttpEntity responseEntity = httpResponse.getEntity();
int statusCode = httpResponse.getStatusLine().getStatusCode();
try {
if (statusCode == 200) {
if (isDirect) {
InputStream inputStream = new BufferedInputStream(responseEntity.getContent());
byte[] bytes = inputStream.readAllBytes();
if(response != null) {
OutputStream outputStream = response.getOutputStream();
outputStream.write(bytes);
outputStream.close();
}
if(local != null){
local.write(bytes);
local.close();
}
inputStream.close();
return null;
} else {
StringBuilder stringBuilder = new StringBuilder();
String str;
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
while ((str = bufferedReader.readLine()) != null) {
stringBuilder.append(str).append("\n");
}
return stringBuilder.toString();
}
} else {
System.out.println(statusCode);
return null;
}
}finally {
httpClient.close();
httpResponse.close();
}
}
} }
@@ -1,6 +1,6 @@
package com.lion.lionwebsite.Service; package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.GalleryMapper; import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Domain.Gallery; import com.lion.lionwebsite.Domain.Gallery;
import com.lion.lionwebsite.Domain.GalleryTask; import com.lion.lionwebsite.Domain.GalleryTask;
import com.lion.lionwebsite.Message.*; import com.lion.lionwebsite.Message.*;
@@ -12,275 +12,387 @@ 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 jakarta.annotation.Resource;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.net.*; import java.net.*;
import java.nio.ByteBuffer; import java.util.Arrays;
import java.nio.channels.FileChannel; import java.util.concurrent.CompletableFuture;
import java.nio.channels.ServerSocketChannel; import java.util.concurrent.ConcurrentHashMap;
import java.nio.channels.SocketChannel; import java.util.concurrent.CopyOnWriteArrayList;
import java.nio.file.StandardOpenOption; import java.util.concurrent.ExecutionException;
import java.util.HashMap;
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
@ConfigurationProperties(prefix = "remote-service")
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;
String storagePath; final GalleryMapper galleryMapper;
@Resource final PushService pushService;
GalleryMapper galleryMapper;
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);
public RemoteService(){ final WebSocketService webSocketService;
atomicInteger = new AtomicInteger(0);
eventLoopGroup = new DefaultEventLoop();
downloadThread = Executors.newCachedThreadPool();
promiseHashMap = new HashMap<>();
if(!initChannel()){ //如果远程服务器连接失败,则开启本地监听 final SubscriptionStandbySnapshotService subscriptionStandbySnapshotService;
monitor = new Thread(this::monitorFunc);
monitor.start(); final ExecutorService subscriptionSyncExecutor = Executors.newSingleThreadExecutor(r -> {
} Thread thread = new Thread(r, "subscription-standby-sync");
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 {
if (!isDead())
return true;
int 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(1024, 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)).sync(); }).connect(new InetSocketAddress(ip, port + i)).sync();
log.info("connect success"); break;
channel = channelFuture.channel(); } catch (InterruptedException e) {
channel.writeAndFlush(new IdentityMessage()); Thread.currentThread().interrupt();
return true; return false;
}catch (Exception e){ } catch (Exception e) {
log.info("connect node failed, wait for node back online"); log.error("连接storageNode失败,端口偏移量(重试次数):{}", i);
}
if (stopping)
return false; return false;
} }
//超过二十次连不上,主动抛出错误,由下方catch
if(i==20) {
throw new Exception();
}
if (stopping) {
channelFuture.channel().close();
return false;
}
log.info("connect success");
if(pushService != null)
pushService.storageNodeOnline();
channel = channelFuture.channel();
closeMonitorSocket();
channel.writeAndFlush(new IdentityMessage("lionwebsite"));
//子节点上线时,发送未完成的任务
resetUndone();
requestSubscriptionSync();
return true;
}catch (Exception e){
log.error("connect node failed, wait for node back online", e);
return false;
} finally {
connecting.set(false);
if (isDead())
startMonitor();
}
}
public byte reconnect(){
//如果当前就是未连接状态,直接返回-2
if (isDead()){
return -2;
}
channelFuture.channel().close().awaitUninterruptibly();
if(initChannel()){
return 0;
}
return -1;
}
public byte checkAvailability(){
return sendRequest(new AvailableCheckMessage(), 10, TimeUnit.SECONDS);
}
byte sendRequest(AbstractMessage message, long timeout, TimeUnit unit) {
Channel target = channel;
if (stopping || target == null || !target.isActive())
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 byte addGalleryToQueue(Gallery gallery, byte type){ public void resetUndone(){
if (channelFuture.channel() == null || !channelFuture.channel().isActive())
return;
Gallery[] galleries = galleryMapper.selectUnDoneGalleries();
if(galleries != null && galleries.length != 0) {
log.info("发送未下载完成图片至节点,{}本", galleries.length);
log.info("{}", Arrays.toString(galleries));
for (Gallery gallery : galleries)
addGalleryToQueue(gallery);
}
}
public byte addGalleryToQueue(Gallery gallery){
GalleryTask galleryTask = new GalleryTask(); GalleryTask galleryTask = new GalleryTask();
galleryTask.setGid(gallery.getGid()); galleryTask.setGid(gallery.getGid());
galleryTask.setType(type); 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) -> {
public byte deleteGallery(Gallery gallery, byte type){ waiters.remove(statusFuture);
DeleteGalleryMessage dgm = new DeleteGalleryMessage(); return waiters.isEmpty() ? null : waiters;
dgm.setGalleryName(gallery.getName());
dgm.setDeleteType(type);
dgm.messageId = atomicInteger.getAndIncrement();
channel.writeAndFlush(dgm);
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(dgm.messageId, promise);
try{
boolean result = promise.await(10, TimeUnit.SECONDS);
if(result){
ResponseMessage rsm = (ResponseMessage) promise.getNow();
return rsm.getResult();
}else return -1;
}catch (InterruptedException e){
return -1;
}
}
public byte cachePreview(String galleryName, short page, File file){
File parentFile = file.getParentFile();
if(!parentFile.isDirectory())
if(parentFile.mkdirs())
log.info("创建文件夹{}成功", parentFile.getAbsolutePath());
else
log.error("创建文件夹{}失败", parentFile.getAbsolutePath());
GalleryRequestMessage grm = new GalleryRequestMessage();
grm.setGalleryName(galleryName);
grm.setPage(page);
grm.setType(GalleryRequestMessage.PREVIEW);
grm.messageId = atomicInteger.getAndIncrement();
DefaultPromise<Object> downloadPromise = new DefaultPromise<>(eventLoopGroup);
DefaultPromise<Object> readyPromise = new DefaultPromise<>(eventLoopGroup);
downloadThread.submit(() -> {
try {
short port = CustomUtil._findIdlePort();
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.bind(new InetSocketAddress("0.0.0.0", port));
grm.setPort(port);
readyPromise.setSuccess("");
SocketChannel socketChannel = ssChannel.accept();
FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (socketChannel.read(byteBuffer) != -1){
byteBuffer.flip();
fileChannel.write(byteBuffer);
byteBuffer.clear();
}
fileChannel.close();
socketChannel.close();
ssChannel.close();
log.info("缓存预览图:" + galleryName + " " + file.getName());
downloadPromise.setSuccess("");
}catch (IOException e){
e.printStackTrace();
}
}); });
try{
readyPromise.await();
channel.writeAndFlush(grm);
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(grm.messageId, promise);
boolean result = promise.await(10, TimeUnit.SECONDS);
if(result){
ResponseMessage rsm = (ResponseMessage) promise.getNow();
if(rsm.getResult() == 0){
downloadPromise.await(10, TimeUnit.SECONDS);
return (byte) (downloadPromise.isSuccess()?0:1);
}else{
return rsm.getResult();
}
}else {
return -1;
}
}catch (InterruptedException e){
return -1;
} }
} }
public String queryPageName(String name, int page){ private void completeRetryStatusWaiters(int gid, String status){
GalleryPageQueryMessage gpqm = new GalleryPageQueryMessage(); CopyOnWriteArrayList<CompletableFuture<String>> waiters = retryStatusWaiters.remove(gid);
gpqm.setName(name); if(waiters != null)
gpqm.setPage(page); waiters.forEach(waiter -> waiter.complete(status));
gpqm.messageId = atomicInteger.getAndIncrement();
channel.writeAndFlush(gpqm);
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(gpqm.messageId, promise);
try{
boolean result = promise.await(10, TimeUnit.SECONDS);
if(result){
gpqm = (GalleryPageQueryMessage) promise.getNow();
if(gpqm.getResult() == 0)
return gpqm.getPageName();
else {
log.error("galleryPageQuery error:" + gpqm.getResult());
return null;
}
}else{
return null;
}
}catch (InterruptedException e){
return null;
}
} }
public void updateGallery(Gallery gallery, byte type){ public record RetryResult(boolean success, String message) {}
GalleryTask galleryTask = new GalleryTask();
galleryTask.setGid(gallery.getGid());
galleryTask.setType(type);
galleryTask.setPages(gallery.getPages());
UpdateGalleryMessage ugm = new UpdateGalleryMessage(); public byte deleteGallery(Gallery gallery){
ugm.messageId = atomicInteger.getAndIncrement(); DeleteGalleryMessage message = new DeleteGalleryMessage();
ugm.setGalleryTask(galleryTask); message.setGalleryName(gallery.getName());
channel.writeAndFlush(ugm); return sendRequest(message, 10, TimeUnit.SECONDS);
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(ugm.messageId, promise);
try {
boolean result = promise.await(10, TimeUnit.SECONDS);
if(result){
ResponseMessage rsm = (ResponseMessage)promise.getNow();
if(rsm.getResult() != 0)
log.error("更新本子" + gallery.getName() + "出错" + rsm.getResult());
} }
}catch (InterruptedException e){
e.printStackTrace(); private void startMonitor() {
if (stopping || !monitoring.compareAndSet(false, true))
return;
monitor = new Thread(this::monitorFunc, "storage-node-monitor");
monitor.setDaemon(true);
monitor.start();
}
private void closeMonitorSocket() {
ServerSocket socket = monitorSocket;
if (socket != null) {
try { socket.close(); }
catch (IOException e) { log.debug("关闭节点监听失败", e); }
} }
} }
public void monitorFunc(){ public void monitorFunc(){
System.out.println("监听端口: " + (port + 1) + " 等待节点上线"); try (ServerSocket socket = new ServerSocket(CustomUtil._findIdlePort(port + 1))) {
try(ServerSocket socket = new ServerSocket(port + 1)) { monitorSocket = socket;
Socket client; if (stopping || !isDead())
while(true){ return;
client = socket.accept(); log.info("监听端口: {}等待节点上线", socket.getLocalPort());
client.close(); while (!stopping) {
if(client.getInetAddress().getHostAddress().equals("5.255.110.45")){ try (Socket client = socket.accept()) {
System.out.println("尝试连接"); if (!client.getInetAddress().getHostAddress().equals(ip))
initChannel(); continue;
socket.close(); OutputStream output = client.getOutputStream();
output.write("lionwebsite".getBytes(java.nio.charset.StandardCharsets.UTF_8));
output.flush();
client.shutdownOutput();
if (initChannel())
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);
}
}
} }
} }
@@ -289,39 +401,60 @@ public class RemoteService {
public void channelRead(ChannelHandlerContext ctx, Object msg) { public void channelRead(ChannelHandlerContext ctx, Object msg) {
//如果不是响应信息或者响应信息为失败,则打印 //如果不是响应信息或者响应信息为失败,则打印
if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0) if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0)
System.out.println(msg); if(! (msg instanceof MaintainMessage))
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(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE){ if (gallery == null) {
gallery.setStatus("下载完成"); log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}",
galleryTask.getGid(), galleryTask.getName());
continue;
}
gallery.setProceeding(galleryTask.getProceeding()); gallery.setProceeding(galleryTask.getProceeding());
galleryMapper.updateGallery(gallery);
}else if(galleryTask.getProceeding() != 0){ if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
gallery.setStatus("下载中");
gallery.setProceeding(galleryTask.getProceeding());
if(!gallery.getName().equals(galleryTask.getName()))
gallery.setName(galleryTask.getName()); gallery.setName(galleryTask.getName());
galleryMapper.updateGallery(gallery);
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
boolean justCompleted = !"下载完成".equals(gallery.getStatus());
gallery.setStatus("下载完成");
if (justCompleted)
pushService.downloadComplete(gallery);
}
else if(galleryTask.getStatus() == GalleryTask.COMPRESSING)
gallery.setStatus("压缩中");
else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE)
gallery.setStatus("等待压缩");
else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING)
gallery.setStatus("下载中");
log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages()); log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages());
galleryMapper.updateGallery(gallery);
completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus());
} }
webSocketService.updateTaskProcessing(galleryTasks);
} }
else if(msg instanceof ResponseMessage rsm) {
Promise<AbstractMessage> promise = promiseHashMap.remove(rsm.messageId);
if(promise != null)
promise.trySuccess(rsm);
else
log.warn("收到无等待者的响应消息: messageId={}", rsm.messageId);
} }
else if(msg instanceof ResponseMessage rsm)
promiseHashMap.get(rsm.messageId).setSuccess(rsm);
else if(msg instanceof GalleryPageQueryMessage gpqm)
promiseHashMap.get(gpqm.messageId).setSuccess(gpqm);
} }
@Override @Override
public void channelUnregistered(ChannelHandlerContext ctx) { public void channelUnregistered(ChannelHandlerContext ctx) {
if(ctx.channel() != null && ctx.channel().remoteAddress().toString().equals(channel.remoteAddress().toString())){ if (ctx.channel() == channel) {
System.out.println("activate monitor thread, waiting for node back online"); failPendingRequests();
monitor = new Thread(RemoteService.this::monitorFunc); if (!stopping) {
monitor.start(); pushService.storageNodeOffline();
startMonitor();
}
} }
} }
} }
@@ -0,0 +1,289 @@
package com.lion.lionwebsite.Service;
import cn.hutool.core.util.RandomUtil;
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.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.GalleryUtil;
import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Date;
import java.util.concurrent.locks.Lock;
import java.util.function.Supplier;
@Service
@Slf4j
@RequiredArgsConstructor
public class SubService {
final SubMapper subMapper;
final UserMapper userMapper;
final SubscriptionRefreshService refreshService;
final RemoteService remoteService;
final SubscriptionStateCoordinator stateCoordinator;
public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
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 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();
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);
while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8);
subMapper.insertSubBind(new SubBind(key, user, account.getId(), account.getName(), account.isEnabled(), account.isFilterHighMultiplier()));
remoteService.requestSubscriptionSync();
return response.success("添加成功").toJSONString();
}
public String resetKey(String user) {
return withWriteLock(() -> resetKeyUnlocked(user));
}
private String resetKeyUnlocked(String user) {
Response response = Response.generateResponse();
if (subMapper.countSubBindByUser(user) == 0)
return response.failure("绑定不存在").toJSONString();
String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8);
subMapper.updateSubBindKey(user, key);
subMapper.deleteSubUpdateRecord(user);
remoteService.requestSubscriptionSync();
return response.success().toJSONString();
}
public String rebind(String user, Integer accountId) {
return withWriteLock(() -> rebindUnlocked(user, accountId));
}
private String rebindUnlocked(String user, Integer accountId) {
Response response = Response.generateResponse();
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
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() {
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubUpdateRecord())).toJSONString();
}
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key) {
if (key == null || client == null)
return;
SubBind subBind = subMapper.selectSubBind(key);
if (subBind == null || subBind.getSubscriptionAccountId() == null || !subBind.isSubscriptionAccountEnabled()) {
sendStatus(response, HttpServletResponse.SC_NOT_FOUND, "subscription not found");
return;
}
String ip = resolveClientIp(request);
String ua = request.getHeader("User-Agent");
if (ua == null) return;
recordUpdate(subBind.getUser(), ip, ua);
if (!"v2".equals(client) && !"cat".equals(client)) {
sendStatus(response, HttpServletResponse.SC_BAD_REQUEST, "client error");
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;
try {
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
location = tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim() : (tds.isEmpty() ? "unknown" : tds.get(1).text());
} catch (Exception e) {
location = "unknown";
}
subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), location));
if (subMapper.selectUpdateRecordCount(user) > 10)
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user));
}
public String deleteSubBind(String user) {
return withWriteLock(() -> deleteSubBindUnlocked(user));
}
private String deleteSubBindUnlocked(String user) {
subMapper.deleteSubBind(user);
subMapper.deleteSubUpdateRecord(user);
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();
}
}
@@ -1,124 +0,0 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.GalleryMapper;
import com.lion.lionwebsite.Dao.TagMapper;
import com.lion.lionwebsite.Domain.Gallery;
import com.lion.lionwebsite.Domain.Tag;
import com.lion.lionwebsite.Util.Response;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
@Service
public class TagService {
@Resource
TagMapper tagMapper;
@Resource
GalleryMapper galleryMapper;
public String createTag(String tagStr){
Response response = Response.generateResponse();
//查看tag是否存在
if(tagMapper.selectTagExistByTag(tagStr) != 0){
response.failure("该tag:" + tagStr + "已存在");
}else{
Tag tag = new Tag(0, tagStr, 0);
tagMapper.insertTag(tag);
response.success("创建tag:" + tagStr + "成功");
response.set("tid", String.valueOf(tag.getId()));
}
return response.toJSONString();
}
public String selectAllTag(){
Response response = Response.generateResponse();
ArrayList<Tag> tags = tagMapper.selectAllTag();
HashMap<Integer, Tag> tagHashMap = new HashMap<>();
for (Tag tag : tags) {
tagHashMap.put(tag.getId(), tag);
}
if(tags.isEmpty())
response.failure("当前还没有标签");
else
response.success(new ObjectMapper().valueToTree(tagHashMap).toString());
return response.toJSONString();
}
public String deleteTag(int tagId){
Response response = Response.generateResponse();
if(tagMapper.selectTagUsage(tagId) == 0){
tagMapper.deleteTagById(tagId);
response.success("标签删除成功");
}else{
response.failure("标签删除失败,还有本子引用该标签(如果还显示可以删除,请刷新)");
}
return response.toJSONString();
}
public String markTag(int gid, int tid){
Response response = Response.generateResponse();
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
if(gallery == null)//查看本子是否存在
response.failure("本子不存在,无法标记标签");
else if (tagMapper.selectTagExistById(tid) == 0) //查看标签是否存在
response.failure("标签不存在,无法标记标签");
else
if(tagMapper.selectIsMark(gid, tid) != 0) //查看是否已有该条记录
response.failure("当前本子已有该标签");
else {
if (tagMapper.markTag(gid, tid) == 1) {
response.success("标记本子成功");
tagMapper.incrTagUsage(tid);
}
else
response.failure("未知错误");
}
return response.toJSONString();
}
public String createTagAndMark(int gid, String tagString){
Response response = Response.generateResponse();
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
//查看tag是否存在
if(tagMapper.selectTagExistByTag(tagString) != 0){
response.failure("该tag:" + tagString + "已存在,请刷新再尝试");
}else if(gallery == null) {//查看本子是否存在
response.failure("本子不存在,取消创建标签,请刷新再尝试");
}else{
Tag tag = new Tag(0, tagString, 0);
tagMapper.insertTag(tag);
if (tagMapper.markTag(gid, tag.getId()) == 1) {
response.success("创建标签并标记成功");
response.set("tid", String.valueOf(tag.getId()));
tagMapper.incrTagUsage(tag.getId());
}
}
return response.toJSONString();
}
public String disMarkTag(int gid, int tid){
Response response = Response.generateResponse();
if(tagMapper.disMarkTag(gid, tid) == 0)
response.failure("取消标记失败,可能并没有标记");
else {
response.success("取消标记成功");
tagMapper.decrTagUsage(tid);
}
return response.toJSONString();
}
}
@@ -1,32 +1,31 @@
package com.lion.lionwebsite.Service; package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.CollectMapper; import com.lion.lionwebsite.Dao.normal.CollectMapper;
import com.lion.lionwebsite.Dao.GalleryMapper; import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Dao.UserMapper; import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.User; 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 String addAuthCode(String targetAuthCode, String people) { public String addAuthCode(String targetAuthCode, String people) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
@@ -36,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("插入失败");
} }
@@ -55,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("修改失败");
} }
@@ -73,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("修改失败");
} }
@@ -97,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("删除失败");
} }
@@ -0,0 +1,78 @@
package com.lion.lionwebsite.Service;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import com.lion.lionwebsite.Domain.GalleryTask;
import com.lion.lionwebsite.Util.CustomUtil;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.*;
import java.util.concurrent.CopyOnWriteArrayList;
@Service
@Slf4j
public class WebSocketService implements WebSocketHandler {
CopyOnWriteArrayList<WebSocketSession> sessions;
ObjectMapper objectMapper;
public WebSocketService() {
sessions = new CopyOnWriteArrayList<>();
objectMapper = CustomUtil.objectMapper;
}
public void updateTaskProcessing(GalleryTask[] galleryTasks){
if(sessions.isEmpty())
return;
for (GalleryTask galleryTask : galleryTasks)
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
sessions.forEach(s -> {
try {
s.sendMessage(new TextMessage("{\"type\": \"fullUpdate\"}"));
} catch (Exception e) {
log.warn("WebSocket send fullUpdate failed", e);
}
});
return;
}
ObjectNode objectNode = objectMapper.createObjectNode();
objectNode.put("type", "updateTasks");
objectNode.set("data", objectMapper.valueToTree(galleryTasks));
log.debug("{}", objectNode);
sessions.forEach(s -> {
try {
s.sendMessage(new TextMessage(objectNode.toString()));
}catch (Exception e){
log.warn("WebSocket send updateTasks failed", e);
}
});
}
@Override
public void afterConnectionEstablished(@NotNull WebSocketSession session) {}
@Override
public void handleMessage(@NotNull WebSocketSession session, @NotNull WebSocketMessage<?> message) throws Exception {
if(message.getPayload().toString().equals("DownloaderWebsocket"))
sessions.add(session);
else
session.close();
}
@Override
public void handleTransportError(@NotNull WebSocketSession session, @NotNull Throwable exception) {}
@Override
public void afterConnectionClosed(@NotNull WebSocketSession session, @NotNull CloseStatus closeStatus) {
sessions.remove(session);
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
@@ -1,13 +1,12 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
import com.lion.lionwebsite.Domain.MaskDomain; 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.MalformedURLException;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.URL;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -15,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;
@@ -25,27 +25,7 @@ public class CustomUtil {
public static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static ObjectMapper objectMapper = new ObjectMapper();
private MaskDomain[] maskDomains;
public String restoreUrl(String link){
URL url;
try {
url = new URL(link);
}catch (MalformedURLException e){
return null;
}
for(MaskDomain maskDomain: maskDomains){
if(url.getHost().equals(maskDomain.getMask())){
return link.replace(maskDomain.getMask(), maskDomain.getRaw());
}
else if(url.getHost().equals(maskDomain.getRaw())){
return link;
}
}
return null;
}
public static String fileSizeToString(long fileSize){ public static String fileSizeToString(long fileSize){
if (fileSize < ONE_KB) { if (fileSize < ONE_KB) {
@@ -63,14 +43,14 @@ public class CustomUtil {
String preNum = ""; String preNum = "";
String unit = ""; String unit = "";
Pattern patternWithDot = Pattern.compile("(\\d+.\\d+)([A-Z]+)"); Pattern patternWithDot = Pattern.compile("(\\d+.\\d+)(([A-Z]|[a-z])+)");
Matcher matcherWithDot = patternWithDot.matcher(fileSizeString); Matcher matcherWithDot = patternWithDot.matcher(fileSizeString);
if(matcherWithDot.find()){ if(matcherWithDot.find()){
preNum = matcherWithDot.group(1); preNum = matcherWithDot.group(1);
unit = matcherWithDot.group(2); unit = matcherWithDot.group(2);
} }
else { else {
Pattern patternWithoutDot = Pattern.compile("(\\d+)([A-Z]+)"); Pattern patternWithoutDot = Pattern.compile("(\\d+)(([A-Z]|[a-z])+)");
Matcher matcherWithoutDot = patternWithoutDot.matcher(fileSizeString); Matcher matcherWithoutDot = patternWithoutDot.matcher(fileSizeString);
if (matcherWithoutDot.find()) { if (matcherWithoutDot.find()) {
preNum = matcherWithoutDot.group(1); preNum = matcherWithoutDot.group(1);
@@ -82,9 +62,9 @@ public class CustomUtil {
return switch (unit) { return switch (unit) {
case "B" -> (long) num; case "B" -> (long) num;
case "KB" -> (long) (ONE_KB * num); case "KB", "KiB" -> (long) (ONE_KB * num);
case "MB" -> (long) (ONE_MB * num); case "MB", "MiB" -> (long) (ONE_MB * num);
case "GB" -> (long) (ONE_GB * num); case "GB", "GiB" -> (long) (ONE_GB * num);
default -> 0; default -> 0;
}; };
} }
@@ -102,11 +82,13 @@ public class CustomUtil {
* *
* @return 可用端口的起始位置 -1为没有(几乎没有可能) * @return 可用端口的起始位置 -1为没有(几乎没有可能)
*/ */
public static short _findIdlePort(){ public static int _findIdlePort(int port) {
for(int i=20000; i<65535; i++){ for(int i=port; i<65535; i++){
try(ServerSocket ignored = new ServerSocket(i)){ try(ServerSocket ignored = new ServerSocket(i)){
return (short) i; ignored.close();
return i;
}catch (IOException ignored) { }catch (IOException ignored) {
log.trace("port {} unavailable", i);
} }
} }
return -1; return -1;
@@ -116,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,45 +1,74 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
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.Exception.ResolutionNotMatchException; import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
import org.apache.http.HttpEntity; import java.nio.file.*;
import org.apache.http.client.methods.CloseableHttpResponse; import java.util.concurrent.TimeUnit;
import org.apache.http.client.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.http.client.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.hc.client5.http.entity.EntityBuilder;
import org.apache.http.impl.client.HttpClients; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
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.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.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 static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
public class GalleryUtil { public class GalleryUtil {
static String EX_HENTAI = "https://exhentai.org"; private static final Logger log = LoggerFactory.getLogger(GalleryUtil.class);
static String E_HENTAI = "https://e-hentai.org";
static String POST = "post"; static String POST = "post";
static String GET = "get"; static String GET = "get";
public static ArrayList<Gallery> galleriesForDownload; static String FORM_DATA = "formData";
static { static String JSON = "json";
galleriesForDownload = new ArrayList<>();
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;
} }
/** /**
* 解析/下载本子 * 解析/下载图片
* @param url 本子链接 * @param url 图片链接
* @param isDownload 是否下载 否则仅解析 * @param isDownload 是否下载 否则仅解析
* @param targetResolution 目标下载分辨率 不下载时改值为空 * @param targetResolution 目标下载分辨率 不下载时改值为空
* @return 解析/下载的本子对象 * @return 解析/下载的图片对象
* @throws IOException io问题 * @throws IOException io问题
* @throws ResolutionNotMatchException 没有目标分辨率 * @throws ResolutionNotMatchException 没有目标分辨率
*/ */
@@ -50,33 +79,34 @@ public class GalleryUtil {
return null; return null;
} }
//初始化本子 //初始化图片
Gallery gallery = new Gallery(); Gallery gallery = new Gallery();
gallery.setLink(url); gallery.setLink(url);
gallery.setCreateTime(System.currentTimeMillis()/1000); gallery.setCreateTime(System.currentTimeMillis()/1000);
gallery.setGid(Integer.parseInt(url.split("/")[4])); gallery.setGid(Integer.parseInt(url.split("/")[4]));
gallery.setProceeding(0); gallery.setProceeding(0);
//访问本子页面 //访问图片页面
String galleryPage = requests(url, null, GET, null); String galleryPage = requests(url, GET, null, null);
Document galleryDoc = Jsoup.parse(galleryPage); Document galleryDoc = Jsoup.parse(galleryPage);
//收集本子基本信息 //收集图片基本信息
gallery.setName(galleryDoc.select("#gn").text() + " [" + gallery.getGid() + ']'); gallery.setName(galleryDoc.select("#gn").text() + " [" + gallery.getGid() + ']');
gallery.setLanguage(galleryDoc.select("#gdd > table > tbody > tr:nth-child(4) > td.gdt2").text().replace("%nbps", "")); gallery.setLanguage(galleryDoc.select("#gdd > table > tbody > tr:nth-child(4) > td.gdt2").text().replace("%nbps", ""));
gallery.setPages(Integer.parseInt(galleryDoc.select("#gdd > table > tbody > tr:nth-child(6) > td.gdt2").text().split(" ")[0])); gallery.setPages(Integer.parseInt(galleryDoc.select("#gdd > table > tbody > tr:nth-child(6) > td.gdt2").text().split(" ")[0]));
gallery.setFileSize(CustomUtil.stringToFileSize(galleryDoc.select("#gdd > table > tbody > tr:nth-child(5) > td.gdt2").text().replace(" ", ""))); gallery.setFileSize(CustomUtil.stringToFileSize(galleryDoc.select("#gdd > table > tbody > tr:nth-child(5) > td.gdt2").text().replace(" ", "")));
gallery.setDisplayFileSize(CustomUtil.fileSizeToString(gallery.getFileSize())); gallery.setDisplayFileSize(CustomUtil.fileSizeToString(gallery.getFileSize()));
String thumb_link = galleryDoc.select("#gd1 > div").getFirst().attr("style");
gallery.setThumb_link(thumb_link.substring(thumb_link.indexOf("url(") + 4, thumb_link.lastIndexOf(")")).replace("https://s.exhentai.org", ""));
//查找下载页面链接并初始化参数 //查找下载页面链接并初始化参数
String download_link = galleryDoc.select("#gd5 > p:nth-child(2) > a").attr("onclick").split("'")[1]; String download_link = galleryDoc.select("#gd5 > p:nth-child(2) > a").attr("onclick").split("'")[1];
HashMap<String, String> extraProperties = new HashMap<>(); HashMap<String, String> headers = new HashMap<>();
extraProperties.put("origin", origin); headers.put("origin", origin);
extraProperties.put("referer", download_link); headers.put("referer", download_link);
//访问下载页面,获取分辨率 //访问下载页面,获取分辨率
String downloadPage = requests(download_link, extraProperties, GET, null); String downloadPage = requests(download_link, GET, headers, null);
Document downloadDoc = Jsoup.parse(downloadPage); Document downloadDoc = Jsoup.parse(downloadPage);
Elements resolutions = downloadDoc.select("#db > div > table > tbody > tr > td"); Elements resolutions = downloadDoc.select("#db > div > table > tbody > tr > td");
download_link = downloadDoc.select("#hathdl_form").attr("action"); download_link = downloadDoc.select("#hathdl_form").attr("action");
@@ -102,10 +132,8 @@ public class GalleryUtil {
} }
availableResolution.put("Original", tempResolutions.get("Original")); availableResolution.put("Original", tempResolutions.get("Original"));
gallery.setAvailableResolution(availableResolution); gallery.setAvailableResolution(availableResolution);
//如果不是下载,则直接返回 //如果不是下载,则直接返回
if(!isDownload){ if(!isDownload){
gallery.setStatus("等待确认下载"); gallery.setStatus("等待确认下载");
@@ -114,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);
} }
@@ -123,30 +151,140 @@ public class GalleryUtil {
gallery.setDisplayFileSize(CustomUtil.fileSizeToString(gallery.getFileSize())); gallery.setDisplayFileSize(CustomUtil.fileSizeToString(gallery.getFileSize()));
//提交下载请求 //提交下载请求
downloadPage = requests(download_link, extraProperties, POST, targetResolution); HashMap<String, String> body = new HashMap<>();
body.put("payload", FORM_DATA);
body.put("hathdl_xres", targetResolution.replace("x", "").replace("Original", "org"));
downloadPage = requests(download_link, POST, headers, body);
downloadDoc = Jsoup.parse(downloadPage); downloadDoc = Jsoup.parse(downloadPage);
//判断下载请求是否提交成功 //判断下载请求是否提交成功
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("提交失败");
} }
return gallery; return gallery;
} }
public static ArrayList<ImageKeyCache> parseImageKeys(String url) throws IOException {
public static String queryUpdateLink(String link) throws IOException{ String temp = url.substring(url.indexOf("/g/") + 3);
String page = requests(link, null, GET, null); String mpvUrl = "https://exhentai.org/mpv/" + temp;
Document document = Jsoup.parse(page); String gid = url.split("/")[4];
HashMap<String, String> header = new HashMap<>();
Elements select = document.select("#gnd"); header.put("Referer", url);
if(select.size() > 0) String content = requests(mpvUrl, "get", header, null);
return select.select("a").get(0).attributes().get("href"); if(content.trim().isEmpty())
return null; return null;
Document document = Jsoup.parse(content);
if(document.select("body > script").size() < 2)
return null;
Element script = document.select("body > script").get(1);
String[] scripts = script.html().split("\n");
ArrayList<ImageKeyCache> imageKeyCaches = new ArrayList<>();
AtomicInteger page = new AtomicInteger(1);
gid2MpvKey.put(gid, scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", ""));
JsonNode nodes = objectMapper.readValue(scripts[2].replace("var imagelist = ", ""), JsonNode.class);
nodes.forEach((n) -> {
ImageKeyCache imageKeyCache = new ImageKeyCache();
imageKeyCache.setGid(gid);
imageKeyCache.setImgkey(n.get("k").asText());
imageKeyCache.setPage(page.getAndIncrement());
imageKeyCaches.add(imageKeyCache);
});
return imageKeyCaches;
}
public static String getMpvKey(String url){
String gid = String.valueOf(parseGid(url));
String key = gid2MpvKey.get(gid);
if (key == null) {
// refreshMpvKey writes the cache itself; never call it inside computeIfAbsent.
refreshMpvKey(url);
key = gid2MpvKey.get(gid);
}
return key;
}
public static void refreshMpvKey(String url) {
String temp = url.substring(url.indexOf("/g/") + 3);
String mpvUrl = "https://exhentai.org/mpv/" + temp;
HashMap<String, String> header = new HashMap<>();
header.put("Referer", url);
String content;
try {
content = requests(mpvUrl, "get", header, null);
}catch (Exception e){
log.error("刷新mpvKey失败, url: {}", url, e);
gid2MpvKey.remove(parseGid(url) + "");
return;
}
Document document = Jsoup.parse(content);
Element script = document.select("body > script").get(1);
String[] scripts = script.html().split("\n");
String mpvKey = scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", "");
gid2MpvKey.put(parseGid(url) + "", mpvKey);
log.info("刷新key:{}", mpvKey);
}
public static String getImageUrl(String mpvKey, ImageKeyCache imageKeyCache) {
String apiUrl = "https://s.exhentai.org/api.php";
HashMap<String, String> header = new HashMap<>();
header.put("Referer", "https://exhentai.org");
HashMap<String, String> body = new HashMap<>();
body.put("gid", imageKeyCache.getGid());
body.put("mpvkey", mpvKey);
body.put("imgkey", imageKeyCache.getImgkey());
body.put("method", "imagedispatch");
body.put("page", "" + imageKeyCache.getPage());
body.put("payload", "json");
try {
String result = requests(apiUrl, "post", header, body);
JsonNode jsonNode = objectMapper.readTree(result);
if (jsonNode.has("error") && jsonNode.get("error").asText().equals("Key mismatch"))
return null;
return jsonNode.get("i").asText();
}catch (Exception e){
log.error("获取imgurl失败:{}:{}", imageKeyCache.getGid(), imageKeyCache.getPage(), e);
return null;
}
}
public static String convertImg(String imagePath, String suffix){
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 {
temporary = Files.createTempFile(target.toAbsolutePath().getParent(), ".convert-", ".avif");
process = new ProcessBuilder("convert", source.toString(), temporary.toString())
.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
if (!process.waitFor(60, TimeUnit.SECONDS))
throw new IOException("图片转换超时");
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;
} 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); }
}
}
} }
@@ -161,77 +299,79 @@ public class GalleryUtil {
if(url.length() < 40) if(url.length() < 40)
return null; return null;
if(!url.contains("/g/") || !url.contains("hentai")) if(!url.contains("/g/") || !url.contains("exhentai"))
return null; return null;
else if (url.contains(EX_HENTAI)) return "";
return EX_HENTAI;
else if (url.contains(E_HENTAI))
return E_HENTAI;
else{
System.out.println(url);
return null;
}
} }
/** /**
* 自用request * 自用request
* @param url 链接 * @param url 链接
* @param extraProperties 额外参数 * @param headers 额外参数
* @param method 请求方法 * @param method 请求方法
* @param targetResolution 目标分辨率
* @return 请求页面 * @return 请求页面
* @throws IOException 可能会抛出IO错误 * @throws IOException 可能会抛出IO错误
*/ */
public static String requests(String url, HashMap<String, String> extraProperties, String method, String targetResolution) 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;
HashMap<String, String> headers = new HashMap<>(); if(headers == null)
headers = new HashMap<>();
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"); 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")) {
headers.put("Cookie", ehentaiCookie);
headers.put("Upgrade-Insecure-Requests", "1"); headers.put("Upgrade-Insecure-Requests", "1");
headers.put("Cookie", "ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; igneous=cea2e08fb; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1"); }
if(extraProperties != null)
headers.putAll(extraProperties);
if(method.equals(GET)){ if(method.equals(GET)){
HttpGet httpGet = new HttpGet(url); HttpGet httpGet = new HttpGet(url);
for (Map.Entry<String, String> header: headers.entrySet()){ for (Map.Entry<String, String> header: headers.entrySet())
httpGet.addHeader(header.getKey(), header.getValue()); httpGet.addHeader(header.getKey(), header.getValue());
}
httpResponse = httpClient.execute(httpGet); httpResponse = httpClient.execute(httpGet);
} else { } else {
HttpPost httpPost = new HttpPost(url); HttpPost httpPost = new HttpPost(url);
for (Map.Entry<String, String> header: headers.entrySet()){ for (Map.Entry<String, String> header: headers.entrySet())
httpPost.addHeader(header.getKey(), header.getValue()); httpPost.addHeader(header.getKey(), header.getValue());
}
if(body != null) {
String payload;
if((payload = body.remove("payload")).equals(JSON)) {
EntityBuilder entityBuilder = EntityBuilder.create();
entityBuilder.setContentType(ContentType.APPLICATION_JSON);
entityBuilder.setText(objectMapper.writeValueAsString(body));
httpPost.setEntity(entityBuilder.build());
} else if(payload.equals(FORM_DATA)) {
MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create(); MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create();
multipartEntityBuilder.addTextBody("hathdl_xres", body.forEach(multipartEntityBuilder::addTextBody);
targetResolution.replace("x", "").replace("Original", "org"));
httpPost.setEntity(multipartEntityBuilder.build()); httpPost.setEntity(multipartEntityBuilder.build());
}
}
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{ } else {
System.out.println(statusCode); log.warn("{}:{}", url, statusCode);
} }
httpClient.close();
httpResponse.close();
return stringBuilder.toString(); return stringBuilder.toString();
} }
}
public static Integer parseGid(String link){
try {
return Integer.parseInt(link.split("/g/")[1].split("/")[0]);
}catch (IndexOutOfBoundsException e){
return null;
}
}
} }
@@ -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,14 +1,13 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
public class Response { public class Response {
HashMap<String, String> result; ObjectNode result;
public Response(){ public Response(){
result = new HashMap<>(); result = CustomUtil.objectMapper.createObjectNode();
} }
public static Response generateResponse(){ public static Response generateResponse(){
@@ -20,7 +19,8 @@ public class Response {
} }
public String get(String key){ public String get(String key){
return result.get(key); JsonNode node = result.get(key);
return node == null ? null : node.asText();
} }
public void setData(String data){ public void setData(String data){
@@ -31,26 +31,50 @@ public class Response {
this.result.put("result", result); this.result.put("result", result);
} }
public void success(){ public Response success(){
setResult("success"); setResult("success");
return this;
} }
public void success(String result){ public Response success(String result){
success(); success();
setData(result); setData(result);
return this;
}
public Response success(JsonNode jsonNode){
success();
this.result.set("data", jsonNode);
return this;
} }
public void failure(){ public void failure(){
setResult("failure"); setResult("failure");
} }
public void failure(String result){ public Response failure(String result){
failure(); failure();
setData(result); setData(result);
return this;
} }
public String getData(){
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(){ public String getResult(){
return result.get("data"); return getData();
}
/** 未设置 result 键时视为失败,而不是抛 NPE。 */
public boolean isSuccess(){
JsonNode node = result.get("result");
return node != null && "success".equals(node.asText());
} }
@@ -72,15 +96,7 @@ public class Response {
return response.toJSONString(); return response.toJSONString();
} }
public static String _default(){
Response response = Response.generateResponse();
response.failure("参数错误");
return response.toJSONString();
}
public String toJSONString(){ public String toJSONString(){
ObjectMapper objectMapper = new ObjectMapper(); return result.toString();
return objectMapper.valueToTree(result).toString();
} }
} }
@@ -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);
}
}
}
Binary file not shown.
+29 -16
View File
@@ -2,11 +2,16 @@ server:
port: 8888 port: 8888
tomcat: tomcat:
max-swallow-size: 10000MB max-swallow-size: 10000MB
http2:
enabled: true
spring: spring:
datasource: datasource-main:
driver-class-name: org.sqlite.JDBC driver-class-name: org.sqlite.JDBC
url: jdbc:sqlite:/root/LionWebsite/LionWebsite.db jdbc-url: jdbc:sqlite:LionWebsite.db
datasource-cache:
driver-class-name: org.sqlite.JDBC
jdbc-url: jdbc:sqlite:cache.db
mvc: mvc:
view: view:
prefix: /resources/templates/ prefix: /resources/templates/
@@ -19,22 +24,30 @@ spring:
max-request-size: 10000MB max-request-size: 10000MB
enabled: true enabled: true
mybatis:
type-aliases-package: com.lion.lionwebsite.Dao
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: remote:
target-path: /storage/gallery/ ip: "5.255.110.45"
cache-size: 100
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"
subscription:
cache-root: sub/accounts
upstream:
# Use environment variables or an external config file in production.
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:
local-service: token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
fires: https://api.dler.io/sub?target=clash&new_name=true&url=https%3A%2F%2Ffast.losadhwselfff2332dasd.xyz%2Flink%2Fz0pfwyTvC5naXkbb%3Fclash%3D1&insert=false&config=https%3A%2F%2Fraw.githubusercontent.com%2FACL4SSR%2FACL4SSR%2Fmaster%2FClash%2Fconfig%2FACL4SSR_Online.ini
DouNaiClash: https://aaaa.gay/link/A5CXg2cJATerEEoe?client=clashv2
DouNaiV2ray: https://aaaa.gay/link/A5CXg2cJATerEEoe?client=v2
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-42
View File
@@ -1,42 +0,0 @@
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" />
<link href="/reset.css" type="text/css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lion</title>
<script type="module" src="/index/index.js"></script>
<link rel="stylesheet" href="/index/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Vite + Vue</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<script type="module" crossorigin src="/mobile/index.js"></script>
<link rel="stylesheet" href="/mobile/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-14
View File
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lion</title>
<script type="module" crossorigin src="/self/index.js"></script>
<link rel="stylesheet" href="/self/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Personal</title>
<script type="module" crossorigin src="/self/mobile.js"></script>
<link rel="stylesheet" href="/self/mobile.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More