Compare commits

66 Commits
Author SHA1 Message Date
root 776eb8cf53 修复 GraalVM 25 原生构建中的 commons-logging 初始化 2026-09-26 12:45:30 +08:00
root e03a2e007b netty 按需声明,并记录 jar 运行期的低内存调优
netty-all 是聚合 pom,会拖进 5 个平台的 native-quic、各架构的 epoll/kqueue/
io_uring 传输以及 htt3/mqtt/redis 等一堆用不到的 codec。主站实际只用
Bootstrap/NioEventLoopGroup/NioSocketChannel/ByteBuf/ByteToMessageCodec/
LengthFieldBasedFrameDecoder/LoggingHandler/Promise,因此收敛为
transport + codec-base + handler(含 buffer/common/resolver 传递依赖)。

效果:jar 68.1MB -> 52.2MB,netty 模块 50 -> 7,进程 RSS 291MB -> 约 227MB。
433 个测试全绿。hutool 未动。

同时把 jar 运行期的 systemd JVM 参数与前后实测数据记入
docs/MEMORY_TUNING_2026-09-21.md,避免这些只存在于服务器 drop-in 的参数
在下次重新部署时丢失。
2026-09-21 17:27:17 +08:00
root 5f1066cdab 去掉订阅快照的周期性重发
节点侧已改为按「收到主站的任何消息」判定新鲜度(过期 ⇔ 主站失联),
主站每 30 分钟的可用性检查即可持续刷新,因此主站不需要任何为续期的周期性推送。

- 删除上一版加入的 scheduledSubscriptionKeepalive(6 小时一次)。
- 保留「内容变化即推」(各处 requestSubscriptionSync)与「节点上线即推」
  (initChannel),两者覆盖变更下发与节点冷启动两种场景。
- 移除对应的 standby 保活配置项。

最终推送时机:只在订阅内容变化时推送,另在节点上线时补推一次;
空闲时零快照传输(此前 60 秒一次时为约 486 MiB/天)。

测试:433 项全过。新增 SubscriptionSnapshotPushTest 锁住两条约定——
RemoteService 不得再有定时任务(续期改由节点存活信号承担),以及订阅刷新成功后
仍必须触发一次推送(用真实 refreshDueAccounts 调用验证)。
2026-09-21 16:49:55 +08:00
root e3d4220997 订阅快照改为变更即推,并加低频保活
原先主站每 60 秒无条件重建并推送整份快照:实测 12 账号时传输约 346 KiB/次、
约 486 MiB/天,节点每次都要 base64 解码、SHA-256、gunzip、JSON 校验后判
APPLY_OLD 丢弃。而 revision 是内容寻址的,订阅不常变动时这些推送纯属浪费。

改为:
- 内容变化即推:各处已有的 requestSubscriptionSync()(刷新成功、绑定/改绑、
  重置 Key、增删停用账号、过滤开关变更)保持原样,延迟仍在数秒内。
- 低频保活:新增 scheduledSubscriptionKeepalive(),默认 6 小时一次,仅用于刷新
  节点「最近收到快照」的时刻。节点的过期判定依据该时刻(见 storageNode 侧修复),
  因此保活周期必须远小于节点 SubscriptionMaxStaleSeconds(默认 7 天)。
- 节点上线即推:initChannel 保持推送一次,覆盖节点刚重启、本地尚无快照的情况。

空闲流量约 486 MiB/天 -> 约 1.4 MiB/天(约三个数量级)。

测试:432 项全过。新增 SubscriptionKeepaliveTest 锁定不变量——保活入口必须存在,
且周期小于节点有效期的 1/4、初始延迟小于一个周期;否则节点可能在两次保活之间判过期。
2026-09-21 16:36:13 +08:00
root 42921ba97b 修复多处隐患并做低风险性能优化
缺陷修复:
- GalleryUtil.parseGid 只捕 IndexOutOfBoundsException:非数字段抛 NumberFormatException、
  link 为 null 抛 NPE,都会穿透成 500(按链接查询直接把用户输入喂进来)。现统一返回 null,
  由调用方转成业务失败。
- 图片索引缓存不是整体生效:先写 gidToKey 再逐页写 ImageKeyCache,中途失败会留下
  「gidToKey 命中但页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。
  现任何异常都回滚已写入部分,并识别历史半截缓存自动重建。
- 全站请求日志把 AuthCode 明文记入 INFO,改为只记 present/absent。
- selectEnableAuthCode 的 SQL 与全量查询完全相同(都无 isEnable 条件),停用用户的
  授权码刷新后仍放行,isEnable 形同虚设;补齐条件并允许 null 按默认 true 处理。
- /validate 手工 String.format 拼 JSON,用户名含引号或反斜杠会产出非法 JSON,
  前端 JSON.parse 失败;改用 ObjectMapper 组装。

性能与并发:
- Netty IO 线程不再直接做 JDBC:节点上报的任务状态改由单线程顺序执行器落库与推送,
  既不打乱「按上报顺序覆盖状态」的语义,也不阻塞心跳与响应。
- 取订阅不再等待第三方归属地查询:先以 unknown 落库并立即分发,归属地由后台线程补齐。
- 搜索关键词与分页参数按 UTF-8 编码,&、#、中文不再破坏上游查询串。
- selectAllGallery 的收藏标记由 O(n*m) 嵌套循环改为集合查找。
- 复用 CustomUtil.objectMapper(8 处 new ObjectMapper);gid2MpvKey 改为有界 LRU(2048);
  缩略图清理排序不再每次比较都重读文件属性;FileDownload 关闭响应流,避免大文件下载的
  临时文件堆积到 Full GC;移除从未使用的 downloadThread 线程池。

测试:431 项全过(新增索引回滚、半截缓存重建、用户名特殊字符、畸形链接查询、
搜索编码、分发不等待定位、批量收藏标记等用例;异步状态处理用例加 awaitStatusApplied
屏障保持断言确定性)。
2026-09-21 16:17:17 +08:00
root 584db277a6 登录方案文档更新为全部完成并记录独立复验结论 2026-09-21 11:25:49 +08:00
root 628f3ad0a2 登录方案:确认复用 /login 并一次返回两条链接,密钥已入库 2026-09-21 10:33:00 +08:00
root dfb647bcd3 登录方案文档更新为已实施状态,补机器人侧票据规格 2026-09-21 10:23:26 +08:00
root 00e27152fe 新增一键登录:共享密钥自签票据换会话
- 新增 PanelLoginTicket:v1.<时间戳>.<HMAC-SHA256> 形状,只验签与时间窗,
  不发一次性状态。机器人用同一把密钥本地签发,因此不需要机器人到主站的网络调用,
  一端临时不可达也不影响生成链接。窗口内可重放(用户明确接受),过期即失效。
- PersonalController 新增 GET /login?t= 换 HttpSession(先作废旧会话防固定攻击)、
  /login/logout 退出、/personal/denied 提示页。
- PersonalInterceptor 放行「有效会话或 AuthCode=alone」,拒绝时回 401 供前端区分;
  InterceptorConfiguration 排除登录端点与 /personal/。
- server.servlet.session.cookie.path 固定为 /:nginx 会把 /user 改写成 /personal/user,
  沿用容器推导的 /personal 会让浏览器判定路径不匹配而丢会话。
- 密钥经 PERSONAL_LOGIN_SECRET 环境变量注入,为空时一律拒绝而非放行。
- 新增 PanelLoginTicketTest(7 项)与登录/退出/会话相关用例,共 423 项通过。
2026-09-21 10:20:51 +08:00
root 654181d2ea 登录方案对齐 PersonalHub 既有实现 2026-09-21 10:03:35 +08:00
root bee2f11fd6 移除文件管理与文件分享,接口范围收敛到订阅与用户管理
- 删除 /personal/files、uploadFile、private/**、compress、delete 与对应服务实现,
  连同压缩线程池、TAR 打包与 PersonalArchiveTest
- 删除 /personal/share、extendShareTime、cancelShare 与 /GetFile/{path},
  以及 ShareFile 实体、ShareFileMapper、PublicService.GetFile、每天四点的
  checkShareCode 定时任务和分享码失败黑名单
- CustomBean 去掉 ShareFile 的 Native 反射登记
- 同步收敛 PersonalServiceTest、PublicServiceTest、PersonalControllerTest、
  PublicControllerTest、LocalServiceTest 与三个画廊测试
- 文档同步,并附上 /login 方案 docs/login-command-plan.md

测试:mvn -o test 410 项通过
2026-09-21 10:02:07 +08:00
root 305036ddfe 新增下载人查询接口,并把管理员判定收敛到服务端
- Gallery 增加 downloaderName,仅在管理员查询任务列表时填充;普通用户
  既不获取也不下发他人身份
- 新增 GET /GalleryManage/downloader?gid=&AuthCode=,按 gid 返回实际下载人
  昵称,非管理员一律拒绝
- /validate 下发 isAdmin,前端不再各自硬编码 userId === 3
- 管理员判定统一走 UserService.ADMIN_USER_ID
- 补充列表填充、按 gid 查询的权限与缺任务分支测试
2026-09-20 22:54:23 +08:00
root d949415e4d 分散刷新后补上备机同步
原实现中「全量刷新 → 通知备机同步」是同一步的两个动作:refreshAll()
由调用方跟一次 requestSubscriptionSync()。改为按账号分散刷新后,调度器
自己没有触发同步,刷新出的新缓存要等到下一次周期同步才可能送到备机。

生产上每 60 秒有一次周期同步兜底,所以影响被限制在最长约一分钟的滞后;
但这属于重构时漏掉的语义,且一旦把周期同步间隔调大就会变成真实缺口,
因此仍按原语义在刷新到内容后立即触发同步。没有账号到期时不打扰备机。

测试 441 通过。
2026-09-15 16:27:29 +08:00
root 3adb42c538 订阅刷新改为每账号错峰,保证 24 小时内各刷一次
原先每 24 小时一次性刷新全部子账号:12 个账号在 16 秒内背靠背发完
24 个请求,且 @Scheduled(fixedRate) 未设 initialDelay,首次触发即立刻
执行,导致每次重启都重刷一遍全部账号——上游看到的请求密度实际取决于
部署频率。请求头也只带 HttpClient 默认值,上游收到的是
"Apache-HttpClient/5.6.4 (Java/25.0.4)"。

改为高频轻量 tick(5 分钟,延迟 2 分钟启动),只刷新已到期账号,单轮
最多 2 个。24 小时保证由三层构成:成功后按「成功时刻 + 窗口 − 一个 tick」
推进相位,使分散效果自我维持;到期时刻持久化在数据库,停机期间错过的
账号恢复后立即到期;兜底把被写到超过一个窗口之后的计划判为损坏并立即
刷新。失败用固定间隔重试(数据库没有连续失败次数字段,做不出真正的指数
退避),重试排在将来时被尊重,避免故障账号每个 tick 重打上游。从未成功过
的账号按 tick 间隔尽快刷,已有缓存的按窗口分散。超过两倍窗口仍未成功的
账号推送告警。

请求头按账号与订阅格式确定性地伪装成 mihomo/clash-verge/v2rayN 等真实
客户端身份;同一账号固定用同一身份,逐请求更换反而更像机器。实现前已
实测确认显式设置 Accept-Encoding 不会破坏 HttpClient5 的透明解压。注意
这只能伪装到 HTTP 头,TLS 指纹仍与真实客户端不同。

新增 next_refresh_at、last_success_epoch 两列,均为 Epoch 毫秒整数:SQLite
的 CURRENT_TIMESTAMP 写 UTC 文本而被 JDBC 按本地时区解释,实测偏差 8 小时,
不能用于时间比较。迁移为纯新增列,旧二进制忽略它们,故回滚程序无需回滚
数据库;回填 last_success_epoch 必须执行,否则既有账号会被当成从未成功过
而集中补刷,正好复现本次要消除的爆发。

同时移除 LocalService 的 24 小时全量入口与 refreshAll()(它们正是爆发式
写法),并将调度线程池由默认 1 调到 2,避免刷新阻塞连接自检。

测试 439 通过,指令覆盖率 82.1%。
2026-09-15 16:15:41 +08:00
root a0959618ca 修复 Jackson 3 迁移引入的看图 500,并大幅补充单元测试
生产缺陷(2026-09-14 Spring Boot 4.1.1 / Jackson 2→3 迁移引入):
- GalleryUtil.parseImageKeys 解析 mpv 页 imagelist 时,行尾分号属 JS 语法,
  Jackson 3 默认 FAIL_ON_TRAILING_TOKENS=true 会抛 StreamReadException。
  改为先由 parseImagelist 剥掉 JS 语句外壳(前缀 + 行尾分号)再交给 JSON 解析。
  已用线上真实页面验证:修复前必抛异常,修复后正确解析出 154 页 imgkey。
- GalleryManageService.cacheImagesKey 只 catch IOException,而 Jackson 3 的
  JacksonException 继承 RuntimeException,异常穿透 Controller(无 @ControllerAdvice)
  表现为 HTTP 500。改为同时兜 JacksonException 并按业务失败返回。
  同时用 parseGidFromLink 拦住段数不足的畸形链接(原先会抛
  ArrayIndexOutOfBoundsException 变成 500)。

既有行为修正:
- PersonalService.deleteFile 原先依据 hutool FileUtil.del 的返回值报成功,
  而该函数对不存在的目标返回 true,导致路径写错也显示「删除成功」;
  改为先校验存在性,不存在则回「文件不存在」。
- PersonalService.getFiles 的 listFiles()==null 分支原先报「文件夹为空」,
  但该返回值只在 I/O 出错时出现,会掩盖权限/磁盘问题,改为「读取文件夹失败」。

测试:143 → 379 用例,指令覆盖率 36.2% → 80.2%(分支 34.6% → 69.8%)。
新增控制器(MockMvc standaloneSetup)、服务层、拦截器/过滤器、WebSocket、
配置装配与 MyBatis AOT hints 的测试;全部离线可跑,不加载 Spring 上下文、不连库、不占端口。
2026-09-15 13:42:22 +08:00
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
128 changed files with 11890 additions and 2102 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
+78
View File
@@ -0,0 +1,78 @@
# 2026-09-21 低内存调优记录(jar 运行期)
编译机未上线期间,两个后端都以 jar 方式运行。本机为 2 核 / 2.9G,活跃堆很小但 RSS 偏高,
做了一轮只改 JVM 参数与依赖声明的低内存调优。全程未改业务代码。
## 实测前后对照
| 服务 | 项目 | 调优前 | 调优后 |
| --- | --- | --- | --- |
| 主站 lionwebsite | RSS | 291 MB | 约 227 MB |
| 主站 lionwebsite | jar 体积 | 68.1 MB | 52.2 MB |
| 主站 lionwebsite | netty 模块 | 50 个 | 7 个 |
| 主站 lionwebsite | 线程数 | 40 | 37 |
| 存储节点 storageNode | RSS | 171 MB | 约 155-162 MB |
| 存储节点 storageNode | netty 模块 | 35 个 | 5 个 |
| 存储节点 storageNode | 线程数 | 24 | 20-22 |
调优前后堆使用都在 40-52 MB 量级,RSS 的主要成分是元空间、代码缓存与各类映射,
因此收益主要来自收敛预留和减少类加载面,而不是堆。
## 一、JVM 参数(systemd drop-in,不在版本控制内)
主站 `/etc/systemd/system/lionwebsite.service.d/jar-run.conf`:
/usr/local/jdk-25/bin/java -Xmx256m -Xss512k -XX:MaxMetaspaceSize=160m \
-XX:+UseSerialGC -XX:+UseCompactObjectHeaders \
-XX:ReservedCodeCacheSize=96m -XX:+ExitOnOutOfMemoryError \
-jar /home/lionwebsite/lionwebsite.jar
存储节点 `/etc/systemd/system/storageNode.service.d/jar-run.conf`:
/usr/local/jdk-25/bin/java -Xmx192m -Xss512k -XX:MaxMetaspaceSize=96m \
-XX:+UseSerialGC -XX:+UseCompactObjectHeaders \
-XX:ReservedCodeCacheSize=96m -XX:+ExitOnOutOfMemoryError \
-cp /root/gallery/storageNode/storageNode-jar.jar:/root/gallery/storageNode/lib/* lion.Main
取值依据:
- `UseSerialGC`:小堆、低吞吐场景,省掉 G1 的分区元数据与并发标记线程。存储节点原本就是默认 SerialGC,主站原本是默认 G1,本次对齐。
- `UseCompactObjectHeaders`:对象头 12 到 8 字节,堆内以 String 与 map 节点为主时收益直接。
- `ReservedCodeCacheSize=96m`:原先 240m 预留,实测主站只用约 14 MB、节点约 6 MB。
- `-Xmx` 收敛:主站由 384m 到 256m,节点由 256m 到 192m,相对活跃堆仍留 4-6 倍余量。
回滚:恢复同目录 `jar-run.conf.bak.20260921T090605Z`(主站)或
`jar-run.conf.bak.20260921T090746Z`(节点)后 `daemon-reload` 加 `restart`。
## 二、依赖精简(已入库)
`netty-all` 是聚合 pom,会拖进全部模块。两个后端只用到 NIO 传输与基础编解码,
因此改为按需声明:
- 主站(netty 4.2.17):`netty-transport` 加 `netty-codec-base` 加 `netty-handler`。
`handler` 用于 `RemoteService` 的 `LoggingHandler` 协议调试日志。
- 存储节点(netty 4.1.138):`netty-transport` 加 `netty-codec`。
节点侧未使用 `handler` 模块的类,故不声明。4.1 无独立 `codec-base`,编解码基类在 `netty-codec`。
剔除的内容包括 5 个平台的 native-quic、aarch64 与 riscv64 与 osx 的 epoll 与 kqueue 与 io_uring 传输、
以及 codec-http3 与 mqtt 与 redis 与 smtp 与 stomp 与 xml 与 protobuf 等。
`hutool-all` 未调整。
## 三、已评估但未采用:AOT 缓存
JDK 25 的 `-XX:AOTCache` 可免原生编译生成类缓存。实测交替 A/B 各两轮:
| | 启动耗时 | RSS |
| --- | --- | --- |
| 无 AOT | 6.37s / 6.74s | 220.9 MB / 223.1 MB |
| 带 AOT | 6.27s / 6.36s | 221.4 MB / 222.7 MB |
启动约快 0.2s(噪声量级),RSS 与元空间均无改善,代价是 80 MB 缓存文件且每次发版需重训。
按降低内存的目标不值得,故未在生产启用。
## 四、遗留与后续
- 原生编译机恢复后应重新评估原生镜像:内存会显著低于 jar,届时上述 JVM 参数失去意义。
- 存储节点目录内仍保留 `lib.bak.20260921T091617Z`(47 jar)与 `storageNode.jar.bak.*`,为本次回滚路径。
- 两个 `jar-run.conf` 的 JVM 参数只存在于服务器 drop-in,未纳入版本控制;重新部署时需按本文恢复。
+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` 已追加待发布说明;本文件是这部分接口行为变更的仓库内交接记录。
+137
View File
@@ -0,0 +1,137 @@
# LionWebsite 一键登录(/login):实现说明
状态:**已全部完成并上线**。主站侧提交 `00e2715`(文档 `dfb647b`、`628f3ad`),
桌面端 `9b5bc94`;机器人侧由 `debian-qq-chatgpt` 于 host-vm103-debian-qq 完成
(见共享盘 `tasks/2026-09-21-lionwebsite-login-bot-command/`)。
关联待办:`td-20260921-a1b2c3`(已归档为
`todos/archive/done-td-20260921-a1b2c3-lionwebsite-login-tg.md`)。
用户已明确选择**接法 B:共享密钥自签票据**,理由是机器人到主站的网络调用不可靠。
因此主站**不提供**任何票据签发端点,也不需要机器人侧发起 HTTP 请求——机器人本地
用共享密钥算出票据即可。
## 1. 已经上线的东西(主站侧)
### 1.1 票据格式
```
v1.<签发时间的 Unix 秒级时间戳>.<HMAC-SHA256 十六进制小写>
```
签名原文为 `v1.<时间戳>`(固定前缀 `v1.` 加时间戳本身),密钥为共享密钥的 UTF-8 字节。
等价于 `hex(HMAC_SHA256(key, "v1." + timestamp))`。
### 1.2 校验规则
- 必须恰好三段,第一段必须是 `v1`;
- 签名比较用常量时间(`MessageDigest.isEqual`);
- 时间戳只接受 `[now - 300s, now]`,**未来时间戳一律拒绝**(防伪造者用远期时间换长期有效);
- 窗口内**允许重放**(用户明确接受),过期即失效;
- 默认 300 秒有效期,可用 `personal.login.ticket-ttl-seconds` 调整,下限 30 秒。
### 1.3 端点
| 端点 | 作用 |
| --- | --- |
| `GET /login?t=<票据>` | 校验票据 → 作废旧会话(防会话固定)→ 建新会话 → 302 跳 `/index`;失败 302 跳 `/denied` |
| `GET /login/logout` | 销毁会话 → 302 跳 `/denied` |
| `GET /denied` | 静态提示页:「请在机器人里发送 `/login`」 |
nginx 无需改动:`location /` 会把 `/login` 改写成后端 `/personal/login`。
### 1.4 会话与鉴权
- 登录成功下发 `JSESSIONID`,参数为 `Path=/`、`HttpOnly`、`SameSite=Lax`,14 天滑动过期。
**`Path=/` 是必须的**:nginx 会把 `/user` 改写成后端 `/personal/user`,若沿用容器按
请求路径推导的 `/personal`,浏览器判定 `/user` 不匹配就不会带会话,面板会一直 401。
- `PersonalInterceptor` 放行「有效会话 **或** `AuthCode=alone`」,两者都拒绝时返回 **401**。
`alone` 是留给下载器前端与存储节点 `/message2me` 推送的,本次**没有**退役它。
### 1.5 密钥
- 主站从环境变量 `PERSONAL_LOGIN_SECRET` 读取(`personal.login.secret`),
由 systemd drop-in `/etc/systemd/system/lionwebsite.service.d/login-secret.conf`
加载 `/etc/lionwebsite/login-secret.env`(`600` 权限,不在仓库里)。
- 密钥为空时**一律拒绝**票据,不会退化成放行。
- 轮换方式:改两侧配置并重启。轮换后旧票据在 300 秒内自然失效。
## 2. 机器人侧待实施(host-vm103-debian-qq)
### 2.1 生成票据
密钥必须与主站一致。**已由主人批准写入 PersonalHub 保险库**,引用为
`secret://services/service-lionwebsite/panel-login-secret`;机器人侧按既有秘密读取流程
申请取值,不要写进仓库或聊天记录。Python 侧计算方式:
```python
import hashlib, hmac, time
def lionwebsite_login_link(secret: str, base: str = "https://personal.lionwebsite.xyz") -> str:
stamp = str(int(time.time()))
signed = f"v1.{stamp}"
digest = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
return f"{base}/login?t=v1.{stamp}.{digest}"
```
Shell 等价写法(`openssl`)供人工验证用:
```sh
SIG=$(printf 'v1.%s' "$STAMP" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
```
### 2.2 命令接入(已实施:复用既有 `/login`)
**复用既有 `/login`,不新建命令名;一条回复里同时给出两个登录链接。**
实现方式(vm103 实际做法,比原计划更稳):**机器人进程不持有密钥**。
签发走保险库固定动作 `CollabSecretService.use_secret`,值只交给进程内签名函数,
不返回、不落盘、不进日志;审计只记引用与动作名 `lionwebsite-login-link`。
QQ 侧 `plugins/login.py` 只经内部令牌 POST `/internal/panel-login`,
由 PersonalService 统一产出文案,因此 QQ 与 Telegram 不会漂移。
- Telegram:`telegram_panel_login()` 的返回文案改为同时给出 PersonalHub 面板链接与
LionWebsite 面板链接,`TELEGRAM_COMMANDS` 里 `login` 的描述同步更新。
- QQ(LionQQBot):既有 `/login`(别名 `/登录`)返回同样两条链接,共用同一套生成逻辑。
- PersonalHub 未完整配置(缺 password / session_secret / TOTP)时,现有逻辑会回一句
配置提示;此时**仍要发出 LionWebsite 链接**,不要让一条链接的失败带掉另一条。
- 帮助菜单(Telegram `/help` 与 QQ 菜单)中 `/login` 的描述同步更新。
- 文案不要写成「两条都只能用一次」:PersonalHub 链接是一次性的,
LionWebsite 票据是 300 秒窗口内可重放。
示例文案:
```
已生成登录链接:
· Personal Hub:[打开面板](< PersonalHub 链接 >)
· LionWebsite:[打开面板](< LionWebsite 链接 >)
两条链接 5 分钟内有效,请勿转发。
```
### 2.3 与 PersonalHub 现有 /login 的关系
PersonalHub 的 `/login` 原本只签发**它自己面板**的票据
(`OneTimePanelLoginTickets` + `/panel/telegram-login`)。改造时保留这条,
再在同一回复里附加 LionWebsite 链接;两者的票据机制与有效期互不影响。
## 3. 交付与复验
- 机器人侧提交状态:**两个机器人仓库的改动尚未提交 Git**(`v103` 侧待主人确认后
按项目约定提交,注意 PersonalHub 工作区另有前一轮未提交改动,勿混提)。
- `us9929main-chatgpt` 的独立复验(2026-09-21):
- 票据边界实测:`now` 与 `now-290s` 放行,`now-301s`、`now-400s`、`now+60s` 均落 `/denied`;
篡改签名与畸形串同样落 `/denied`。
- 密钥明文扫描:抓取两个任务目录全部正文与线程消息共 17 KiB,未出现密钥明文,
只有 4 处 `secret://` 引用。
- 内部接口鉴权:`POST /internal/panel-login` 无令牌与错令牌均返回 403。
- PersonalHub `/health` 返回 200;共享盘 waiting 指针已消费,队列已清空。
## 4. 可选收尾(未做,需要时另开任务)
- 移动端 `PrivateMainForMobile` 仍写死 `authCode: "alone"`。后端两种方式都接受,
所以它能用;要一并切到会话,照搬 `PrivateMain/src/store/index.js` 的改法即可。
- `sourcecode/storageNode` 有两处硬编码 `alone`(`CustomUtil.java` 的 `/message2me`
与 `MultiThreadedHTTPServer.java` 的本机鉴权),属另一仓库,需单独发布后才能退役字面量。
- 概览页的「本机订阅」链接 `https://personal.lionwebsite.xyz/sub/self` 目前是 404,
后端没有对应映射,属历史遗留,与本次登录改造无关。
+160
View File
@@ -0,0 +1,160 @@
================================================================================
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/、/validate
│ ├── PersonalController.java 个人管理 (订阅更新/最后更新时间/IP/留言) /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 订阅最后更新时间、家里 IP、留言转发
│ ├── SubService.java 代理订阅绑定/重置/查询/更新记录
│ ├── CollectService.java 画廊收藏/取消收藏
│ └── UserServiceImpl.java 用户 CRUD + 授权码管理
│
├── Dao/
│ ├── normal/ 主库 Mapper
│ │ ├── GalleryMapper.java gallery 表 CRUD
│ │ ├── UserMapper.java user 表 CRUD
│ │ ├── CollectMapper.java collect 表 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 配置键常量定义
│ ├── 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. 个人管理服务
- 手动触发订阅更新,返回最近更新时间与已记录的家庭 IP
- 管理员留言经 PushService 转发到 Telegram
(文件浏览/上传/下载/分享/压缩/删除已在 td-20260921-a1b2c3 中整体下线)
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 有效性
- 每 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-io, commons-lang3, netty-all (TCP 通信)
java-telegram-bot-api (Telegram Bot), graalvm native-maven-plugin (AOT)
================================================================================
End of Summary
================================================================================
+71 -33
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.5</version> <version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.lion</groupId> <groupId>com.lion</groupId>
@@ -19,18 +19,18 @@
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.mybatis.spring.boot</groupId> <groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId> <artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version> <version>4.1.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<version>1.18.30</version> <version>1.18.46</version>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
@@ -41,20 +41,20 @@
<dependency> <dependency>
<groupId>org.mybatis.spring.boot</groupId> <groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId> <artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>3.0.3</version> <version>4.1.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.15.3</version> <version>1.23.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>5.8.20</version> <version>5.8.47</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -63,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>
@@ -89,45 +75,97 @@
<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> <!--
<version>4.1.101.Final</version> 只保留实际用到的模块。原先的 netty-all 是聚合 pom,会拖进 50 个模块
(含 5 个平台的 native-quic、aarch64/riscv64/osx 的 epoll/kqueue/io_uring、
codec-http3/mqtt/redis/smtp/stomp/xml/protobuf 等),
而本项目只用 Bootstrap/NioEventLoopGroup/NioSocketChannel/ByteBuf/
ByteToMessageCodec/LengthFieldBasedFrameDecoder/LoggingHandler/Promise。
transport 提供 Bootstrap/EventLoop/Channel,codec-base 提供
ByteToMessageCodec 与 LengthFieldBasedFrameDecoder,handler 提供
LoggingHandler(RemoteService 的协议调试日志用)。
buffer/common/resolver 由它们传递引入。
-->
<artifactId>netty-transport</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-base</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.github.pengrad</groupId> <groupId>com.github.pengrad</groupId>
<artifactId>java-telegram-bot-api</artifactId> <artifactId>java-telegram-bot-api</artifactId>
<version>6.9.1</version> <version>10.1.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.lionsoul</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>ip2region</artifactId> <artifactId>spring-boot-starter-websocket</artifactId>
<version>2.7.0</version>
</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> <plugin>
<groupId>org.graalvm.buildtools</groupId> <groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId> <artifactId>native-maven-plugin</artifactId>
<version>1.1.8</version>
<configuration> <configuration>
<imageName>lionwebsite</imageName> <imageName>lionwebsite</imageName>
<buildArgs> <buildArgs>
<arg>--gc=G1</arg>
<arg>-H:+ReportExceptionStackTraces</arg> <arg>-H:+ReportExceptionStackTraces</arg>
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService</arg> <arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.apache.commons.logging.impl.WeakHashtable,org.sqlite.util.ProcessRunner</arg>
</buildArgs> </buildArgs>
<metadataRepository> <metadataRepository>
<enabled>true</enabled> <enabled>true</enabled>
+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;
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
db_path="${1:-LionWebsite.db}"
if [[ ! -f "$db_path" ]]; then
echo "数据库不存在: $db_path" >&2
exit 2
fi
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet lionwebsite 2>/dev/null; then
echo "检测到 lionwebsite.service 仍在运行;请先停止服务再迁移,避免写冲突。" >&2
exit 3
fi
if pgrep -f 'java .*lionwebsite\.jar' >/dev/null 2>&1; then
echo "检测到主站进程仍在运行;请先停止服务再迁移,避免写冲突。" >&2
exit 3
fi
backup="${db_path}.before-refresh-schedule.$(date -u +%Y%m%dT%H%M%SZ)"
cp "$db_path" "$backup"
echo "已备份: $backup"
sqlite3 "$db_path" < "$(dirname "$0")/migrate_subscription_refresh.sql"
echo "--- 校验 ---"
sqlite3 "$db_path" "select count(*) as accounts, sum(next_refresh_at is null) as missing_schedule from subscription_account;"
echo "订阅刷新计划迁移完成: $db_path"
+26
View File
@@ -0,0 +1,26 @@
-- 为每账号分散刷新增加计划字段。
-- 纯新增列:旧版本二进制忽略这两列,因此回滚程序时不必回滚数据库。
BEGIN;
-- next_refresh_at:该子账号下一次应刷新的时刻,Epoch 毫秒。
-- 用整数存时刻,避免 SQLite 文本时间戳被按本地时区解释(实证存在 8 小时偏差)。
-- last_success_epoch:最近一次成功刷新的真实时刻,Epoch 毫秒。
-- 既有的 last_success_at 保持原样,仅供页面展示,不参与调度判断。
ALTER TABLE subscription_account ADD COLUMN next_refresh_at INTEGER;
ALTER TABLE subscription_account ADD COLUMN last_success_epoch INTEGER;
-- 回填 last_success_epoch:SQLite 的 strftime 把文本时间按 UTC 解释,而
-- CURRENT_TIMESTAMP 写入的正是 UTC,因此这里能得到正确时刻(JDBC 读取则会偏 8 小时)。
-- 必须回填:否则所有既有账号都会被当成「从未成功过」,迁移后立刻集中补刷一遍,
-- 正好复现本次改造要消除的爆发。
UPDATE subscription_account
SET last_success_epoch = CAST(strftime('%s', last_success_at) AS INTEGER) * 1000
WHERE last_success_epoch IS NULL AND last_success_at IS NOT NULL;
-- 已有账号在窗口内按 id 错开,避免迁移后同一 tick 集中开火。
-- 以迁移时刻为基准按 37 分钟步长铺开(12 个账号约 7.4 小时排完)。
UPDATE subscription_account
SET next_refresh_at = (CAST(strftime('%s', 'now') AS INTEGER) * 1000) + (id * 37 * 60 * 1000)
WHERE next_refresh_at IS NULL;
COMMIT;
@@ -2,29 +2,66 @@ package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Domain.*; import com.lion.lionwebsite.Domain.*;
import com.lion.lionwebsite.Message.*; import com.lion.lionwebsite.Message.*;
import com.lion.lionwebsite.Service.SubscriptionRefreshPlanner;
import com.lion.lionwebsite.Util.GalleryUtil;
import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.model.Chat; import com.pengrad.telegrambot.model.*;
import com.pengrad.telegrambot.model.Message; import com.pengrad.telegrambot.model.User;
import com.pengrad.telegrambot.model.MessageEntity;
import com.pengrad.telegrambot.response.SendResponse; import com.pengrad.telegrambot.response.SendResponse;
import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariConfig;
import jakarta.annotation.PostConstruct;
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import com.pengrad.telegrambot.model.User;
import java.time.Clock;
import java.time.Duration;
import java.util.Random;
@Configuration @Configuration
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class, @RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class, GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
PageNameCache.class, ShareFile.class, User.class, PageNameCache.class, User.class,
SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class, SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class,
Chat.class, MessageEntity.class, Chat.class, MessageEntity.class,
AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class, AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class,
IdentityMessage.class, MaintainMessage.class, ResponseMessage.class}) IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, AvailableCheckMessage.class,
SubscriptionSnapshotMessage.class, SubscriptionSnapshotPayload.class, SubscriptionAccountSnapshot.class,
SubscriptionBindingSnapshot.class, LinkPreviewOptions.class})
public class CustomBean { public class CustomBean {
@Value("${bot.token:5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA}")
private String botToken;
@Value("${gallery.cookie:ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1}")
private String ehentaiCookie;
@PostConstruct
void initGalleryCookie() {
GalleryUtil.setEhentaiCookie(ehentaiCookie);
}
@Bean @Bean
public TelegramBot getTelegramBot(){ public TelegramBot getTelegramBot(){
return new TelegramBot("5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"); return new TelegramBot(botToken);
}
/**
* 订阅分散刷新的排程器。
*
* <p>放在这里是为了让窗口、最小间隔、tick 周期与重试间隔集中由配置注入,
* 同时保持 {@link SubscriptionRefreshPlanner} 本身是可直接构造的纯对象
* (便于用固定时钟与固定随机种子做确定性单元测试)。
*/
@Bean
public SubscriptionRefreshPlanner subscriptionRefreshPlanner(
@Value("${subscription.refresh.window-hours:24}") long windowHours,
@Value("${subscription.refresh.min-gap-minutes:60}") long minGapMinutes,
@Value("${subscription.refresh.tick-interval-ms:300000}") long tickIntervalMs,
@Value("${subscription.refresh.retry-delay-minutes:60}") long retryDelayMinutes) {
return new SubscriptionRefreshPlanner(Clock.systemDefaultZone(), new Random(),
Duration.ofHours(windowHours), Duration.ofMinutes(minGapMinutes),
Duration.ofMillis(tickIntervalMs), Duration.ofMinutes(retryDelayMinutes));
} }
} }
@@ -3,6 +3,7 @@ package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Interceptor.HumanInterceptor; import com.lion.lionwebsite.Interceptor.HumanInterceptor;
import com.lion.lionwebsite.Interceptor.PersonalInterceptor; import com.lion.lionwebsite.Interceptor.PersonalInterceptor;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
@@ -10,17 +11,19 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
@RequiredArgsConstructor
public class InterceptorConfiguration implements WebMvcConfigurer { public class InterceptorConfiguration implements WebMvcConfigurer {
TaskHandlerInterceptor taskHandlerInterceptor; final TaskHandlerInterceptor taskHandlerInterceptor;
public InterceptorConfiguration(TaskHandlerInterceptor taskHandlerInterceptor) {
this.taskHandlerInterceptor = taskHandlerInterceptor;
}
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getPersonalInterceptor()).addPathPatterns("/personal/**", "/remote/**"); // 登录端点必须排除在闸门之外,否则校验票据的入口会被自己拦住。
registry.addInterceptor(taskHandlerInterceptor).addPathPatterns("/GalleryManage", "/validate"); // /personal/ 只是跳到静态入口 /index,也一并排除:未登录时让前端自己去提示,
// 否则用户直接访问根域名只会看到 401。
registry.addInterceptor(getPersonalInterceptor())
.addPathPatterns("/personal/**", "/remote/**")
.excludePathPatterns("/personal/", "/personal/login", "/personal/login/logout", "/personal/denied");
registry.addInterceptor(taskHandlerInterceptor).addPathPatterns("/GalleryManage", "/GalleryManage/**", "/validate");
registry.addInterceptor(getHumanInterceptor()).addPathPatterns("/", "/mobile"); registry.addInterceptor(getHumanInterceptor()).addPathPatterns("/", "/mobile");
} }
@@ -38,6 +38,7 @@ import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter;
import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; 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.MergedBeanDefinitionPostProcessor;
import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RegisteredBean;
import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.beans.factory.support.RootBeanDefinition;
@@ -201,8 +202,7 @@ public class MyBatisNativeConfiguration {
} else { } else {
result = (Class<?>) src; result = (Class<?>) src;
} }
} else if (src instanceof ParameterizedType) { } else if (src instanceof ParameterizedType parameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) src;
int index = (parameterizedType.getRawType() instanceof Class int index = (parameterizedType.getRawType() instanceof Class
&& Map.class.isAssignableFrom((Class<?>) parameterizedType.getRawType()) && Map.class.isAssignableFrom((Class<?>) parameterizedType.getRawType())
&& parameterizedType.getActualTypeArguments().length > 1) ? 1 : 0; && parameterizedType.getActualTypeArguments().length > 1) ? 1 : 0;
@@ -246,8 +246,10 @@ public class MyBatisNativeConfiguration {
Class<?> mapperInterface = getMapperInterface(beanDefinition); Class<?> mapperInterface = getMapperInterface(beanDefinition);
if (mapperInterface != null) { if (mapperInterface != null) {
// Exposes a generic type information to context for prevent early initializing // Exposes a generic type information to context for prevent early initializing
beanDefinition ConstructorArgumentValues constructorArgumentValues = new ConstructorArgumentValues();
.setTargetType(ResolvableType.forClassWithGenerics(beanDefinition.getBeanClass(), mapperInterface)); constructorArgumentValues.addGenericArgumentValue(mapperInterface);
beanDefinition.setConstructorArgumentValues(constructorArgumentValues);
beanDefinition.setTargetType(ResolvableType.forClassWithGenerics(beanDefinition.getBeanClass(), mapperInterface));
} }
} }
} }
@@ -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,28 +2,29 @@ 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.Service.UserService;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.concurrent.Callable;
@RestController @RestController
@RequestMapping("/GalleryManage") @RequestMapping("/GalleryManage")
@Slf4j @Slf4j
@RequiredArgsConstructor
public class GalleryManageController { public class GalleryManageController {
GalleryManageService galleryManageService; final GalleryManageService galleryManageService;
CollectService collectService; final CollectService collectService;
UserServiceImpl userService; final UserService userService;
public GalleryManageController(GalleryManageService galleryManageService, CollectService collectService, UserServiceImpl userService) { final RemoteService remoteService;
this.galleryManageService = galleryManageService;
this.collectService = collectService;
this.userService = userService;
}
@PostMapping("") @PostMapping("")
public String create_task(String link, String targetResolution, String AuthCode){ public String create_task(String link, String targetResolution, String AuthCode){
@@ -32,6 +33,17 @@ public class GalleryManageController {
return galleryManageService.createTask(link, targetResolution, AuthCode); return galleryManageService.createTask(link, targetResolution, AuthCode);
} }
@PostMapping("/reconnect")
public String reconnect(){
return galleryManageService.reconnect();
}
@PostMapping("/test")
public String test(){
remoteService.checkAvailability();
return "";
}
@GetMapping("") @GetMapping("")
public String selectGallery(String param, String type, String AuthCode) { public String selectGallery(String param, String type, String AuthCode) {
int userId = userService.getUserId(AuthCode); //能调到这里的授权码对应用户不可能为空 int userId = userService.getUserId(AuthCode); //能调到这里的授权码对应用户不可能为空
@@ -43,7 +55,6 @@ public class GalleryManageController {
case "gid" -> galleryManageService.selectTaskByGid(Integer.parseInt(param)); case "gid" -> galleryManageService.selectTaskByGid(Integer.parseInt(param));
case "all" -> galleryManageService.selectAllGallery(userId); case "all" -> galleryManageService.selectAllGallery(userId);
case "name" -> galleryManageService.selectGalleryByName(param); case "name" -> galleryManageService.selectGalleryByName(param);
case "undone" -> galleryManageService.selectUnDoneGallery();
case "downloader" -> galleryManageService.selectGalleryByDownloader(AuthCode); case "downloader" -> galleryManageService.selectGalleryByDownloader(AuthCode);
default -> Response._failure("参数错误"); default -> Response._failure("参数错误");
}; };
@@ -56,6 +67,14 @@ public class GalleryManageController {
return galleryManageService.deleteGalleryByGid(gid, AuthCode); return galleryManageService.deleteGalleryByGid(gid, AuthCode);
} }
/** 按 gid 查实际下载人昵称,仅管理员可用。 */
@GetMapping("/downloader")
public String selectDownloader(Integer gid, String AuthCode){
if(gid == null)
return Response._failure("参数不全");
return galleryManageService.selectDownloaderByGid(gid, AuthCode);
}
@PostMapping("/collect") @PostMapping("/collect")
public String collectGallery(Integer gid, String AuthCode){ public String collectGallery(Integer gid, String AuthCode){
@@ -78,12 +97,19 @@ public class GalleryManageController {
} }
@GetMapping("/onlineImage/{page}") @GetMapping("/onlineImage/{page}")
public void getCacheImage(String gid, @PathVariable("page") int page, HttpServletRequest request, HttpServletResponse response){ public Callable<?> getCacheImage(String gid, @PathVariable("page") int page, HttpServletRequest request, HttpServletResponse response){
galleryManageService.getCachedImage(gid, page, request, response); return galleryManageService.getCachedImage(gid, page, request, response);
} }
@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,86 +1,123 @@
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.PanelLoginTicket;
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 jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
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;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
@RestController @RestController
@Slf4j @Slf4j
@RequiredArgsConstructor
@RequestMapping("/personal") @RequestMapping("/personal")
public class PersonalController { public class PersonalController {
PersonalServiceImpl personalService; /** 会话有效期,与 PersonalHub 的面板会话保持一致:14 天滑动过期。 */
private static final int SESSION_MAX_INACTIVE_SECONDS = 60 * 60 * 24 * 14;
LocalServiceImpl localService; final PersonalService personalService;
public PersonalController(PersonalServiceImpl personalService, LocalServiceImpl localService) { final LocalService localService;
this.personalService = personalService;
this.localService = localService; final PanelLoginTicket panelLoginTicket;
@GetMapping("/")
public void index(HttpServletResponse resp) throws IOException {
resp.sendRedirect("/index");
} }
@GetMapping("/sub/self") /**
public void sub(HttpServletResponse response, HttpServletRequest request){ * 一键登录:校验机器人签发的 HMAC 票据,通过后建立会话并跳到个人面板。
FileDownload.export(request, response, "sub/sub.txt"); *
* <p>票据一次性之外的防护全靠有效期,因此失败的票据不重定向到登录页,
* 而是直接回到提示页,避免把参数回显到浏览器历史里。
*/
@GetMapping("/login")
public void login(@RequestParam(value = "t", required = false) String ticket,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
if (!panelLoginTicket.verify(ticket)) {
log.warn("一键登录票据无效或已过期,来自 {}", clientIp(request));
// 重定向目标用浏览器可见路径:nginx 的 location / 会把它改写成 /personal/…
response.sendRedirect("/denied");
return;
} }
@GetMapping("/files") // 先作废旧会话再建新的,避免会话固定攻击。
public String file(String path) throws IOException { HttpSession existing = request.getSession(false);
return personalService.getFiles(path); if (existing != null)
existing.invalidate();
HttpSession session = request.getSession(true);
session.setAttribute("personalAuthenticated", Boolean.TRUE);
session.setMaxInactiveInterval(SESSION_MAX_INACTIVE_SECONDS);
log.info("一键登录成功,来自 {}", clientIp(request));
response.sendRedirect("/index");
} }
@PostMapping("/uploadFile") @GetMapping("/login/logout")
public String uploadFile(String path, String fileName, MultipartFile file){ public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
return personalService.uploadFile(path, fileName, file); HttpSession session = request.getSession(false);
if (session != null)
session.invalidate();
response.sendRedirect("/denied");
} }
@GetMapping("/private/**") /** 未登录或票据失效时的提示页;纯静态文案,不含任何可推断的信息。 */
public void getFile(HttpServletRequest request, HttpServletResponse response, String path){ @GetMapping(value = "/denied", produces = MediaType.TEXT_HTML_VALUE)
personalService.download(request, response, path); public String denied() {
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>需要登录 · LionWebsite</title>
<style>
body { margin:0; display:flex; min-height:100vh; align-items:center; justify-content:center;
background:#f6f7f9; color:#1f2328;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; }
.card { max-width:26rem; padding:2rem; background:#fff; border:1px solid #e2e5e9;
border-radius:10px; box-shadow:0 1px 2px rgba(0,0,0,.04); }
h1 { margin:0 0 .75rem; font-size:1.15rem; }
p { margin:0; line-height:1.7; color:#4a5259; }
code { background:#f0f2f5; padding:.1rem .35rem; border-radius:4px; }
</style>
</head>
<body>
<div class="card">
<h1>需要登录</h1>
<p>请在机器人里发送 <code>/login</code>,用返回的一次性链接打开个人面板。</p>
</div>
</body>
</html>
""";
} }
@PostMapping("/share") private static String clientIp(HttpServletRequest request) {
public String shareFile(String path, Integer expireHour) { String forwarded = request.getHeader("X-Forwarded-For");
return personalService.shareFile(path, expireHour); if (forwarded != null && !forwarded.isBlank())
} return forwarded.split(",")[0].trim();
return request.getRemoteAddr();
@PostMapping("/compress")
public String compress(String path){
return personalService.compress(path);
}
@PostMapping("/delete")
public String deleteFile(String path){
return personalService.deleteFile(path);
}
@PostMapping("/extendShareTime")
public String extendShareTime(String path, Integer extendHour) {
return personalService.extendShareTime(path, extendHour);
}
@PostMapping("/cancelShare")
public String cancelShare(String path){
return personalService.cancelShare(path);
} }
@PostMapping("/updateSub") @PostMapping("/updateSub")
public String updateSub() throws IOException { public String updateSub() throws IOException {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
if(localService.updateSub(true)) if(localService.updateSub())
response.success(); response.success();
else else
response.failure(); response.failure();
@@ -93,7 +130,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,36 +2,39 @@ 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.Service.SubService; import com.lion.lionwebsite.Service.SubService;
import com.lion.lionwebsite.Service.UserService;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import tools.jackson.databind.node.ObjectNode;
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.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.LinkedList;
import java.util.List;
@RestController @RestController
@Slf4j @Slf4j
@RequiredArgsConstructor
public class PublicController { public class PublicController {
final List<String> black_share_codes = new LinkedList<>(); final PublicService publicService;
PublicServiceImpl publicService; final RemoteService remoteService;
RemoteService remoteService; final SubService subService;
SubService subService; final QueryService queryService;
public PublicController(PublicServiceImpl publicService, RemoteService remoteService, SubService subService) { @GetMapping("/")
this.publicService = publicService; public void index(HttpServletResponse resp) throws IOException {
this.remoteService = remoteService; resp.sendRedirect("/index");
this.subService = subService;
} }
@GetMapping("/ip") @GetMapping("/ip")
@@ -55,32 +58,20 @@ public class PublicController {
subService.updateSub(response, request, client, key); subService.updateSub(response, request, client, key);
} }
@GetMapping("/GetFile/{path}")
public void getFile(HttpServletRequest request, HttpServletResponse response, String ShareCode, @PathVariable("path") String path) throws IOException {
synchronized (black_share_codes) {
if (black_share_codes.contains(ShareCode))
return;
}
log.info("ShareCode:{}", ShareCode);
log.info("Path:{}", path);
boolean result = publicService.GetFile(request, response, ShareCode);
if(!result)
black_share_codes.add(ShareCode);
if(black_share_codes.size() > 100)
black_share_codes.remove(0);
}
@PostMapping("/validate") @PostMapping("/validate")
public String validate(String AuthCode){ public String validate(String AuthCode){
Response response = Response.generateResponse(); Response response = Response.generateResponse();
User user = publicService.getUserId(AuthCode); User user = publicService.getUserId(AuthCode);
String isAvailable = remoteService.isDead() ? "false": "true"; // 用 ObjectMapper 组装内层 JSON:手工 String.format 拼用户名时,
response.success(String.format("{\"userId\": %d, " + // 名字里带引号或反斜杠会直接产出非法 JSON,前端 JSON.parse 随即失败。
"\"username\": \"%s\", " + // 这里仍以「JSON 文本」形式放进 data(历史契约,前端按字符串再解析一次)。
"\"isAvailable\": %s}", user.getId(), user.getUsername(), isAvailable)); ObjectNode identity = CustomUtil.objectMapper.createObjectNode();
identity.put("userId", user.getId());
identity.put("username", user.getUsername());
identity.put("isAvailable", !remoteService.isDead());
// 管理员标记随登录一起下发,前端据此决定是否显示下载人信息与筛选。
identity.put("isAdmin", user.getId() == UserService.ADMIN_USER_ID);
response.success(identity.toString());
return response.toJSONString(); return response.toJSONString();
} }
@@ -90,8 +81,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,22 +9,13 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/query") @RequestMapping("/query")
@RequiredArgsConstructor
public class QueryController { public class QueryController {
QueryService queryService; final QueryService queryService;
public QueryController(QueryService queryService) {
this.queryService = queryService;
}
@GetMapping("") @GetMapping("")
public String query(String keyword, String prev, String next){ public String query(String keyword, String prev, String next){
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);
}
} }
@@ -1,20 +1,18 @@
package com.lion.lionwebsite.Controller; package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.SubService; import com.lion.lionwebsite.Service.SubService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/personal/subBind/") @RequestMapping("/personal/subBind/")
@RequiredArgsConstructor
public class SubController { public class SubController {
SubService subService; final SubService subService;
public SubController(SubService subService) {
this.subService = subService;
}
@PostMapping("") @PostMapping("")
public String addSubBind(String user){ public String addSubBind(String user, Integer accountId){
return subService.insertSubBind(user); return subService.insertSubBind(user, accountId);
} }
@PutMapping("") @PutMapping("")
@@ -36,4 +34,38 @@ public class SubController {
public String deleteSubBind(String user){ public String deleteSubBind(String user){
return subService.deleteSubBind(user); return subService.deleteSubBind(user);
} }
@GetMapping("accounts")
public String getAccounts(){
return subService.listSubscriptionAccounts();
}
@PostMapping("accounts")
public String addAccount(String name, String upstreamKey,
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
@RequestParam(defaultValue = "true") boolean enabled){
return subService.insertSubscriptionAccount(name, upstreamKey, filterHighMultiplier, enabled);
}
@PutMapping("accounts/{id}")
public String updateAccount(@PathVariable Integer id, String name, String upstreamKey,
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
@RequestParam(defaultValue = "true") boolean enabled){
return subService.updateSubscriptionAccount(id, name, upstreamKey, filterHighMultiplier, enabled);
}
@PostMapping("accounts/{id}/refresh")
public String refreshAccount(@PathVariable Integer id){
return subService.refreshSubscriptionAccount(id);
}
@DeleteMapping("accounts/{id}")
public String deleteAccount(@PathVariable Integer id){
return subService.deleteSubscriptionAccount(id);
}
@PutMapping("{user}/account")
public String rebind(@PathVariable String user, Integer accountId){
return subService.rebind(user, accountId);
}
} }
@@ -1,18 +1,15 @@
package com.lion.lionwebsite.Controller; package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.UserServiceImpl; import com.lion.lionwebsite.Service.UserService;
import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/personal/user") @RequestMapping("/personal/user")
@RequiredArgsConstructor
public class UserController { public class UserController {
UserServiceImpl userService; final UserService userService;
public UserController(UserServiceImpl userService) {
this.userService = userService;
}
@GetMapping("") @GetMapping("")
public String getAllUser(){ public String getAllUser(){
@@ -5,6 +5,7 @@ import com.lion.lionwebsite.Domain.ImageKeyCache;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
@@ -21,4 +22,13 @@ public interface ImageCacheMapper {
@Select("select * from gidToKey where gid=#{gid}") @Select("select * from gidToKey where gid=#{gid}")
GidToKey selectKeyByGid(String gid); GidToKey selectKeyByGid(String gid);
/** 已缓存的页 key 行数,用于判断索引是否完整(半截缓存需要重建)。 */
@Select("select count(*) from ImageKeyCache where gid=#{gid}")
int countImageKeyCacheByGid(String gid);
@Delete("delete from ImageKeyCache where gid=#{gid}")
void deleteImageKeyCacheByGid(String gid);
@Delete("delete from gidToKey where gid=#{gid}")
void deleteGidToKey(String gid);
} }
@@ -16,6 +16,9 @@ public interface CustomConfigurationMapper {
// @Delete("delete from customConfiguration where parameter=#{parameter}") // @Delete("delete from customConfiguration where parameter=#{parameter}")
// void deleteConfiguration(CustomConfiguration configuration); // void deleteConfiguration(CustomConfiguration configuration);
@Update("update customConfiguration set value=cast(value as integer)+#{amount} where parameter=#{parameter}")
void incrementConfiguration(@Param("parameter") String parameter, @Param("amount") long amount);
@Select("select * from customConfiguration where parameter=#{parameter}") @Select("select * from customConfiguration where parameter=#{parameter}")
CustomConfiguration selectConfiguration(String parameter); CustomConfiguration selectConfiguration(String parameter);
} }
@@ -25,7 +25,7 @@ public interface GalleryMapper {
@Select("select * from gallery where downloader=#{downloader}") @Select("select * from gallery where downloader=#{downloader}")
Gallery[] selectGalleryByDownloader(int downloader); Gallery[] selectGalleryByDownloader(int downloader);
@Select("select * from gallery where status in ('已提交', '下载中')") @Select("select * from gallery where status in ('已提交', '下载中', '等待压缩', '压缩中')")
Gallery[] selectUnDoneGalleries(); Gallery[] selectUnDoneGalleries();
@Select("select * from gallery order by createTime") @Select("select * from gallery order by createTime")
@@ -1,31 +0,0 @@
package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.ShareFile;
import org.apache.ibatis.annotations.*;
import java.util.ArrayList;
import java.util.Date;
@Mapper
public interface ShareFileMapper {
@Insert("insert into ShareFile (ShareCode, FilePath, ExpireTime) values (#{ShareCode}, #{FilePath}, #{ExpireTime})")
void insertShareFile(@Param("ShareCode")String ShareCode, @Param("FilePath")String FilePath, @Param("ExpireTime") Date ExpireTime);
@Select("select * from ShareFile where ShareCode=#{ShareCode}")
ShareFile selectShareFileByShareCode(String ShareCode);
@Select("select * from ShareFile where FilePath=#{FilePath}")
ShareFile selectShareFileByFilePath(String FilePath);
@Select("select * from ShareFile where FilePath like '%' || #{FilePath} || '%'")
ArrayList<ShareFile> selectShareFilesByFilePath(String FilePath);
@Delete("delete from ShareFile where ShareCode=#{ShareCode}")
void deleteShareFile(String ShareCode);
@Select("select * from ShareFile")
ShareFile[] selectAllShareFile();
@Update("update ShareFile set ExpireTime=#{ExpireTime}, ShareCode=#{ShareCode} where FilePath=#{FilePath}")
void updateShareFile(ShareFile ShareFile);
}
@@ -2,29 +2,67 @@ package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.SubBind; import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord; import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.ArrayList; import java.util.ArrayList;
@Mapper @Mapper
public interface SubMapper { public interface SubMapper {
@Insert("insert into sub_bind values (#{key}, #{user})") @Insert("insert into subscription_account (name, upstream_key, filter_high_multiplier, enabled, created_at, updated_at) values (#{name}, #{upstreamKey}, #{filterHighMultiplier}, #{enabled}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertSubscriptionAccount(SubscriptionAccount account);
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, next_refresh_at as nextRefreshAt, last_success_epoch as lastSuccessEpoch, (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, next_refresh_at as nextRefreshAt, last_success_epoch as lastSuccessEpoch, (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);
// last_success_at 仍写 SQLite 的 UTC 文本时间,仅供页面显示;
// last_success_epoch 是调度判断用的权威时刻,两者口径不同,不要互相推导。
@Update("update subscription_account set last_success_at=CURRENT_TIMESTAMP, last_success_epoch=#{epoch}, last_error=null, updated_at=CURRENT_TIMESTAMP where id=#{id}")
void markSubscriptionRefreshSuccess(@Param("id") Integer id, @Param("epoch") long epoch);
@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);
/** 排定下一次刷新时刻;失败重试与次日分槽都走这里。 */
@Update("update subscription_account set next_refresh_at=#{nextRefreshAt}, updated_at=CURRENT_TIMESTAMP where id=#{id}")
void updateNextRefreshAt(@Param("id") Integer id, @Param("nextRefreshAt") long nextRefreshAt);
@Delete("delete from subscription_account where id=#{id}")
void deleteSubscriptionAccount(Integer id);
@Insert("insert into sub_bind (key, user, subscription_account_id) values (#{key}, #{user}, #{subscriptionAccountId})")
void insertSubBind(SubBind subBind); void insertSubBind(SubBind subBind);
@Select("select * from sub_bind") @Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id order by sb.user")
ArrayList<SubBind> selectAllSubBind(); ArrayList<SubBind> selectAllSubBind();
@Select("select * from sub_bind where key=#{key}") @Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id where sb.key=#{key}")
SubBind selectSubBind(String key); SubBind selectSubBind(String key);
@Select("select count(key) from sub_bind where key=#{key}") @Select("select count(key) from sub_bind where key=#{key}")
boolean selectSubBindExist(String key); boolean selectSubBindExist(String key);
@Select("select count(user) from sub_update_record where user=#{user}") @Select("select count(*) from sub_bind where user=#{user}")
Integer selectUpdateRecordCount(String user); int countSubBindByUser(String user);
@Select("select min(id) from sub_update_record where user=#{user}") @Update("update sub_bind set subscription_account_id=#{accountId} where user=#{user}")
Integer selectMinUpdateRecordId(String user); int updateSubBindAccount(@Param("user") String user, @Param("accountId") Integer accountId);
@Update("update sub_bind set key=#{key} where user=#{user}")
int updateSubBindKey(@Param("user") String user, @Param("key") String key);
@Delete("delete from sub_bind where user=#{user}") @Delete("delete from sub_bind where user=#{user}")
void deleteSubBind(String user); void deleteSubBind(String user);
@@ -32,12 +70,22 @@ public interface SubMapper {
@Insert("insert into sub_update_record (user, ip, UA, time, location) values (#{user}, #{ip}, #{UA}, #{time}, #{location})") @Insert("insert into sub_update_record (user, ip, UA, time, location) values (#{user}, #{ip}, #{UA}, #{time}, #{location})")
void insertSubUpdateRecord(SubUpdateRecord subUpdateRecord); void insertSubUpdateRecord(SubUpdateRecord subUpdateRecord);
@Select("select * from sub_update_record") @Select("select * from sub_update_record order by time desc")
ArrayList<SubUpdateRecord> selectAllSubUpdateRecord(); ArrayList<SubUpdateRecord> selectAllSubUpdateRecord();
@Delete("delete from sub_update_record where user=#{user}") @Delete("delete from sub_update_record where user=#{user}")
void deleteSubUpdateRecord(String user); void deleteSubUpdateRecord(String user);
@Select("select count(user) from sub_update_record where user=#{user}")
Integer selectUpdateRecordCount(String user);
@Select("select min(id) from sub_update_record where user=#{user}")
Integer selectMinUpdateRecordId(String user);
/** 后台补齐归属地:只更新该用户最近一条记录,避免覆盖更早的历史记录。 */
@Update("update sub_update_record set location=#{location} where id=(select max(id) from sub_update_record where user=#{user})")
void updateLatestSubUpdateRecordLocation(@Param("user") String user, @Param("location") String location);
@Delete("delete from sub_update_record where id=#{id}") @Delete("delete from sub_update_record where id=#{id}")
void deleteSubUpdateRecordById(int id); void deleteSubUpdateRecordById(int id);
} }
@@ -11,10 +11,23 @@ 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 * from User where id=#{id}")
User selectUserById(int id);
@Select("select AuthCode from User") @Select("select AuthCode from User")
String[] selectAllAuthCode(); String[] selectAllAuthCode();
@Select("select AuthCode from User") /**
* 仅返回仍启用的授权码。
*
* <p>此前这条 SQL 与 {@link #selectAllAuthCode()} 完全相同(都没有 isEnable 条件),
* 于是「停用用户」后刷新授权码集合依旧把该码放行,isEnable 形同虚设。
* isEnable 为空按 DDL 默认值 true 处理,避免历史行被锁死。
*/
@Select("select AuthCode from User where isEnable is null or isEnable = 1")
String[] selectEnableAuthCode(); String[] selectEnableAuthCode();
@Select("select * from User") @Select("select * from User")
@@ -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; //文件大小
@@ -48,6 +48,11 @@ public class Gallery {
@JsonProperty("downloader") @JsonProperty("downloader")
private int downloader; //下载人 private int downloader; //下载人
/** 下载人昵称。仅管理员查询时填充,普通用户拿不到,避免暴露他人信息。 */
@JsonProperty("downloaderName")
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private String downloaderName;
@JsonProperty("collector") @JsonProperty("collector")
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
private String collector; //收藏人 private String collector; //收藏人
@@ -6,17 +6,11 @@ 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 COMPRESSING = 5;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
private String name; private String name;
@@ -1,12 +0,0 @@
package com.lion.lionwebsite.Domain;
import lombok.Data;
import java.util.Date;
@Data
public class ShareFile {
String ShareCode;
String FilePath;
Date ExpireTime;
}
@@ -10,4 +10,8 @@ import lombok.NoArgsConstructor;
public class SubBind { public class SubBind {
String key; String key;
String user; String user;
Integer subscriptionAccountId;
String subscriptionAccountName;
boolean subscriptionAccountEnabled;
boolean filterHighMultiplier;
} }
@@ -0,0 +1,31 @@
package com.lion.lionwebsite.Domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@NoArgsConstructor
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;
/**
* 下一次应刷新的时刻(Epoch 毫秒),由分散调度器排定。
* 刻意用整数而非 DATETIME:SQLite 文本时间戳会被 JDBC 按本地时区解释,
* 实测偏差 8 小时,不能用于时间比较。
*/
private Long nextRefreshAt;
/** 最近一次成功刷新的真实时刻(Epoch 毫秒),用于「24 小时内必刷一次」的判断与陈旧告警。 */
private Long lastSuccessEpoch;
}
@@ -4,17 +4,15 @@ import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.CustomUtil;
import jakarta.servlet.*; import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.annotation.WebFilter;
import lombok.RequiredArgsConstructor;
import java.io.IOException; import java.io.IOException;
@WebFilter(filterName = "AccessFilter", urlPatterns = {"/validate"}) @WebFilter(filterName = "AccessFilter", urlPatterns = {"/validate"})
@RequiredArgsConstructor
public class AccessFilter implements Filter { public class AccessFilter implements Filter {
UserMapper userMapper; final UserMapper userMapper;
public AccessFilter(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override @Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
@@ -4,41 +4,17 @@ 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.time.LocalTime;
import java.time.format.DateTimeFormatter;
@WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"}) @WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"})
@Slf4j
public class AdaptorFilter implements Filter { public class AdaptorFilter implements Filter {
FileWriter writer; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
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 {
@@ -50,17 +26,15 @@ public class AdaptorFilter implements Filter {
if(UserAgent == null) if(UserAgent == null)
return; return;
String AuthCode = request.getParameter("AuthCode") == null ? "null" : request.getParameter("AuthCode"); // 这里是全站请求日志,AuthCode 是真正的凭据;只记录是否携带,绝不落明文。
boolean hasAuthCode = request.getParameter("AuthCode") != null;
String ServletPath = request.getServletPath(); String ServletPath = request.getServletPath();
String ip = request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr(): request.getHeader("X-Forwarded-For"); String ip = request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr(): request.getHeader("X-Forwarded-For");
Calendar calendar = Calendar.getInstance(); String now = LocalTime.now().format(TIME_FORMATTER);
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,
writer.write(String.format("%s ip:%s \tpath:%s \tAuthCode:%s ua:%s\n", now, ip, ServletPath, AuthCode, UserAgent)); hasAuthCode ? "present" : "absent", UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent);
writer.flush();
//如果是验证,则直接跳转 //如果是验证,则直接跳转
if(ServletPath.equals("/validate")) if(ServletPath.equals("/validate"))
@@ -68,7 +42,7 @@ public class AdaptorFilter implements Filter {
//如果不是,则根据UA判断是否跳转 //如果不是,则根据UA判断是否跳转
else if ((UserAgent.contains("Android") || UserAgent.contains("iPhone"))) else if ((UserAgent.contains("Android") || UserAgent.contains("iPhone")))
if (ServletPath.equals("/personal/") && AuthCode.equals("alone")) if (ServletPath.equals("/personal/") && "alone".equals(request.getParameter("AuthCode")))
response.sendRedirect("/mobile?AuthCode=alone"); response.sendRedirect("/mobile?AuthCode=alone");
else else
response.sendRedirect("/mobile"); response.sendRedirect("/mobile");
@@ -2,13 +2,40 @@ package com.lion.lionwebsite.Interceptor;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
/**
* 个人管理区的访问闸门:有效会话或合法 AuthCode。
*
* <p>会话来自机器人签发的登录链接(见 {@code PanelLoginTicket} 与
* {@code /personal/login});固定的 {@code alone} 授权码保留给下载器与存储节点推送,
* 因此这里两条路径都放行,等那些调用方切换完成后再退役字面量。
*/
public class PersonalInterceptor implements HandlerInterceptor { public class PersonalInterceptor implements HandlerInterceptor {
/** 会话标记:{@code /personal/login} 校验票据后写入。 */
public static final String SESSION_ATTRIBUTE = "personalAuthenticated";
@Override @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
return request.getParameter("AuthCode") != null && request.getParameter("AuthCode").equals("alone"); if (authenticatedSession(request))
return true;
String authCode = request.getParameter("AuthCode");
if (authCode != null && authCode.equals("alone"))
return true;
// 会话缺失或过期时回 401,前端据此提示「去机器人发 /login」而不是静默失败。
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
/** 只认服务端写入的会话标记,不信任客户端可伪造的 Cookie 内容。 */
public static boolean authenticatedSession(HttpServletRequest request) {
// 已有会话才可能已登录;不调用 getSession(),以免为匿名访问创建空会话。
HttpSession session = request.getSession(false);
return session != null && Boolean.TRUE.equals(session.getAttribute(SESSION_ATTRIBUTE));
} }
} }
@@ -1,24 +1,27 @@
package com.lion.lionwebsite.Interceptor; package com.lion.lionwebsite.Interceptor;
import com.lion.lionwebsite.Dao.normal.UserMapper; import com.lion.lionwebsite.Dao.normal.UserMapper;
import jakarta.annotation.Resource; import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
@Component @Component
@RequiredArgsConstructor
public class TaskHandlerInterceptor implements HandlerInterceptor { public class TaskHandlerInterceptor implements HandlerInterceptor {
@Resource final UserMapper userMapper;
UserMapper userMapper;
String[] AuthCodes; // 由 PostConstruct 加载后仅被读(发布式更新)与被测试线程读取;
// volatile 保证 refresh 后其它线程立即看到新数组,避免停用的授权码短暂仍可用。
volatile String[] AuthCodes;
public TaskHandlerInterceptor(UserMapper userMapper) { @PostConstruct
this.userMapper = userMapper; void init() {
AuthCodes = userMapper.selectAllAuthCode(); AuthCodes = userMapper.selectAllAuthCode();
} }
@@ -5,7 +5,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.mybatis.spring.annotation.MapperScans; import org.mybatis.spring.annotation.MapperScans;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan; import org.springframework.boot.web.server.servlet.context.ServletComponentScan;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@@ -16,6 +16,11 @@ public class AbstractMessage {
public static final byte IDENTITY_MESSAGE = 6; public static final byte IDENTITY_MESSAGE = 6;
public static final byte MAINTAIN_MESSAGE = 7; 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;
}
}
@@ -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;
@@ -43,6 +43,8 @@ public class MessageCodec extends ByteToMessageCodec<AbstractMessage> {
case AbstractMessage.DELETE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, DeleteGalleryMessage.class); case AbstractMessage.DELETE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, DeleteGalleryMessage.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.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<>();
}
@@ -8,11 +8,7 @@ import org.springframework.stereotype.Service;
@Service @Service
@Data @Data
public class CollectService { public class CollectService {
CollectMapper collectMapper; final CollectMapper collectMapper;
public CollectService(CollectMapper collectMapper){
this.collectMapper = collectMapper;
}
public String collectGallery(int gid, int collector){ public String collectGallery(int gid, int collector){
Response response = Response.generateResponse(); Response response = Response.generateResponse();
@@ -28,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("取消收藏成功");
@@ -7,21 +7,21 @@ 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.node.ObjectNode;
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.im4java.core.ConvertCmd;
import org.im4java.core.IM4JavaException;
import org.im4java.core.IMOperation;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.*; import java.io.*;
import java.net.URI; import java.net.URI;
import java.util.*; import java.util.*;
import java.util.concurrent.Callable;
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper; import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
import static com.lion.lionwebsite.Util.GalleryUtil.*; import static com.lion.lionwebsite.Util.GalleryUtil.*;
@@ -30,36 +30,21 @@ import static com.lion.lionwebsite.Util.GalleryUtil.*;
@Data @Data
@Slf4j @Slf4j
public class GalleryManageService { public class GalleryManageService {
String TargetPath = "/root/gallery/"; String cachePath = "/storage/galleryCache/onlineImages/";
String cachePath = "/root/galleryCache/";
GalleryMapper galleryMapper; final GalleryMapper galleryMapper;
CollectMapper collectMapper; final CollectMapper collectMapper;
CustomConfigurationMapper configurationMapper; final CustomConfigurationMapper configurationMapper;
UserMapper userMapper; final UserMapper userMapper;
ShareFileMapper shareFileMapper; final ImageCacheMapper imageCacheMapper;
ImageCacheMapper imageCacheMapper; final RemoteService remoteService;
RemoteService remoteService; final PushService pushService;
PushService pushService;
public GalleryManageService(GalleryMapper galleryMapper, CollectMapper collectMapper, CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper,
RemoteService remoteService, PushService pushService, ImageCacheMapper imageCacheMapper) {
this.galleryMapper = galleryMapper;
this.collectMapper = collectMapper;
this.configurationMapper = configurationMapper;
this.userMapper = userMapper;
this.shareFileMapper = shareFileMapper;
this.remoteService = remoteService;
this.pushService = pushService;
this.imageCacheMapper = imageCacheMapper;
}
/** /**
* 创建任务 * 创建任务
@@ -73,17 +58,25 @@ public class GalleryManageService {
User user = userMapper.selectUserByAuthCode(AuthCode); User user = userMapper.selectUserByAuthCode(AuthCode);
// return Response._failure("调试中,请勿提交任务"); // return Response._failure("调试中,请勿提交任务");
if (remoteService.isDead()) { if (user == null) {
response.failure("节点挂了,找狮子处理"); response.failure("授权码无效");
pushService.taskCreateReport(user.getUsername(), link, response);
return response.toJSONString(); return response.toJSONString();
} }
int gid;
try { // 段数不足会先抛 ArrayIndexOutOfBoundsException,非数字段抛 NumberFormatException;
gid = Integer.parseInt(link.split("/")[4]); // 只捕后者会让畸形链接穿透为 500(本项目无 @ControllerAdvice)。
} catch (NumberFormatException e) { Integer parsedGid = parseGidFromLink(link);
if (parsedGid == null) {
response.failure("链接错误"); response.failure("链接错误");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), "未知任务", response);
return response.toJSONString();
}
int gid = parsedGid;
String taskName = "任务 [" + gid + "]";
if (remoteService.isDead()) {
response.failure("节点挂了,找狮子处理");
pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} }
Gallery gallery; Gallery gallery;
@@ -91,58 +84,86 @@ public class GalleryManageService {
//判断数据库中是否有这个任务,有则返回状态 //判断数据库中是否有这个任务,有则返回状态
if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) { if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) {
response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务"); response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), gallery.getName(), response);
return response.toJSONString(); return response.toJSONString();
} }
//尝试下载本子,返回结果 //尝试下载图片,返回结果
try { try {
gallery = GalleryUtil.parse(link, true, targetResolution); gallery = GalleryUtil.parse(link, true, targetResolution);
if (gallery == null || !gallery.getStatus().equals("已提交")) { if (gallery == null || !gallery.getStatus().equals("已提交")) {
log.error("创建任务失败: {},", link); log.error("创建任务失败: {},", link);
response.failure("提交任务失败,未知原因,请检查链接是否正常"); response.failure("提交任务失败,未知原因,请检查链接是否正常");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} else { } else {
taskName = gallery.getName();
log.info("创建任务: {} 目标分辨率:{}", link, targetResolution); log.info("创建任务: {} 目标分辨率:{}", link, targetResolution);
// Persist before dispatch: the node sends its current status before its ACK.
gallery.setDownloader(user.getId());
gallery.set_download(true);
galleryMapper.insertGallery(gallery);
configurationMapper.incrementConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, gallery.getFileSize());
if (remoteService.addGalleryToQueue(gallery) != 0) { if (remoteService.addGalleryToQueue(gallery) != 0) {
log.error("传送任务{}失败, 未知原因", gallery.getName()); log.error("传送任务{}失败, 未知原因", gallery.getName());
response.failure("任务传送失败,未知原因"); response.failure("任务已保存,但节点未确认接收;请刷新任务列表后重试");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} }
} }
} catch (ResolutionNotMatchException e) { } catch (ResolutionNotMatchException e) {
response.failure("提交失败,分辨率不存在"); response.failure("提交失败,分辨率不存在");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} catch (IOException e) { } catch (IOException e) {
log.error(e.getMessage()); log.error(e.getMessage());
response.failure("IO错误,可能是网络波动"); response.failure("IO错误,可能是网络波动");
pushService.taskCreateReport(user.getUsername(), link, response); pushService.taskCreateReport(user.getUsername(), taskName, response);
return response.toJSONString(); return response.toJSONString();
} }
//处理下载结果,将任务插入数据库并且更新每周用量 // Do not overwrite an immediate node status with the original submitted state.
if (gallery.getStatus().equals("已提交")) { Gallery current = galleryMapper.selectGalleryByGid(gallery.getGid());
response.success(gallery.toString()); response.success((current == null ? gallery : current).toString());
gallery.setDownloader(user.getId()); pushService.taskCreateReport(user.getUsername(), taskName, response);
galleryMapper.insertGallery(gallery);
long usedAmount = Long.parseLong(configurationMapper.selectConfiguration(CustomConfiguration.WEEK_USED_AMOUNT).getValue());
usedAmount += gallery.getFileSize();
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, String.valueOf(usedAmount));
} else {
response.failure("提交失败,未知原因");
galleryMapper.deleteGalleryByGid(gallery.getGid());
}
pushService.taskCreateReport(user.getUsername(), link, response);
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 根据链接查询本子 * 从任务链接中安全提取 gid。任何畸形输入(null、段数不足、非数字)都返回 null,
* 由调用方转成业务失败,避免异常穿透为 500。
*/
static Integer parseGidFromLink(String link) {
if (link == null)
return null;
String[] segments = link.split("/");
if (segments.length <= 4)
return null;
try {
return Integer.parseInt(segments[4]);
} catch (NumberFormatException e) {
return null;
}
}
/**
* 尝试重新连接
* @return 重连结果
*/
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 查询结果
*/ */
@@ -163,7 +184,7 @@ public class GalleryManageService {
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(objectMapper.valueToTree(gallery).toString());
else else
response.failure("查询失败"); response.failure("查询失败");
} catch (Exception e) { } catch (Exception e) {
@@ -172,13 +193,13 @@ public class GalleryManageService {
} }
else else
response.success(new ObjectMapper().valueToTree(gallery).toString()); response.success(objectMapper.valueToTree(gallery).toString());
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 通过gid查询本子 * 通过gid查询图片
* *
* @param gid gid * @param gid gid
* @return 查询结果 * @return 查询结果
@@ -188,7 +209,7 @@ public class GalleryManageService {
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());
@@ -197,7 +218,7 @@ public class GalleryManageService {
} }
/** /**
* 查询所有本子 * 查询所有图片
* *
* @return 查询结果 * @return 查询结果
*/ */
@@ -206,53 +227,72 @@ public class GalleryManageService {
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; // 原先对每个画廊线性扫描收藏列表(O(n*m))并就地删除迭代器元素;
// 改成集合查找后同样是「命中即标记」,复杂度降到 O(n+m)。
if (!galleryIds.isEmpty()) //如果该用户收藏了本子 if (!galleryIds.isEmpty()) {
galleryLoop:for (Gallery gallery : galleries) { //遍历本子 Set<Integer> collected = new HashSet<>(galleryIds);
idIterator = galleryIds.iterator(); for (Gallery gallery : galleries)
while (idIterator.hasNext()) { //遍历收藏的gid if (collected.contains(gallery.getGid()))
Integer id = idIterator.next();
if (id.equals(gallery.getGid())) { //如果找到对应的gid,修改对应本子的属性,删除当前gid,判断是否需要跳出或者结束循环
gallery.setCollect(true); gallery.setCollect(true);
idIterator.remove();
if (galleryIds.isEmpty())
break galleryLoop;
else
continue galleryLoop;
}
}
} }
response.success(new ObjectMapper().valueToTree(galleries).toString()); // 下载人昵称只对管理员下发;一次建表避免逐条查询。
if (userId == UserService.ADMIN_USER_ID)
fillDownloaderNames(galleries);
response.success(objectMapper.valueToTree(galleries).toString());
return response.toJSONString(); return response.toJSONString();
} }
/** 给任务列表补上下载人昵称,找不到用户时保留空值而不是报错。 */
private void fillDownloaderNames(Gallery[] galleries) {
User[] users = userMapper.selectAllUser();
Map<Integer, String> names = new HashMap<>();
if (users != null)
for (User user : users)
names.put(user.getId(), user.getUsername());
for (Gallery gallery : galleries)
gallery.setDownloaderName(names.get(gallery.getDownloader()));
}
/** /**
* 查询未完成的本子 * 查询某个任务的实际下载人昵称。
* 只有管理员可调用,普通用户一律拒绝,避免暴露他人身份。
* *
* @return 查询结果 * @param gid 任务 gid
* @param AuthCode 调用方授权码
*/ */
public String selectUnDoneGallery() { public String selectDownloaderByGid(int gid, String AuthCode) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
Gallery[] galleries = galleryMapper.selectUnDoneGalleries(); User requester = userMapper.selectUserByAuthCode(AuthCode);
if (requester == null || requester.getId() != UserService.ADMIN_USER_ID) {
response.failure("无权查看下载人");
return response.toJSONString();
}
if (galleries.length > 0) Gallery gallery = galleryMapper.selectGalleryByGid(gid);
response.success(new ObjectMapper().valueToTree(galleries).toString()); if (gallery == null) {
else response.failure("任务不存在");
response.failure(); return response.toJSONString();
}
User downloader = userMapper.selectUserById(gallery.getDownloader());
ObjectNode node = objectMapper.createObjectNode();
node.put("gid", gid);
node.put("downloader", gallery.getDownloader());
node.put("downloaderName", downloader == null ? "" : downloader.getUsername());
response.success(node);
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 通过本子名查询本子 * 通过图片名查询图片
* *
* @param name 名字 * @param name 名字
* @return 查询结果 * @return 查询结果
@@ -262,15 +302,15 @@ public class GalleryManageService {
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(objectMapper.valueToTree(gallery).toString());
else else
response.failure("没有找到该名字的本子"); response.failure("没有找到该名字的图片");
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 查询用户下载的本子 * 查询用户下载的图片
* *
* @param AuthCode 授权码(用于查询用户名) * @param AuthCode 授权码(用于查询用户名)
* @return 查询结果 * @return 查询结果
@@ -280,15 +320,15 @@ public class GalleryManageService {
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(objectMapper.valueToTree(galleries).toString());
else else
response.failure("您未下载本子"); response.failure("您未下载图片");
return response.toJSONString(); return response.toJSONString();
} }
/** /**
* 删除本子以及对应的文件(如果存在的话) * 删除图片以及对应的文件(如果存在的话)
* *
* @param gid gid * @param gid gid
* @return 删除结果 * @return 删除结果
@@ -298,25 +338,39 @@ public class GalleryManageService {
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.getFirst().equals(user.getId()) //判断收藏 // 拒绝条件与提示文案一致:被别人收藏,或者请求者不是下载人。
&& gallery.getDownloader() == user.getId())) //判断下载 // 注意不能写成 collector.isEmpty() || ...:那样在「无任何收藏」时会短路放行,
response.failure("删除失败,该本子已被别人收藏或你不是下载人"); // 从而完全跳过下载者校验,导致任何有效授权码都能删除他人任务。
else { boolean collectedByOthers = collector.stream().anyMatch(id -> id != user.getId());
log.info("删除本子{}", gallery.getName()); boolean isDownloader = gallery.getDownloader() == user.getId();
galleryMapper.deleteGalleryByGid(gallery.getGid()); //删除本子记录 if (collectedByOthers || !isDownloader) {
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
log.info("拒绝删除 gid={}:collectedByOthers={} isDownloader={}", gid, collectedByOthers, isDownloader);
return response.toJSONString();
} }
// 通过授权后才落库并通知节点,避免被拒请求仍删除节点文件。
log.info("删除图片{}", gallery.getName());
galleryMapper.deleteGalleryByGid(gallery.getGid());
switch (remoteService.deleteGallery(gallery)) { switch (remoteService.deleteGallery(gallery)) {
case ErrorCode.IO_ERROR -> response.failure("本子:" + gallery.getName() + "删除失败,IO错误"); case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
case ErrorCode.FILE_NOT_FOUND -> response.failure("本子:" + gallery.getName() + "删除失败,文件不存在"); case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
case 0 -> response.success(); case 0 -> response.success();
// 节点无响应/超时会返回 -1 等非枚举值;必须显式判失败,
// 否则 result 保持未设置,末尾的 response.get("result") 会抛 NPE。
default -> response.failure("图片:" + gallery.getName() + "删除失败,节点无响应");
} }
if (response.get("result").equals("failure")) if (!response.isSuccess())
log.info(response.getResult()); log.info(response.getData());
return response.toJSONString(); return response.toJSONString();
} }
@@ -330,120 +384,127 @@ 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(objectMapper.valueToTree(data).toString());
return response.toJSONString(); return response.toJSONString();
} }
public String cacheImagesKey(String url) { public String cacheImagesKey(String url) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
String gid = String.valueOf(GalleryUtil.parseGid(url));
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid); // 畸形链接在这里就拦掉:段数不足会让 url.split("/")[5] 抛 ArrayIndexOutOfBoundsException,
//已缓存过,直接返回 // 而 parseGid 也会对无 /g/ 的链接返回 null,两者都不该变成 500。
if(gidToKey != null) { String[] segments = url == null ? null : url.split("/");
return response.success(objectMapper.valueToTree(gidToKey)).toJSONString(); Integer parsedGid = parseGidFromLink(url);
if (parsedGid == null || segments == null || segments.length <= 5) {
response.failure("链接错误");
return response.toJSONString();
} }
String gid = String.valueOf(parsedGid);
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
//已缓存过且页 key 完整,直接返回
if(gidToKey != null && isIndexComplete(gidToKey)) {
return response.success(objectMapper.valueToTree(gidToKey)).toJSONString();
}
// 半截缓存(历史崩溃或旧版本写入中断)先清干净再整体重建,否则残留的页 key 会重复累积。
if (gidToKey != null) {
log.warn("图片索引缓存不完整,重建 gid={} 记录页数={}", gid, gidToKey.getPages());
rollbackImageIndex(gid);
}
boolean inserted = false;
try { try {
gidToKey = new GidToKey(); gidToKey = new GidToKey();
gidToKey.setGid(gid); gidToKey.setGid(gid);
gidToKey.setKey(url.split("/")[5].strip()); gidToKey.setKey(segments[5].strip());
ArrayList<ImageKeyCache> imageKeyCaches = GalleryUtil.parseImageKeys(url); ArrayList<ImageKeyCache> imageKeyCaches = GalleryUtil.parseImageKeys(url);
if(imageKeyCaches == null)
return response.failure("该图片已下架或已被删除").toJSONString();
gidToKey.setPages(imageKeyCaches.size()); gidToKey.setPages(imageKeyCaches.size());
imageCacheMapper.insertGidToKey(gidToKey); imageCacheMapper.insertGidToKey(gidToKey);
inserted = true;
for (ImageKeyCache imageKeyCache : imageKeyCaches) for (ImageKeyCache imageKeyCache : imageKeyCaches)
imageCacheMapper.insertImageKeyCache(imageKeyCache); imageCacheMapper.insertImageKeyCache(imageKeyCache);
response.success(objectMapper.valueToTree(gidToKey)); response.success(objectMapper.valueToTree(gidToKey));
}catch (IOException e){ }catch (Exception e){
log.error(e.getMessage()); // 索引必须整体生效:先写 gidToKey 再逐页写 key,中途失败会留下「gidToKey 命中、
// 页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。
// 因此任何异常都回滚已写入的部分再回业务失败。Jackson 3 的解析异常继承
// RuntimeException 而非 IOException,只用 IOException 会漏掉,这里统一兜住。
if (inserted)
rollbackImageIndex(gid);
log.warn("缓存图片索引失败 gid={} errorType={}", gid, e.getClass().getSimpleName());
response.failure("网络波动或其他异常"); response.failure("网络波动或其他异常");
} }
return response.toJSONString(); return response.toJSONString();
} }
String[] suffixes = {".webp", ".gif"}; /** 页 key 行数与 gidToKey 记录不一致时视为半截缓存,需要重建。 */
public void getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) { private boolean isIndexComplete(GidToKey gidToKey) {
//检查文件夹是否存在 Integer pages = gidToKey.getPages();
File folder = new File(cachePath + gid); if (pages == null || pages <= 0)
if(!folder.isDirectory()) return false;
folder.mkdirs(); return imageCacheMapper.countImageKeyCacheByGid(gidToKey.getGid()) == pages;
//检查对应图片是否存在,存在则直接返回
for (String suffix : suffixes) {
if(new File(cachePath + gid + "/" + page + suffix).exists()){
FileDownload.export(request, response, cachePath + gid + "/" + page + suffix);
return;
}
} }
//检查该本子缓存是否存在 /** 删除某个 gid 已写入的索引(页 key 与 gidToKey),用于回滚或重建。 */
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid); private void rollbackImageIndex(String gid) {
if(gidToKey == null)
try { try {
log.error("未缓存gid:" + gid); imageCacheMapper.deleteImageKeyCacheByGid(gid);
response.sendError(404); imageCacheMapper.deleteGidToKey(gid);
return; } catch (Exception e) {
}catch (IOException ignored){ log.warn("回滚图片索引缓存失败 gid={}", gid, e);
return; }
} }
synchronized (this){ public Callable<?> getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) {
if(response.isCommitted()) { Path directory = Path.of(cachePath, gid);
log.info("连接已关闭: gid=" + gid + " page=" + page); String name = String.valueOf(page);
return; Path cached = ImageFileCache.find(directory, name);
if (cached != null) {
FileDownload.export(request, response, cached.toString());
return null;
} }
return () -> {
String imageUrl = null; if (response.isCommitted()) return null;
//获取该图片key try {
ImageKeyCache imageKeyCache = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page); Path image = ImageFileCache.get(directory, name, () -> {
if (imageKeyCache == null) { GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
CustomUtil.fourZeroFour(response); ImageKeyCache imageKey = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page);
return; if (gidToKey == null || imageKey == null)
} throw new IOException("图片索引不存在");
for (int attempt = 0; attempt < 2; attempt++) {
//获取图片地址 String url = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKey);
for (int i = 0; i < 2; i++) { if (url != null) return url;
imageUrl = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKeyCache);
if (imageUrl != null)
break;
GalleryUtil.refreshMpvKey(gidToKey.toUrl()); GalleryUtil.refreshMpvKey(gidToKey.toUrl());
} }
throw new IOException("无法获取图片地址");
if (imageUrl == null) { });
CustomUtil.fourZeroFour(response); FileDownload.export(request, response, image.toString());
log.error("获取图片url失败:gid=" + gid + " page=" + page + " imageKey=" + imageKeyCache.getImgkey()); } catch (InterruptedException e) {
return; Thread.currentThread().interrupt();
} if (!response.isCommitted()) response.sendError(503);
} catch (Exception e) {
//下载图片,转格式并返回 log.warn("获取在线图片失败: gid={} page={} errorType={}", gid, page, e.getClass().getSimpleName());
String suffix = imageUrl.substring(imageUrl.lastIndexOf(".")); if (!response.isCommitted()) response.sendError(404);
String imagePath = cachePath + gid + "/" + page + suffix;
try {
new URI(imageUrl).toURL().openConnection().getInputStream().transferTo(new FileOutputStream(imagePath));
}catch (Exception e){
log.error("下载图片失败:url" + imageUrl);
e.printStackTrace();
CustomUtil.fourZeroFour(response);
return;
}
if (!suffix.equals(".gif")) {
ConvertCmd convertCmd = new ConvertCmd(true);
IMOperation operation = new IMOperation();
operation.addImage(imagePath);
operation.format("webp");
operation.addImage(imagePath.replace(suffix, ".webp"));
try {
convertCmd.run(operation);
new File(imagePath).delete();
imagePath = imagePath.replace(suffix, ".webp");
} catch (IOException | IM4JavaException | InterruptedException e) {
log.error("文件" + imagePath + "转换失败");
}
}
FileDownload.export(request, response, imagePath);
} }
return null;
};
} }
public String resetUndone(){ public String resetUndone(){
@@ -456,14 +517,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); 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 String retryGallery(int gid){
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
if(gallery == null)
return Response._failure("任务不存在");
if("下载完成".equals(gallery.getStatus()))
return Response._success("下载完成");
if(remoteService.isDead())
return Response._failure("节点不在线,无法重试");
RemoteService.RetryResult retryResult = remoteService.retryGallery(gallery);
if(retryResult.success())
return Response._success(retryResult.message());
return Response._failure(retryResult.message());
}
} }
@@ -0,0 +1,190 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.GalleryUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.HttpEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.nio.file.FileVisitResult.CONTINUE;
@Service
@Data
@Slf4j
public class LocalService{
@Value("${local.dou-nai-clash:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta}")
String DouNaiClash;
@Value("${local.dou-nai-v2ray:https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2}")
String DouNaiV2ray;
private static final CloseableHttpClient httpClient = HttpClients.createDefault();
final CustomConfigurationMapper configurationMapper;
final GalleryMapper galleryMapper;
final PushService pushService;
final RemoteService remoteService;
final SubscriptionRefreshScheduler subscriptionRefreshScheduler;
/**
* 检查连接是否有效,如果无效自动重连
*/
@Scheduled(cron = "0 0/30 * * * *")
public void CheckConnectionAvailability(){
if (remoteService.isDead()){
remoteService.initChannel();
pushService.sendToMe("主动检测连接已断开,自动进行重连");
log.warn("主动检测连接已断开,自动进行重连");
return;
}
// -1 为对方没有返回
if (remoteService.checkAvailability() == -1) {
String result = switch (remoteService.reconnect()){
case 0 -> "重连成功";
case -1 -> "重连失败";
case -2 -> "当前未连接,不进行重连";
default -> "未知错误";
};
pushService.sendToMe("主动检测连接无数据返回,自动进行重连:" + result);
log.warn("主动检测连接无数据返回,自动进行重连:{}", result);
}
}
/**
* 每周周一四点重置额度
*/
@Scheduled(cron = "0 0 4 * * MON")
public void reset() {
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, "0");
configurationMapper.updateConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME, CustomUtil.now());
}
/**
* 每天凌晨测试e-hentai cookie是否过期
*/
@Scheduled(cron = "0 0 0 * * *")
public void verifyCookie(){
try {
String content = GalleryUtil.requests("https://exhentai.org", "GET", null, null);
if(content.trim().isEmpty())
pushService.sendToMe("cookie过期");
} catch (IOException e) {
pushService.sendToMe("检测cookie异常:" + e.getMessage());
}
}
/**
* 手动「更新订阅」:立即刷新全部启用账号,供管理页按钮调用。
*
* <p>定时刷新已移交 {@link SubscriptionRefreshScheduler}。原先这里还有一个
* {@code @Scheduled(fixedRate = 86400000)} 的定时入口,它会一次性刷新全部账号
* (十几个账号在十几秒内打满),且因为没有 initialDelay,每次重启都会立刻重刷一遍,
* 使上游看到的请求密度取决于部署频率。该入口已移除。
*
* <p>不再保留 {@code isManual} 参数:定时路径已不存在,留着它只会让人以为
* 「传 false 就是不刷新」,而实际语义是「什么都不做却报成功」。
*/
public boolean updateSub() {
boolean success = subscriptionRefreshScheduler.refreshAllNow();
remoteService.requestSubscriptionSync();
if (success)
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME,
CustomUtil.dateTimeFormatter().format(LocalDateTime.now()));
return success;
}
/**
* 封装好的get方法
* @param url url
* @return 请求结果
* @throws IOException 网络异常
*/
public static ArrayList<String> Get(String url) throws IOException {
CloseableHttpResponse httpResponse;
HttpGet httpGet = new HttpGet(url);
httpResponse = httpClient.execute(httpGet);
HttpEntity responseEntity = httpResponse.getEntity();
int statusCode = httpResponse.getCode();
ArrayList<String> temp = new ArrayList<>();
if (statusCode == 200) {
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
String str;
while ((str = reader.readLine()) != null)
temp.add(str);
}
httpResponse.close();
return temp;
}
/**
* 每周,查看缩略图数量,按照访问时间排序,删除超出1w的部分
*/
@Scheduled(cron = "0 0 4 1 * *")
public void clearThumbnailCache(){
String cachePath = "/storage/hentaiCache/";
File directory = new File(cachePath);
// 排序需要文件的最后访问时间。原先在比较器里每次重新读属性(n log n 次系统调用),
// 且属性读取失败会让整个排序抛 RuntimeException;这里在遍历时一次性带出属性。
List<Map.Entry<Path, FileTime>> files = new ArrayList<>();
try {
Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isRegularFile()) {
files.add(Map.entry(file, attrs.lastAccessTime()));
}
return CONTINUE;
}
});
} catch (IOException e) {
throw new RuntimeException(e);
}
files.sort(Map.Entry.comparingByValue());
if (files.size() > 10000) {
List<Map.Entry<Path, FileTime>> toDelete = files.subList(0, files.size() - 10000);
for (Map.Entry<Path, FileTime> entry : toDelete) {
try {
Files.delete(entry.getKey());
}catch (IOException e){
log.warn("删除缩略图缓存文件失败: {}", entry.getKey(), e);
}
}
log.info("Deleted {} files", toDelete.size());
} else {
log.info("No files to delete");
}
}
}
@@ -1,243 +0,0 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Util.CustomUtil;
import lombok.Data;
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.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import static java.nio.file.FileVisitResult.CONTINUE;
@Service
@Data
public class LocalServiceImpl{
String DouNaiClash = "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta";
String DouNaiV2ray = "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2";
CustomConfigurationMapper configurationMapper;
ShareFileMapper shareFileMapper;
GalleryMapper galleryMapper;
public LocalServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, GalleryMapper galleryMapper){
this.shareFileMapper = shareFileMapper;
this.configurationMapper = configurationMapper;
this.galleryMapper = galleryMapper;
}
/**
* 每周周一四点重置额度
*/
@Scheduled(cron = "0 0 4 * * MON")
public void reset() {
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, "0");
configurationMapper.updateConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME, CustomUtil.now());
}
/**
* 定时更新订阅
* @throws IOException 下载以及保存异常
*/
@Scheduled(fixedRate = 86400000)
public void updateSubScheduler() throws IOException {
updateSub(false);
}
/**
* 更新订阅链接的实际方法
*/
public boolean updateSub(boolean isManual) throws IOException {
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
CustomConfiguration customConfiguration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
//如果不是手动,则判断更新间隔是否满足,不满足则取消更新
if (!isManual) {
LocalDateTime lastUpdate = LocalDateTime.parse(customConfiguration.getValue(), CustomUtil.dateTimeFormatter());
LocalDateTime now = LocalDateTime.now();
now = now.plusHours(-3);
if(now.isBefore(lastUpdate))
return false;
}
File DouNaiClashFile = new File("sub/DouNaiClash.txt");
File DouNaiV2rayFile = new File("sub/DouNaiV2ray.txt");
File directory = new File("sub");
if(!directory.isDirectory())
Files.createDirectory(Paths.get("sub"));
List<String> DouNaiClash_profile;
//下载豆奶v2ray订阅
try(FileWriter writer = new FileWriter(DouNaiV2rayFile)) {
String DouNaiV2rayRaw = Get(DouNaiV2ray).getFirst();
String[] v2rayPlain = new String(Base64.getDecoder().decode(DouNaiV2rayRaw)).split("\n");
StringBuilder stringBuilder = new StringBuilder();
//过滤高倍率节点
for(String node: v2rayPlain){
String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8);
if(name.startsWith("⛅") && name.contains("流量")){
float ratio = Float.parseFloat(name.substring(name.indexOf("(") + 1, name.indexOf(")")).replace("倍流量", ""));
if(ratio <= 1)
stringBuilder.append(node).append("\n");
}
else{
stringBuilder.append(node).append("\n");
}
}
writer.write(new String(Base64.getEncoder().encode(stringBuilder.toString().getBytes(StandardCharsets.UTF_8))));
System.out.println("load DouNai v2ray complete");
}catch (IOException e){
e.printStackTrace();
System.out.println("load DouNai v2ray failure");
}
//下载豆奶clash订阅
try(FileWriter writer = new FileWriter(DouNaiClashFile)) {
DouNaiClash_profile = Get(DouNaiClash);
//过滤高倍率节点
ArrayList<String> clashProcessed = new ArrayList<>();
boolean isProxies = false;
boolean skip = false;
for(String line: DouNaiClash_profile){
if(line.equals("proxies:"))
isProxies = true;
else if(line.equals("proxy-groups:") && isProxies)
isProxies = false;
if(isProxies) {
if (line.contains("name"))
skip = line.contains("流量");
if (!skip)
clashProcessed.add(line);
}
else
if (!line.contains("流量"))
clashProcessed.add(line);
}
for(String line: clashProcessed)
writer.write(line + "\n");
System.out.println("load DouNai clash complete");
}catch (IOException e){
e.printStackTrace();
System.out.println("load DouNai clash failure");
}
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME, dateTimeFormatter.format(LocalDateTime.now()));
return true;
}
/**
* 封装好的get方法
* @param url url
* @return 请求结果
* @throws IOException 网络异常
*/
public static ArrayList<String> Get(String url) throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse httpResponse;
HttpGet httpGet = new HttpGet(url);
httpResponse = httpClient.execute(httpGet);
HttpEntity responseEntity = httpResponse.getEntity();
int statusCode = httpResponse.getStatusLine().getStatusCode();
ArrayList<String> temp = new ArrayList<>();
if (statusCode == 200) {
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
String str;
while ((str = reader.readLine()) != null)
temp.add(str);
}
httpClient.close();
httpResponse.close();
return temp;
}
/**
* 每天四点,清理过期分享码
*/
@Scheduled(cron = "0 0 4 * * *")
public void checkShareCode(){
ShareFile[] shareFiles = shareFileMapper.selectAllShareFile();
Calendar now;
Calendar expireTime;
for(ShareFile shareFile: shareFiles){
now = Calendar.getInstance();
expireTime = Calendar.getInstance();
expireTime.setTime(shareFile.getExpireTime());
if(now.after(expireTime))
shareFileMapper.deleteShareFile(shareFile.getShareCode());
}
}
/**
* 每周,查看缩略图数量,按照访问时间排序,删除超出1w的部分
*/
@Scheduled(cron = "0 0 4 1 * *")
public void clearThumbnailCache(){
String cachePath = "/storage/hentaiCache/";
File directory = new File(cachePath);
List<Path> files = new ArrayList<>();
try {
Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isRegularFile()) {
files.add(file);
}
return CONTINUE;
}
});
} catch (IOException e) {
throw new RuntimeException(e);
}
files.sort((f1, f2) -> {
try {
return Files.readAttributes(f1, BasicFileAttributes.class).lastAccessTime()
.compareTo(Files.readAttributes(f2, BasicFileAttributes.class).lastAccessTime());
} catch (IOException e) {
throw new RuntimeException(e);
}
});
if (files.size() > 10000) {
List<Path> toDelete = files.subList(0, files.size() - 10000);
for (Path file : toDelete) {
try {
Files.delete(file);
}catch (IOException e){
e.printStackTrace();
}
}
System.out.println("Deleted " + toDelete.size() + " files");
} else {
System.out.println("No files to delete");
}
}
}
@@ -0,0 +1,59 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Util.Response;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
@Service
@Data
@Slf4j
public class PersonalService{
final CustomConfigurationMapper configurationMapper;
final PushService pushService;
/**
* 获取订阅文件上次更新时间
* @return 订阅文件上次更新时间
*/
public String lastUpdate() {
Response response = Response.generateResponse();
CustomConfiguration configuration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
response.success(configuration.getValue());
return response.toJSONString();
}
/**
* 获取家里的ip
* @return 家里的ip
*/
public String getIp(){
Response response = Response.generateResponse();
Map<String, String> jsonObject = new HashMap<>();
String ip = configurationMapper.selectConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS).getValue();
String updateTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME).getValue();
jsonObject.put("ip", ip);
jsonObject.put("lastUpdateTime", updateTime);
response.success(objectMapper.valueToTree(jsonObject).toString().replace("\"", " ").replace("\\", " "));
return response.toJSONString();
}
public String message2me(String message){
Response response = Response.generateResponse();
pushService.sendToMe(message);
return response.success().toJSONString();
}
}
@@ -1,367 +0,0 @@
package com.lion.lionwebsite.Service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.RandomUtil;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.Response;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Data;
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.TarArchiveOutputStream;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Service
@Data
@Slf4j
public class PersonalServiceImpl{
@Resource
CustomConfigurationMapper configurationMapper;
@Resource
UserMapper userMapper;
@Resource
ShareFileMapper shareFileMapper;
@Resource
TaskHandlerInterceptor taskHandlerInterceptor;
String StoragePath = "/storage/";
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
ExecutorService compressThreadPool;
public PersonalServiceImpl(CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper, TaskHandlerInterceptor taskHandlerInterceptor){
this.configurationMapper = configurationMapper;
this.userMapper = userMapper;
this.shareFileMapper = shareFileMapper;
this.taskHandlerInterceptor = taskHandlerInterceptor;
compressThreadPool = Executors.newFixedThreadPool(1);
}
/**
* 获取文件列表,同时带上分享码以及过期时间
* @param path 路径
* @return 文件列表
*/
public String getFiles(String path) {
Response response = Response.generateResponse();
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
File root_file = new File(StoragePath + path);
Calendar now = Calendar.getInstance();
Calendar expireTime = Calendar.getInstance();
//如果目标路径是文件夹
if(root_file.isDirectory()) {
ArrayList<Map<String, String>> fileMaps = new ArrayList<>();
File[] originalFiles = root_file.listFiles();
//如果文件夹里的文件不为空
if (originalFiles != null) {
originalFiles = Arrays.stream(originalFiles).sorted(Comparator.comparing(File::getName)).toArray(File[]::new);
ArrayList<ShareFile> shareFiles = shareFileMapper.selectShareFilesByFilePath(root_file.getAbsolutePath());
//遍历文件,放入文件信息以及查询对应的分享码
for (File file : originalFiles) {
Map<String, String> fileMap = new LinkedHashMap<>();
fileMap.put("name", file.getName());
fileMap.put("path", file.getAbsolutePath());
if (file.isDirectory()) {
fileMap.put("type", "FOLDER");
} else if (file.isFile()) {
fileMap.put("type", "FILE"); //处理文件大小单位
String fileSize = CustomUtil.fileSizeToString(file.length());
fileMap.put("size", fileSize);
Iterator<ShareFile> iterator = shareFiles.iterator();
while(iterator.hasNext()){
ShareFile shareFile = iterator.next();
if(shareFile.getFilePath().equals(file.getAbsolutePath())){
expireTime.setTime(shareFile.getExpireTime());
if(now.after(expireTime)){
shareFileMapper.deleteShareFile(shareFile.getShareCode());
}
else {
fileMap.put("shareCode", shareFile.getShareCode());
fileMap.put("expireTime", dateTimeFormatter.format(shareFile.getExpireTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()));
}
iterator.remove();
break;
}
}
}
fileMaps.add(fileMap);
}
response.success(new ObjectMapper().valueToTree(fileMaps).toString());
}
else
response.failure("文件夹为空");
}
return response.toJSONString();
}
/**
* 下载文件
* @param request 请求对象
* @param response 响应对象
* @param path 目标路径
*/
public void download(HttpServletRequest request, HttpServletResponse response, String path){
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
File file = new File(path);
if(file.exists())
FileDownload.export(request, response, path);
else
try{
response.getWriter().print("404 NOT FOUND");
}catch (IOException e){
e.printStackTrace();
}
}
/**
* 上传文件
* @param path 目标路径
* @param fileName 文件名称
* @param file 文件对象
* @return 上传结果
*/
public String uploadFile(String path, String fileName, MultipartFile file) {
Response response = Response.generateResponse();
log.info("上传文件:{}, 目标路径:{}", fileName, path);
if(path == null || fileName == null || file == null){
response.failure("参数不完整");
return response.toJSONString();
}
File directory = new File(StoragePath + path);
if(directory.isDirectory()){
File targetFile = new File(StoragePath + path, fileName);
if(targetFile.exists())
response.failure("目标文件已存在");
else
try {
file.transferTo(Path.of(StoragePath + path, fileName));
response.success("上传成功");
} catch (IOException e) {
response.failure("上传失败");
e.printStackTrace();
}
}
else
response.failure("该路径不存在或者不是文件夹");
return response.toJSONString();
}
/**
* 创建分享码
* @param path 目标路径
* @param expireHour 过期时间
* @return 如果成功则是分享码以及过期时间,失败则是失败原因
*/
public String shareFile(String path, Integer expireHour) {
Response response = Response.generateResponse();
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
Map<String, String> jsonObject = new HashMap<>();
File file = new File(path);
if(file.isFile()){ //如果是文件,则生成分享码,调用此接口时不需要考虑分享已分享文件以及分享已过期文件
String ShareCode;
ShareCode = RandomUtil.randomString(8);
Calendar expireTime = Calendar.getInstance();
expireTime.add(Calendar.HOUR, expireHour);
shareFileMapper.insertShareFile(ShareCode, path, expireTime.getTime());
jsonObject.put("shareCode", ShareCode);
jsonObject.put("expireTime", dateTimeFormatter.format(expireTime.getTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()));
response.success(new ObjectMapper().valueToTree(jsonObject).toString());
}
else
response.failure("此路径为文件夹或不存在");
return response.toJSONString();
}
/**
* 延长分享时间
* @param path 目标文件路径
* @param extendHour 延长小时数
* @return 延长结果
*/
public String extendShareTime(String path, Integer extendHour) {
Response response = Response.generateResponse();
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
Map<String, String> data = new LinkedHashMap<>();
ShareFile shareFile = shareFileMapper.selectShareFileByFilePath(path);
if(shareFile != null){
Calendar calendar = Calendar.getInstance();
calendar.setTime(shareFile.getExpireTime());
calendar.add(Calendar.HOUR, extendHour);
shareFile.setExpireTime(calendar.getTime());
shareFileMapper.updateShareFile(shareFile);
data.put("expireTime", dateTimeFormatter.format(shareFile.getExpireTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()));
data.put("path", shareFile.getFilePath());
response.success(new ObjectMapper().valueToTree(data).toString());
}
else{
response.failure("该文件未被分享");
}
return response.toJSONString();
}
/**
* 取消分享
* @param path 目标路径
* @return 取消结果
*/
public String cancelShare(String path) {
Response response = Response.generateResponse();
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
ShareFile shareFile = shareFileMapper.selectShareFileByFilePath(path);
if(shareFile != null){
shareFileMapper.deleteShareFile(shareFile.getShareCode());
response.success("取消分享成功");
}
else{
response.failure("该文件未被分享");
}
return response.toJSONString();
}
/**
* 获取订阅文件上次更新时间
* @return 订阅文件上次更新时间
*/
public String lastUpdate() {
Response response = Response.generateResponse();
CustomConfiguration configuration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
response.success(configuration.getValue());
return response.toJSONString();
}
/**
* 获取家里的ip
* @return 家里的ip
*/
public String getIp(){
Response response = Response.generateResponse();
Map<String, String> jsonObject = new HashMap<>();
String ip = configurationMapper.selectConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS).getValue();
String updateTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME).getValue();
jsonObject.put("ip", ip);
jsonObject.put("lastUpdateTime", updateTime);
response.success(new ObjectMapper().valueToTree(jsonObject).toString().replace("\"", " ").replace("\\", " "));
return response.toJSONString();
}
/**
* 打包文件
* @param path 目标路径
* @return 响应提交成功,因为该方法为异步执行,未完成时后辍为undone
*/
public String compress(String path) {
Response response = Response.generateResponse();
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
File file = new File(path);
String finalPath = path;
if(!file.isDirectory() || !file.exists()){
response.failure("选中的路径不是文件夹");
return response.toJSONString();
}
compressThreadPool.submit(() -> {
try(OutputStream bos = new BufferedOutputStream(Files.newOutputStream(Paths.get(finalPath + ".tar***undone")));
TarArchiveOutputStream aos = new TarArchiveOutputStream(bos)) {
aos.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU); //解除文件名长度限制
Path dirPath = Paths.get(finalPath);
Files.walkFileTree(dirPath, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
ArchiveEntry entry = new TarArchiveEntry(dir.toFile(), dirPath.relativize(dir).toString());
aos.putArchiveEntry(entry);
aos.closeArchiveEntry();
return super.preVisitDirectory(dir, attrs);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
ArchiveEntry entry = new TarArchiveEntry(file.toFile(), dirPath.relativize(file).toString());
aos.putArchiveEntry(entry);
IOUtils.copy(Files.newInputStream(file.toFile().toPath()), aos);
aos.closeArchiveEntry();
return super.visitFile(file, attrs);
}
});
File targetFile = new File(finalPath + ".tar***undone");
log.info("打包成功,重命名:" + targetFile.renameTo(new File(finalPath + ".tar")));
}catch (IOException e){
e.printStackTrace();
log.info("打包失败,删除文件结果:" + new File(finalPath + ".tar***undone").delete());
}
});
response.success("加入队列成功");
return response.toJSONString();
}
/**
* 删除文件
* @param path 目标路径
* @return 删除结果
*/
public String deleteFile(String path) {
Response response = Response.generateResponse();
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
File file = new File(path);
if(FileUtil.del(file))
response.success("删除成功");
else
response.failure("删除失败");
return response.toJSONString();
}
}
@@ -0,0 +1,54 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.User;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PublicService {
final CustomConfigurationMapper configurationMapper;
final UserMapper userMapper;
final TaskHandlerInterceptor taskHandlerInterceptor;
/**
* 记录家里ip地址
* @param ip ip地址
*/
public void logIpAddress(String ip) {
configurationMapper.updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, ip);
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, CustomUtil.now());
}
/**
* 修改授权码,如果能够执行此方法,则授权码一定存在
* @param AuthCode 原来的授权码
* @param newAuthCode 新的授权码
* @return 修改结果
*/
public String alterAuthCode(String AuthCode, String newAuthCode) {
Response response = Response.generateResponse();
userMapper.updateAuthCode(AuthCode, newAuthCode);
taskHandlerInterceptor.updateAuthCodes();
response.success("修改成功");
return response.toJSONString();
}
public User getUserId(String AuthCode){
return userMapper.selectUserByAuthCode(AuthCode);
}
}
@@ -1,165 +0,0 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Domain.User;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.Response;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
@Service
public class PublicServiceImpl {
@Resource
CustomConfigurationMapper configurationMapper;
@Resource
ShareFileMapper shareFileMapper;
@Resource
UserMapper userMapper;
@Resource
TaskHandlerInterceptor taskHandlerInterceptor;
public PublicServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, UserMapper userMapper, TaskHandlerInterceptor taskHandlerInterceptor) {
this.configurationMapper = configurationMapper;
this.shareFileMapper = shareFileMapper;
this.userMapper = userMapper;
this.taskHandlerInterceptor = taskHandlerInterceptor;
}
/**
* 记录家里ip地址
* @param ip ip地址
*/
public void logIpAddress(String ip) {
configurationMapper.updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, ip);
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, CustomUtil.now());
}
/**
* 通过分享码获取文件
* @param httpRequest 请求对象
* @param httpResponse 响应对象
* @param ShareCode 分享码
* @throws IOException 响应时的异常
*/
public boolean GetFile(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String ShareCode) throws IOException {
Response response = Response.generateResponse();
//参数为空的情况
if(ShareCode == null) {
response.failure("ShareCode invalid");
} else {
ShareFile shareFile = shareFileMapper.selectShareFileByShareCode(ShareCode);
Calendar ExpireTime = Calendar.getInstance();
Calendar now = Calendar.getInstance();
if (shareFile != null) {
ExpireTime.setTime(shareFile.getExpireTime());
if (ExpireTime.after(now) && new File(shareFile.getFilePath()).isFile()) {
FileDownload.export(httpRequest, httpResponse, shareFile.getFilePath());
return true;
} else {
shareFileMapper.deleteShareFile(shareFile.getShareCode());
response.failure("ShareCode is expired or File is not exist");
}
} else
response.failure("ShareCode is not exist or expired");
}
httpResponse.getOutputStream().write(response.toJSONString().getBytes(StandardCharsets.UTF_8));
return false;
}
/**
* 修改授权码,如果能够执行此方法,则授权码一定存在
* @param AuthCode 原来的授权码
* @param newAuthCode 新的授权码
* @return 修改结果
*/
public String alterAuthCode(String AuthCode, String newAuthCode) {
Response response = Response.generateResponse();
userMapper.updateAuthCode(AuthCode, newAuthCode);
taskHandlerInterceptor.updateAuthCodes();
response.success("修改成功");
return response.toJSONString();
}
public User getUserId(String 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 | URISyntaxException e){
e.printStackTrace();
}
}
public static byte[] getImageBytesFromUrl(String imageUrl) throws IOException, URISyntaxException {
URL url = new URI(imageUrl).toURL();
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();
}
}
@@ -3,36 +3,37 @@ package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Domain.Gallery; import com.lion.lionwebsite.Domain.Gallery;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import com.pengrad.telegrambot.TelegramBot; import com.pengrad.telegrambot.TelegramBot;
import com.pengrad.telegrambot.UpdatesListener;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.request.SendMessage; import com.pengrad.telegrambot.request.SendMessage;
import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Service @Service
@Slf4j
@RequiredArgsConstructor
public class PushService { public class PushService {
long self = 686839482; long self = 686839482;
@Resource private static final DateTimeFormatter COMPLETION_TIME_FORMATTER =
TelegramBot bot; DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
public PushService(TelegramBot bot) { final TelegramBot bot;
this.bot = bot;
}
public void taskCreateReport(String username, String link, Response response){ public void taskCreateReport(String username, String taskName, Response response){
if(response.isSuccess()) if(response.isSuccess())
sendToMe(String.format("用户%s提交下载%s", username, link)); sendToMe(String.format("用户%s提交下载任务:%s", username, taskName));
else else
sendToMe(String.format("用户%s提交下载%s , 下载失败:%s", username, link, response.get("data"))); sendToMe(String.format("用户%s提交下载任务:%s,下载失败:%s", username, taskName, response.get("data")));
} }
public void downloadComplete(Gallery gallery){ public void downloadComplete(Gallery gallery){
sendToMe(gallery.getLink() + " 下载完成"); String completionTime = COMPLETION_TIME_FORMATTER.format(Instant.now());
sendToMe(String.format("任务下载完成:%s\n完成时间:%s", gallery.getName(), completionTime));
} }
public void storageNodeOnline(){ public void storageNodeOnline(){
@@ -44,7 +45,7 @@ public class PushService {
} }
public void sendToMe(String text){ public void sendToMe(String text){
System.out.println(text); log.info(text);
SendMessage sendMessage = new SendMessage(self, text); SendMessage sendMessage = new SendMessage(self, text);
bot.execute(sendMessage); bot.execute(sendMessage);
} }
@@ -1,16 +1,15 @@
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.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 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;
@@ -18,33 +17,43 @@ 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.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
@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=" + URLEncoder.encode(keyword == null ? "" : keyword, StandardCharsets.UTF_8)
+ "&f_sft=on&f_sfu=on&f_sfl=on";
if(prev != null) if(prev != null)
param += "&prev=" + prev; param += "&prev=" + URLEncoder.encode(prev, StandardCharsets.UTF_8);
else if(next != null) else if(next != null)
param += "&next=" + next; param += "&next=" + URLEncoder.encode(next, StandardCharsets.UTF_8);
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<>();
@@ -73,8 +82,7 @@ public class QueryService {
galleries.add(gallery); galleries.add(gallery);
} }
response.success(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"))
response.set("next", nextLink.attr("href")); response.set("next", nextLink.attr("href"));
@@ -94,83 +102,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 {
if(!path.startsWith("/t")) Path image = ImageFileCache.get(Path.of(CachePath), fileName, () -> sourceUrl);
path = "/t" + path; FileDownload.export(request, response, image.toString());
requests("https://s.exhentai.org" + path, true, response, new FileOutputStream(image)); } catch (InterruptedException e) {
}catch (IOException e){ Thread.currentThread().interrupt();
e.printStackTrace(); 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();
}
}
} }
@@ -5,158 +5,424 @@ 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.*;
import com.lion.lionwebsite.Util.CustomUtil;
import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*; import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder; import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.logging.LoggingHandler; import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.logging.ByteBufFormat;
import io.netty.util.concurrent.DefaultPromise; import io.netty.util.concurrent.DefaultPromise;
import io.netty.util.concurrent.Promise; import io.netty.util.concurrent.Promise;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.net.*; import java.net.*;
import java.util.HashMap; import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicBoolean;
@Service @Service
@Data @Data
@Slf4j @Slf4j
public class RemoteService { public class RemoteService {
ChannelFuture channelFuture; volatile ChannelFuture channelFuture;
Channel channel; volatile Channel channel;
String ip = "5.255.110.45"; @Value("${remote.ip:5.255.110.45}")
String ip;
short port = 26321; short port = 26321;
GalleryMapper galleryMapper; final GalleryMapper galleryMapper;
PushService pushService; final PushService pushService;
HashMap<Integer, Promise<AbstractMessage>> promiseHashMap; ConcurrentHashMap<Integer, Promise<AbstractMessage>> promiseHashMap = new ConcurrentHashMap<>();
EventLoop eventLoopGroup; ConcurrentHashMap<Integer, CopyOnWriteArrayList<CompletableFuture<String>>> retryStatusWaiters =
new ConcurrentHashMap<>();
ExecutorService downloadThread; final EventLoop eventLoopGroup = new DefaultEventLoop();
final EventLoopGroup networkGroup = new NioEventLoopGroup(2);
final AtomicBoolean connecting = new AtomicBoolean();
final AtomicBoolean monitoring = new AtomicBoolean();
volatile boolean stopping;
volatile ServerSocket monitorSocket;
Thread monitor; Thread monitor;
AtomicInteger atomicInteger; AtomicInteger atomicInteger = new AtomicInteger(0);
public RemoteService(GalleryMapper galleryMapper, PushService pushService){ final WebSocketService webSocketService;
this.galleryMapper = galleryMapper;
this.pushService = pushService;
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;
});
/**
* 处理节点上报的任务状态。原先这些 JDBC 读写直接跑在 Netty 的 IO 线程上,
* N 条上报会阻塞该 IO 线程,连带拖慢心跳与请求响应;改为单线程顺序执行,
* 既不打乱「按上报顺序覆盖状态」的语义,也避免并发写库。
*/
ExecutorService statusApplyExecutor = newStatusApplyExecutor();
private static ExecutorService newStatusApplyExecutor() {
return Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r, "gallery-status-apply");
thread.setDaemon(true);
return thread;
});
} }
/**
* 测试钩子:等待已提交的状态处理任务全部执行完毕。
*
* <p>单线程执行器天然有序,投递一个空任务并等它跑完,即说明此前提交的
* 任务都已处理完,从而让「上报后落库」的断言保持确定性。
*/
void awaitStatusApplied() {
try {
statusApplyExecutor.submit(() -> { }).get(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.debug("等待状态处理完成失败", e);
}
}
final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean();
final AtomicBoolean subscriptionSyncRunning = new AtomicBoolean();
@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(100000000, 1, 4)); channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
channel.pipeline().addLast(new MessageCodec()); channel.pipeline().addLast(new MessageCodec());
channel.pipeline().addLast(new LoggingHandler()); // 只记录事件和字节数,避免把订阅正文、签名等消息内容写入日志。
channel.pipeline().addLast(new LoggingHandler(io.netty.handler.logging.LogLevel.DEBUG, ByteBufFormat.SIMPLE));
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter()); channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
} }
}).connect(new InetSocketAddress(ip, port)).sync(); }).connect(new InetSocketAddress(ip, port + i)).sync();
break;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} catch (Exception e) {
log.error("连接storageNode失败,端口偏移量(重试次数):{}", i);
}
if (stopping)
return false;
}
//超过二十次连不上,主动抛出错误,由下方catch
if(i==20) {
throw new Exception();
}
if (stopping) {
channelFuture.channel().close();
return false;
}
log.info("connect success"); log.info("connect success");
if(pushService != null) if(pushService != null)
pushService.storageNodeOnline(); pushService.storageNodeOnline();
channel = channelFuture.channel(); channel = channelFuture.channel();
closeMonitorSocket();
channel.writeAndFlush(new IdentityMessage("lionwebsite")); channel.writeAndFlush(new IdentityMessage("lionwebsite"));
//子节点上线时,发送未完成的任务
resetUndone();
// 节点刚上线时可能没有任何快照(例如刚重启),必须推一次;
// 这同时会刷新节点「最近收到快照」的时刻,避免内容未变时被判过期。
requestSubscriptionSync();
return true; return true;
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("connect node failed, wait for node back online", e);
log.info("connect node failed, wait for node back online");
return false; return false;
} finally {
connecting.set(false);
if (isDead())
startMonitor();
} }
} }
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;
}
}
// 这里刻意没有「定期重发整份快照」的定时任务:
// 快照只在订阅内容变化时推送(各处 requestSubscriptionSync),节点上线时补推一次。
// 备机的过期语义是「主站失联」,由节点按「收到主站的任何消息」判定新鲜度——
// 主站每 30 分钟的可用性检查即可持续刷新它,因此不需要为续期而周期性传输快照。
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();
statusApplyExecutor.shutdownNow();
subscriptionSyncExecutor.shutdownNow();
networkGroup.shutdownGracefully();
eventLoopGroup.shutdownGracefully();
}
private void failPendingRequests() {
promiseHashMap.forEach((id, promise) -> promise.tryFailure(new IOException("节点连接已关闭")));
promiseHashMap.clear();
retryStatusWaiters.forEach((gid, waiters) ->
waiters.forEach(waiter -> waiter.completeExceptionally(new IOException("节点连接已关闭"))));
retryStatusWaiters.clear();
}
public boolean isDead(){ public boolean isDead(){
return channelFuture.channel() == null || !channelFuture.channel().isActive(); return channelFuture == null || channelFuture.channel() == null || !channelFuture.channel().isActive();
}
public void resetUndone(){
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){ public byte addGalleryToQueue(Gallery gallery){
GalleryTask galleryTask = new GalleryTask(); GalleryTask galleryTask = new GalleryTask();
galleryTask.setGid(gallery.getGid()); galleryTask.setGid(gallery.getGid());
galleryTask.setName(gallery.getName());
DownloadPostMessage message = new DownloadPostMessage();
message.setGalleryTask(galleryTask);
return sendRequest(message, 10, TimeUnit.SECONDS);
}
DownloadPostMessage dpm = new DownloadPostMessage(); public RetryResult retryGallery(Gallery gallery){
dpm.messageId = atomicInteger.getAndIncrement(); CompletableFuture<String> statusFuture = new CompletableFuture<>();
dpm.setGalleryTask(galleryTask); retryStatusWaiters.computeIfAbsent(gallery.getGid(), ignored -> new CopyOnWriteArrayList<>())
channel.writeAndFlush(dpm); .add(statusFuture);
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
promiseHashMap.put(dpm.messageId, promise);
try { try {
boolean result = promise.await(10, TimeUnit.SECONDS); byte submitResult = addGalleryToQueue(gallery);
if(result){ if(submitResult != 0 && !statusFuture.isDone())
ResponseMessage rsm = (ResponseMessage)promise.getNow(); return new RetryResult(false, "节点未接受重试请求");
return rsm.getResult(); return new RetryResult(true, statusFuture.get(10, TimeUnit.SECONDS));
} }catch (TimeoutException e){
else return -1; return new RetryResult(false, "节点已收到重试请求,但未及时返回任务状态");
}catch (ExecutionException e){
log.warn("等待重试状态失败, gid={}", gallery.getGid(), e);
return new RetryResult(false, "获取任务状态失败");
}catch (InterruptedException e){ }catch (InterruptedException e){
e.printStackTrace(); log.warn("等待重试状态被中断, gid={}", gallery.getGid(), e);
return -1; Thread.currentThread().interrupt();
return new RetryResult(false, "获取任务状态被中断");
}finally {
retryStatusWaiters.computeIfPresent(gallery.getGid(), (gid, waiters) -> {
waiters.remove(statusFuture);
return waiters.isEmpty() ? null : waiters;
});
} }
} }
private void completeRetryStatusWaiters(int gid, String status){
CopyOnWriteArrayList<CompletableFuture<String>> waiters = retryStatusWaiters.remove(gid);
if(waiters != null)
waiters.forEach(waiter -> waiter.complete(status));
}
public record RetryResult(boolean success, String message) {}
public byte deleteGallery(Gallery gallery){ public byte deleteGallery(Gallery gallery){
DeleteGalleryMessage dgm = new DeleteGalleryMessage(); DeleteGalleryMessage message = new DeleteGalleryMessage();
dgm.setGalleryName(gallery.getName()); message.setGalleryName(gallery.getName());
dgm.messageId = atomicInteger.getAndIncrement(); return sendRequest(message, 10, TimeUnit.SECONDS);
}
channel.writeAndFlush(dgm); private void startMonitor() {
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup); if (stopping || !monitoring.compareAndSet(false, true))
promiseHashMap.put(dgm.messageId, promise); return;
try{ monitor = new Thread(this::monitorFunc, "storage-node-monitor");
boolean result = promise.await(10, TimeUnit.SECONDS); monitor.setDaemon(true);
if(result){ monitor.start();
ResponseMessage rsm = (ResponseMessage) promise.getNow(); }
return rsm.getResult();
}else return -1; private void closeMonitorSocket() {
}catch (InterruptedException e){ ServerSocket socket = monitorSocket;
return -1; if (socket != null) {
try { socket.close(); }
catch (IOException e) { log.debug("关闭节点监听失败", e); }
} }
} }
public void monitorFunc(){ public void monitorFunc(){
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);
}
}
} }
} }
@@ -166,44 +432,72 @@ public class RemoteService {
//如果不是响应信息或者响应信息为失败,则打印 //如果不是响应信息或者响应信息为失败,则打印
if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0) if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0)
if(! (msg instanceof MaintainMessage)) if(! (msg instanceof MaintainMessage))
System.out.println(msg); log.debug("{}", msg);
//下载状态 //下载状态
if(msg instanceof DownloadStatusMessage dsm){ if(msg instanceof DownloadStatusMessage dsm){
GalleryTask[] galleryTasks = dsm.getGalleryTasks(); GalleryTask[] galleryTasks = dsm.getGalleryTasks();
// 落库与通知可能较慢(含 Telegram 推送),交给单线程顺序执行,
// 不再占用 Netty IO 线程;异常在此兜住,避免打进 IO 线程。
statusApplyExecutor.execute(() -> {
try {
applyReportedStatus(galleryTasks);
} catch (Exception e) {
log.warn("处理节点上报的任务状态失败", e);
}
});
}
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);
}
}
/** 把节点上报的任务状态写回库里并推送给前端,按上报顺序逐个处理。 */
private void applyReportedStatus(GalleryTask[] galleryTasks) {
for (GalleryTask galleryTask : galleryTasks) { for (GalleryTask galleryTask : galleryTasks) {
Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid()); Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid());
if (gallery == null) {
log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}",
galleryTask.getGid(), galleryTask.getName());
continue;
}
gallery.setProceeding(galleryTask.getProceeding()); gallery.setProceeding(galleryTask.getProceeding());
if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null) if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
gallery.setName(galleryTask.getName()); gallery.setName(galleryTask.getName());
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) { if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
boolean justCompleted = !"下载完成".equals(gallery.getStatus());
gallery.setStatus("下载完成"); gallery.setStatus("下载完成");
if (justCompleted)
pushService.downloadComplete(gallery); pushService.downloadComplete(gallery);
} }
else if(galleryTask.getStatus() == GalleryTask.COMPRESSING) else if(galleryTask.getStatus() == GalleryTask.COMPRESSING)
gallery.setStatus("压缩中"); gallery.setStatus("压缩中");
else if(galleryTask.getProceeding() != 0) else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE)
gallery.setStatus("等待压缩");
else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING)
gallery.setStatus("下载中"); gallery.setStatus("下载中");
log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages()); log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages());
galleryMapper.updateGallery(gallery); galleryMapper.updateGallery(gallery);
completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus());
} }
} webSocketService.updateTaskProcessing(galleryTasks);
else if(msg instanceof ResponseMessage rsm)
promiseHashMap.get(rsm.messageId).setSuccess(rsm);
} }
@Override @Override
public void channelUnregistered(ChannelHandlerContext ctx) { public void channelUnregistered(ChannelHandlerContext ctx) {
System.out.println(ctx.channel()); if (ctx.channel() == channel) {
System.out.println(channel); failPendingRequests();
if(ctx.channel() != null && ctx.channel().remoteAddress().toString().equals(channel.remoteAddress().toString())){ if (!stopping) {
System.out.println("activate monitor thread, waiting for node back online");
pushService.storageNodeOffline(); pushService.storageNodeOffline();
monitor = new Thread(RemoteService.this::monitorFunc); startMonitor();
monitor.start(); }
} }
} }
} }
@@ -2,131 +2,333 @@ package com.lion.lionwebsite.Service;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.lion.lionwebsite.Dao.normal.SubMapper; import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.SubBind; import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord; import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Util.CustomUtil; import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload; import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.GalleryUtil;
import com.lion.lionwebsite.Util.Response; import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.lionsoul.ip2region.xdb.Searcher; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.function.Supplier;
@Service @Service
public class SubService { @Slf4j
SubMapper subMapper; @RequiredArgsConstructor
public class SubService {
final SubMapper subMapper;
final UserMapper userMapper;
final SubscriptionRefreshService refreshService;
final RemoteService remoteService;
final SubscriptionStateCoordinator stateCoordinator;
byte[] vIndex; /**
* 归属地查询是同步外呼(最长十几秒),且失败只影响记录里的一列展示。
* 若放在订阅分发路径上,用户取一次订阅就得先等它完成,因此改为后台补齐。
*
* <p>非 final:测试可替换为受控执行器,避免在单测里触发真实外呼。
*/
Executor locationExecutor = newFixedLocationExecutor();
String dbPath = "ip2region.xdb"; private static ExecutorService newFixedLocationExecutor() {
public SubService(SubMapper subMapper) { return Executors.newFixedThreadPool(2, r -> {
this.subMapper = subMapper; Thread thread = new Thread(r, "sub-location-resolver");
try { thread.setDaemon(true);
vIndex = Searcher.loadVectorIndexFromFile(dbPath); return thread;
}catch (Exception e){ });
System.out.printf("failed to load vector index from `%s`: %s\n", dbPath, e);
}
} }
public String insertSubBind(String user){ public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
SubscriptionAccount account;
Lock lock = stateCoordinator.writeLock();
lock.lock();
try {
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();
account.setName(name.trim());
account.setUpstreamKey(upstreamKey.trim());
account.setFilterHighMultiplier(filterHighMultiplier);
account.setEnabled(enabled);
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); String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key)) while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8); key = RandomUtil.randomString(8);
subMapper.insertSubBind(new SubBind(key, user, account.getId(), account.getName(), account.isEnabled(), account.isFilterHighMultiplier()));
SubBind subBind = new SubBind(key, user); remoteService.requestSubscriptionSync();
subMapper.insertSubBind(subBind);
return response.success("添加成功").toJSONString(); return response.success("添加成功").toJSONString();
} }
public String resetKey(String user){ public String resetKey(String user) {
Response response = Response.generateResponse(); return withWriteLock(() -> resetKeyUnlocked(user));
}
subMapper.deleteSubBind(user); private String resetKeyUnlocked(String user) {
subMapper.deleteSubUpdateRecord(user); Response response = Response.generateResponse();
if (subMapper.countSubBindByUser(user) == 0)
return response.failure("绑定不存在").toJSONString();
String key = RandomUtil.randomString(8); String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key)) while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8); key = RandomUtil.randomString(8);
SubBind subBind = new SubBind(key, user); subMapper.updateSubBindKey(user, key);
subMapper.insertSubBind(subBind); subMapper.deleteSubUpdateRecord(user);
remoteService.requestSubscriptionSync();
return response.success().toJSONString(); return response.success().toJSONString();
} }
public String selectAllSubBind(){ public String rebind(String user, Integer accountId) {
Response response = Response.generateResponse(); return withWriteLock(() -> rebindUnlocked(user, accountId));
ArrayList<SubBind> subBinds = subMapper.selectAllSubBind();
return response.success(CustomUtil.objectMapper.valueToTree(subBinds)).toJSONString();
} }
public String SelectAllSubUpdateRecord(){ private String rebindUnlocked(String user, Integer accountId) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
ArrayList<SubUpdateRecord> subUpdateRecords = subMapper.selectAllSubUpdateRecord(); SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
return response.success(CustomUtil.objectMapper.valueToTree(subUpdateRecords)).toJSONString(); if (account == null || !account.isEnabled())
return response.failure("子账号不存在或已停用").toJSONString();
if (!refreshService.hasCompleteCache(account.getId()))
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
if (subMapper.updateSubBindAccount(user, accountId) == 0)
return response.failure("绑定不存在").toJSONString();
remoteService.requestSubscriptionSync();
return response.success("改绑成功").toJSONString();
} }
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key){ public String selectAllSubBind() {
if(key == null || client == null) 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; return;
SubBind subBind = subMapper.selectSubBind(key); SubBind subBind = subMapper.selectSubBind(key);
if(subBind == null) if (subBind == null || subBind.getSubscriptionAccountId() == null || !subBind.isSubscriptionAccountEnabled()) {
sendStatus(response, HttpServletResponse.SC_NOT_FOUND, "subscription not found");
return; return;
}
String ip; String ip = resolveClientIp(request);
if(request.getRemoteAddr().equals("127.0.0.1")){ String ua = request.getHeader("User-Agent");
ip = request.getHeader("X-Forwarded-For"); if (ua == null) return;
if(ip.contains(",")) recordUpdate(subBind.getUser(), ip, ua);
ip = ip.split(",")[0].trim(); if (!"v2".equals(client) && !"cat".equals(client)) {
if(ip.contains(":")) sendStatus(response, HttpServletResponse.SC_BAD_REQUEST, "client error");
ip = ip.split(":")[0].trim();
} else
ip = request.getRemoteAddr();
String UA = request.getHeader("User-Agent");
if(UA == null)
return; return;
}
java.nio.file.Path path = refreshService.cachedPath(subBind.getSubscriptionAccountId(), client);
if (!Files.isRegularFile(path)) {
sendStatus(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "subscription unavailable");
return;
}
FileDownload.export(request, response, path.toString());
}
StringBuilder location = new StringBuilder(); private static String resolveClientIp(HttpServletRequest request) {
Searcher searcher; 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) {
// 先落一条 unknown 记录并立即返回,归属地随后在后台补齐;
// 这样取订阅的延迟不再取决于第三方定位站点的响应时间。
subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), "unknown"));
if (subMapper.selectUpdateRecordCount(user) > 10)
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user));
locationExecutor.execute(() -> enrichLocation(user, ip));
}
/** 后台补齐最近一条记录的归属地;任何失败都只记日志,不影响已落库的记录。 */
private void enrichLocation(String user, String ip) {
try { try {
searcher = Searcher.newWithVectorIndex(dbPath, vIndex); String location = resolveLocation(ip);
String region = searcher.search(ip); if (location == null || location.isBlank() || "unknown".equals(location))
String[] temp = region.split("\\|");
for (int i = 2; i < temp.length; i++)
if(!temp[i].equals("0"))
location.append(temp[i]).append(" ");
} catch (Exception e) {
System.out.printf("failed to create vectorIndex cached searcher with `%s`: %s\n", dbPath, e);
return; return;
} subMapper.updateLatestSubUpdateRecordLocation(user, location);
} catch (Exception e) {
SubUpdateRecord subUpdateRecord = new SubUpdateRecord(0, subBind.getUser(), ip, UA, new Date(), location.toString().trim()); log.debug("补齐订阅更新记录归属地失败 user={}", user, e);
subMapper.insertSubUpdateRecord(subUpdateRecord);
if(subMapper.selectUpdateRecordCount(subBind.getUser()) > 10)
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(subBind.getUser()));
switch (client){
case "v2" -> FileDownload.export(request, response, "sub/DouNaiV2ray.txt");
case "cat" -> FileDownload.export(request, response, "sub/DouNaiClash.txt");
default -> {
try{
response.getOutputStream().write("client error".getBytes(StandardCharsets.UTF_8));
} catch (IOException ignored){}
}
} }
} }
public String deleteSubBind(String user){ /**
Response response = Response.generateResponse(); * 查询 IP 归属地。第三方站点结构变化或网络异常都只降级为 unknown,
* 绝不让定位失败影响订阅分发本身。
*/
static String resolveLocation(String ip) {
try {
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
return tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim()
: (tds.isEmpty() ? "unknown" : tds.get(1).text());
} catch (Exception e) {
return "unknown";
}
}
public String deleteSubBind(String user) {
return withWriteLock(() -> deleteSubBindUnlocked(user));
}
private String deleteSubBindUnlocked(String user) {
subMapper.deleteSubBind(user); subMapper.deleteSubBind(user);
subMapper.deleteSubUpdateRecord(user); subMapper.deleteSubUpdateRecord(user);
return response.success("删除成功").toJSONString(); remoteService.requestSubscriptionSync();
return Response._success("删除成功");
}
private String withWriteLock(Supplier<String> action) {
Lock stateLock = stateCoordinator.writeLock();
stateLock.lock();
try {
return action.get();
} finally {
stateLock.unlock();
}
}
private SubscriptionAccount firstEnabledAccount() {
return subMapper.selectAllSubscriptionAccounts().stream().filter(SubscriptionAccount::isEnabled).findFirst().orElse(null);
}
private String accountJson(SubscriptionAccount account) {
account.setV2Url(refreshService.v2Url(account));
account.setClashUrl(refreshService.clashUrl(account));
return CustomUtil.objectMapper.valueToTree(account).toString();
}
private static void sendStatus(HttpServletResponse response, int status, String message) {
try {
response.sendError(status, message);
} catch (IOException ignored) { }
} }
} }
@@ -0,0 +1,225 @@
package com.lion.lionwebsite.Service;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/**
* 为一批子账号排定刷新时刻。
*
* <p>目标:每个账号每 24 小时内至少刷新一次,且各账号的时刻互相错开,
* 不出现「一次全量、十几个账号在十几秒内打完」的爆发式请求。
*
* <p>排程模型是「错峰 + 固定相位」:
* <ol>
* <li><b>错峰</b>:首次排程把账号随机铺满一个窗口,两两之间至少隔开 {@code minGap};</li>
* <li><b>固定相位</b>:账号每次成功后按窗口长度(略减一个 tick)推进,
* 因此各自的相位保持不变,分散效果自我维持,不需要每天重新洗牌;</li>
* <li><b>兜底</b>:任何情况下下一次刷新都不会晚于「上次成功 + 窗口」,
* 把「24 小时内必然刷新一次」从概率保证变成确定保证。</li>
* </ol>
*
* <p>本类是纯函数式设计(注入 {@link Clock} 与带种子的 {@link Random}),
* 不触碰数据库与网络,因此可以直接做确定性单元测试。
*/
public class SubscriptionRefreshPlanner {
private final Clock clock;
private final Random random;
private final Duration window;
private final Duration minGap;
/** 一个调度周期的最大延迟;用它给窗口留余量,使实际间隔严格落在窗口内。 */
private final Duration tickInterval;
/** 刷新失败后的重试间隔。 */
private final Duration retryDelay;
/**
* @param clock 时间源,测试可注入固定时钟
* @param random 随机源,测试可注入固定种子以复现排程
* @param window 刷新窗口,即「24 小时内至少一次」中的 24 小时
* @param minGap 相邻两个账号之间的最小间隔
* @param tickInterval 调度周期,用于给窗口留出余量
* @param retryDelay 失败后的重试间隔
*/
public SubscriptionRefreshPlanner(Clock clock, Random random, Duration window, Duration minGap,
Duration tickInterval, Duration retryDelay) {
if (window.isZero() || window.isNegative())
throw new IllegalArgumentException("刷新窗口必须为正数");
if (minGap.isNegative())
throw new IllegalArgumentException("最小间隔不能为负数");
if (tickInterval.isNegative())
throw new IllegalArgumentException("调度周期不能为负数");
if (retryDelay.isZero() || retryDelay.isNegative())
throw new IllegalArgumentException("重试间隔必须为正数");
if (retryDelay.compareTo(window) > 0)
throw new IllegalArgumentException("重试间隔不能大于刷新窗口,否则无法保证窗口内重试");
this.clock = clock;
this.random = random;
this.window = window;
this.minGap = minGap;
this.tickInterval = tickInterval;
this.retryDelay = retryDelay;
}
/** 便捷构造:重试间隔默认取 1 小时。 */
public SubscriptionRefreshPlanner(Clock clock, Random random, Duration window, Duration minGap,
Duration tickInterval) {
this(clock, random, window, minGap, tickInterval, Duration.ofHours(1));
}
/**
* 账号数与最小间隔是否放得下。
*
* <p>注意这里刻意只用于「提示」而不是阻断启动:账号数是由用户在管理页决定的,
* 若因为它超限就让整个应用起不来,等于把配置细节变成一次线上故障。
* 放不下时 {@link #initialSchedule} 仍会把账号铺满整个窗口(只是间隔小于期望值),
* 结果是错峰效果变弱,而不是退回串行爆发。
*/
public boolean isCapacitySufficient(int accountCount) {
if (accountCount <= 1)
return true;
return minGap.toMillis() * (accountCount - 1) <= window.toMillis();
}
/** 容量不足时的告警文案,供调用方记录日志。 */
public String capacityMessage(int accountCount) {
long required = minGap.toMillis() * (accountCount - 1);
return String.format(
"刷新窗口放不下最小间隔:账号 %d 个、最小间隔 %d 分钟,需要 %d 分钟,但窗口只有 %d 分钟;"
+ "实际仍会铺满窗口,只是账号间间隔会小于期望值。可调小 subscription.refresh.min-gap-minutes。",
accountCount, minGap.toMinutes(),
Duration.ofMillis(required).toMinutes(), window.toMinutes());
}
/**
* 为一批「已成功刷新过」的账号铺开计划,返回顺序与 accountIds 一致。
*
* <p>已成功过的账号内容仍然可用,因此可以安心分散到整个窗口,
* 不会因为排在 20 小时后而让用户拿不到订阅。
*/
public List<Long> initialSchedule(List<Integer> accountIds) {
List<Long> result = new ArrayList<>(accountIds.size());
if (accountIds.isEmpty())
return result;
long now = clock.millis();
long windowMillis = window.toMillis();
int count = accountIds.size();
long slotWidth = windowMillis / count;
long jitterCap = Math.max(1, Math.min(slotWidth, Math.max(1, slotWidth - minGap.toMillis())));
List<Integer> slots = new ArrayList<>(count);
for (int i = 0; i < count; i++)
slots.add(i);
Collections.shuffle(slots, random);
long[] planned = new long[count];
for (int accountIndex = 0; accountIndex < count; accountIndex++) {
long offset = slots.get(accountIndex) * slotWidth + (long) (random.nextDouble() * jitterCap);
planned[accountIndex] = now + Math.min(offset, windowMillis - 1);
}
for (int i = 0; i < count; i++)
result.add(planned[i]);
return result;
}
/**
* 为尚未成功过的账号排程:尽快刷,但互相错开。
*
* <p>这类账号(新建、或上游 key 刚改过)可能根本没有可用缓存,用户立刻请求订阅
* 就会拿不到内容,因此不能像常规账号那样分散到整个窗口。这里把它们按
* {@code spacing} 依次排开,配合调度器的单轮上限逐批刷新,
* 既尽早补齐,又避免一批新账号同时打上游。
*
* @param accountIds 账号 ID,顺序即刷新优先级
* @param spacing 相邻两个账号的间隔(通常取一个 tick)
*/
public List<Long> coldStartSchedule(List<Integer> accountIds, Duration spacing) {
List<Long> result = new ArrayList<>(accountIds.size());
long now = clock.millis();
long step = Math.max(1, spacing.toMillis());
for (int i = 0; i < accountIds.size(); i++)
result.add(now + i * step);
return result;
}
/**
* 已排定的时刻是否已到期。
*
* <p>直接以排定时刻为准,这样分散效果与失败退避都得以保持。
* 只额外兜住一种异常:计划被写到超过一个窗口之后。这不可能是本调度器产生的
* 排程(成功时排在窗口减一个 tick 之后,失败时排在 4 小时以内),
* 按「计划损坏」处理并立即到期,从而保证 24 小时内必定刷新。
*
* <p>尚未排程({@code scheduled == null})视为立即到期,由调用方先行铺开计划。
*/
public boolean isDue(Long scheduled, long now) {
if (scheduled == null)
return true;
if (scheduled - now > window.toMillis())
return true;
return scheduled <= now;
}
/**
* 成功之后的下一次刷新时刻。
*
* <p>只推进「窗口 - 一个调度周期」,使 tick 即使晚触发也不会突破 24 小时;
* 从上次成功的时刻起算,保证相邻两次的间隔严格小于窗口。
*/
public long nextAfterSuccess(long successMillis) {
long advance = Math.max(1, window.minus(tickInterval).toMillis());
return successMillis + advance;
}
/**
* 失败之后的重试时刻。
*
* <p>用固定的重试间隔而不是「失败次数 × 指数退避」:刷新成功会清空 {@code last_error},
* 数据库里没有「连续失败次数」这一栏,退避指数实际上只能取到 0 或 1,
* 写出来会让人误以为有指数退避而其实没有。固定的重试间隔行为可预期,
* 且远小于窗口,不会威胁「24 小时内尝试刷新一次」。
*
* <p>唯一需要裁剪的情况:若「上次成功 + 窗口」还没到,则重试不得晚于它;
* 已经越过该点时保留重试时刻本身——重复请求并不会提高成功率,
* 反过来在每个 tick 重打上游才是真正的问题,这种情况交由陈旧告警处理。
*
* @param now 当前时刻
* @param lastSuccess 上次成功时刻;未知时传 null,不做裁剪
*/
public long nextAfterFailure(long now, Long lastSuccess) {
long candidate = now + retryDelay.toMillis();
if (lastSuccess == null)
return candidate;
long deadline = lastSuccess + window.toMillis();
return deadline > now ? Math.min(candidate, deadline) : candidate;
}
/** 是否已越过兜底线(距上次成功已达一个窗口),用于诊断与告警判定。 */
public boolean isOverdue(Long lastSuccess, long now) {
return lastSuccess != null && now - lastSuccess >= window.toMillis();
}
private long now() {
return clock.millis();
}
public Clock clock() {
return clock;
}
public Duration window() {
return window;
}
public Duration minGap() {
return minGap;
}
public Duration tickInterval() {
return tickInterval;
}
}
@@ -0,0 +1,247 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 分散刷新各子账号的订阅。
*
* <p>取代原先「每 24 小时一次全量」的调度。旧写法有两个问题:所有账号在十几秒内
* 背靠背打完,且 {@code @Scheduled(fixedRate = 86400000)} 没有 initialDelay,
* 首次触发即立刻执行——于是每次重启都会重刷一遍全部账号,上游看到的请求密度取决于
* 部署频率,而不是每天一次。
*
* <p>现在改为高频轻量 tick,每轮只刷新已经到期的账号。「24 小时内每个账号至少
* 更新一次」由三层保证:
* <ol>
* <li>成功后排定「成功时刻 + 窗口 - 一个 tick」,相位自我维持,分散效果不会因
* 重启或重排而退化;</li>
* <li>到期时刻存在数据库里,停机期间错过计划的账号在恢复后立即到期;</li>
* <li>兜底闸:任何排定时刻都被裁剪到「上次成功 + 窗口」以内,越线即立刻刷新。</li>
* </ol>
*/
@Service
@Slf4j
public class SubscriptionRefreshScheduler {
private final SubMapper subMapper;
private final SubscriptionRefreshService refreshService;
private final SubscriptionRefreshPlanner planner;
private final PushService pushService;
private final RemoteService remoteService;
/** 单个 tick 最多刷新几个账号,避免重启后一批到期账号同时开火。 */
@Value("${subscription.refresh.max-per-tick:2}")
int maxPerTick;
/** 超过窗口的这个倍数仍未成功即告警,避免静默失败。 */
@Value("${subscription.refresh.stale-alert-multiplier:2.0}")
double staleAlertMultiplier;
/** 已告警过的账号,避免每轮重复推送;刷新成功后移除。 */
private final Set<Integer> alertedAccounts = new HashSet<>();
public SubscriptionRefreshScheduler(SubMapper subMapper,
SubscriptionRefreshService refreshService,
SubscriptionRefreshPlanner planner,
PushService pushService,
RemoteService remoteService) {
this.subMapper = subMapper;
this.refreshService = refreshService;
this.planner = planner;
this.pushService = pushService;
this.remoteService = remoteService;
}
/**
* 轻量 tick:只处理已到期的账号,且单轮有数量上限。
*
* <p>用 fixedDelay 而非 fixedRate,一轮结束再计下一轮,避免上一轮没跑完就叠加触发;
* initialDelay 让重启后的第一轮稍晚开始,先让应用完成启动。
*/
@Scheduled(fixedDelayString = "${subscription.refresh.tick-interval-ms:300000}",
initialDelayString = "${subscription.refresh.initial-delay-ms:120000}")
public void tick() {
try {
refreshDueAccounts();
} catch (Exception e) {
// 调度方法抛出异常会导致后续触发被取消,这里必须兜住。
log.error("订阅分散刷新 tick 失败", e);
}
}
/**
* 刷新当前已到期的账号。
*
* @return 本轮实际刷新的账号数
*/
public int refreshDueAccounts() {
List<SubscriptionAccount> enabled = enabledAccounts();
ensureScheduled(enabled);
long now = planner.clock().millis();
int refreshed = 0;
for (SubscriptionAccount account : enabled) {
if (refreshed >= maxPerTick)
break;
if (!isDue(account, now))
continue;
refreshOne(account, now);
refreshed++;
}
reportStale(enabled, now);
// 缓存内容变了就要让备机同步。原先全量刷新后由调用方跟一次同步,
// 改为分散刷新后必须在这里补上,否则备机要等到下一次周期同步才拿到新内容。
if (refreshed > 0)
remoteService.requestSubscriptionSync();
return refreshed;
}
/**
* 手动刷新入口:立即刷新全部启用账号。
*
* <p>刻意不受 {@code maxPerTick} 限制——管理页「更新订阅」的语义就是立刻全部刷新。
* 但成功后同样要重排各自的下一次时刻,否则刚手动刷完会让当天的计划作废,
* 该账号反而可能超过 24 小时没有下次更新。
*/
public boolean refreshAllNow() {
boolean success = true;
long now = planner.clock().millis();
for (SubscriptionAccount account : enabledAccounts()) {
if (!refreshOne(account, now))
success = false;
}
return success;
}
private List<SubscriptionAccount> enabledAccounts() {
return subMapper.selectAllSubscriptionAccounts().stream()
.filter(SubscriptionAccount::isEnabled)
.sorted(Comparator.comparing(SubscriptionAccount::getId))
.toList();
}
/** 刷新单个账号并按结果重排下一次时刻。 */
private boolean refreshOne(SubscriptionAccount account, long now) {
boolean ok = refreshService.refresh(account.getId());
if (ok) {
long next = planner.nextAfterSuccess(planner.clock().millis());
subMapper.updateNextRefreshAt(account.getId(), next);
alertedAccounts.remove(account.getId());
log.info("订阅刷新成功 accountId={} 下次刷新={}", account.getId(), next);
} else {
long next = planner.nextAfterFailure(now, account.getLastSuccessEpoch());
subMapper.updateNextRefreshAt(account.getId(), next);
log.warn("订阅刷新失败 accountId={} 下次重试={}", account.getId(), next);
}
return ok;
}
/**
* 账号是否到期。
*
* <p>以排定时刻为准;尚未排程或计划明显损坏(超出窗口)时视为立即到期,
* 由 {@link #ensureScheduled} 先铺开或纠正。
*/
private boolean isDue(SubscriptionAccount account, long now) {
return planner.isDue(account.getNextRefreshAt(), now);
}
/**
* 为尚无计划的账号补齐计划。
*
* <p>按紧迫性分三类,因为它们对「排到多晚」的容忍度不同:
* <ul>
* <li><b>从未成功过</b>(新建账号、刚改过上游 key):可能还没有可用缓存,
* 用户此时请求订阅会拿不到内容,因此尽快刷,只按 tick 间隔互相错开;</li>
* <li><b>已成功过但已超期</b>(距上次成功已达一个窗口):保证即将失效,
* 必须立即刷新,不能排到窗口内更晚的位置;</li>
* <li><b>已成功过且未超期</b>:缓存仍可分发,按窗口分散即可,不必挤在一起。</li>
* </ul>
*/
private void ensureScheduled(List<SubscriptionAccount> enabled) {
long now = planner.clock().millis();
List<SubscriptionAccount> cold = new ArrayList<>();
List<SubscriptionAccount> overdue = new ArrayList<>();
List<SubscriptionAccount> warm = new ArrayList<>();
for (SubscriptionAccount account : enabled) {
if (account.getNextRefreshAt() != null)
continue;
if (account.getLastSuccessEpoch() == null)
cold.add(account);
else if (planner.isOverdue(account.getLastSuccessEpoch(), now))
overdue.add(account);
else
warm.add(account);
}
if (cold.isEmpty() && overdue.isEmpty() && warm.isEmpty())
return;
if (!planner.isCapacitySufficient(enabled.size()))
log.warn("{}", planner.capacityMessage(enabled.size()));
// 超期账号立即到期(计划设为当前时刻),本轮就会被刷新。
for (SubscriptionAccount account : overdue)
schedule(account, now);
int index = 0;
List<Long> coldPlan = planner.coldStartSchedule(
cold.stream().map(SubscriptionAccount::getId).toList(), planner.tickInterval());
for (SubscriptionAccount account : cold)
schedule(account, coldPlan.get(index++));
List<Integer> warmIds = warm.stream().map(SubscriptionAccount::getId).toList();
List<Long> warmPlan = planner.initialSchedule(warmIds);
for (int i = 0; i < warm.size(); i++)
schedule(warm.get(i), warmPlan.get(i));
}
/**
* 写入新计划,并同步更新内存中的对象。
*
* <p>必须同时更新内存副本:否则刚被排到几小时后的账号在本轮仍带着
* {@code nextRefreshAt == null},会被 {@link #isDue} 判为立即到期并当场刷新,
* 于是「铺开」在第一个 tick 就失效了(迁移后首批账号会全部集中刷新)。
*/
private void schedule(SubscriptionAccount account, long nextRefreshAt) {
subMapper.updateNextRefreshAt(account.getId(), nextRefreshAt);
account.setNextRefreshAt(nextRefreshAt);
}
/** 陈旧账号告警:同一账号在连续陈旧期间只推一次,刷新成功后重置。 */
private void reportStale(List<SubscriptionAccount> enabled, long now) {
long threshold = (long) (planner.window().toMillis() * staleAlertMultiplier);
for (SubscriptionAccount account : enabled) {
Long lastSuccess = account.getLastSuccessEpoch();
if (lastSuccess == null || now - lastSuccess < threshold)
continue;
if (!alertedAccounts.add(account.getId()))
continue;
long hours = (now - lastSuccess) / 3_600_000L;
String message = String.format("订阅账号 %d(%s)已 %d 小时未成功刷新,最近错误:%s",
account.getId(), account.getName(), hours,
account.getLastError() == null ? "无" : account.getLastError());
log.error("{}", message);
pushService.sendToMe(message);
}
// 已删除或停用的账号不再保留告警状态,避免集合无限增长。
Set<Integer> alive = new HashSet<>();
for (SubscriptionAccount account : enabled)
alive.add(account.getId());
alertedAccounts.retainAll(alive);
}
SubscriptionRefreshPlanner planner() {
return planner;
}
}
@@ -0,0 +1,285 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Util.SubscriptionClientProfile;
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.time.Clock;
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;
/** 时间源,测试可替换以固定成功时刻。 */
Clock clock = Clock.systemDefaultZone();
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), SubscriptionClientProfile.forAccount(accountId, false))),
account.isFilterHighMultiplier(), highMultiplierThreshold);
List<String> clash = processClash(download(clashUrl(account), SubscriptionClientProfile.forAccount(accountId, true)),
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, clock.millis());
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;
}
/**
* 下载上游订阅正文。
*
* @param profile 要伪装的客户端身份;为 null 时保留 HttpClient 默认头(仅测试使用)。
* 生产路径必须传入,否则上游会看到 {@code Apache-HttpClient/... (Java/...)}。
*/
List<String> download(String url, SubscriptionClientProfile profile) throws IOException {
HttpGet get = new HttpGet(url);
if (profile != null) {
get.addHeader("User-Agent", profile.userAgent());
get.addHeader("Accept", profile.accept());
get.addHeader("Accept-Language", profile.acceptLanguage());
// 实测:显式设置 Accept-Encoding 不会破坏 HttpClient5 的透明解压,
// ContentCompressionExec 仍按 Content-Encoding 正确解码。
get.addHeader("Accept-Encoding", "gzip, deflate");
// 订阅客户端是长连接复用,且不会发送 Referer。
get.addHeader("Connection", "keep-alive");
}
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
if (response.getCode() != 200)
throw new IOException("上游 HTTP 状态码 " + response.getCode());
HttpEntity entity = response.getEntity();
if (entity == null)
throw new IOException("上游返回为空");
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(entity.getContent(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null)
lines.add(line);
}
return lines;
}
}
private static void atomicWrite(Path target, byte[] data) throws IOException {
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
Files.write(temp, data);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
}
public Path cachedPath(Integer accountId, String client) {
return Paths.get(cacheRoot, String.valueOf(accountId), client.equals("v2") ? "v2ray.txt" : "clash.yaml");
}
public boolean hasCompleteCache(Integer accountId) {
return Files.isRegularFile(cachedPath(accountId, "v2")) && Files.isRegularFile(cachedPath(accountId, "cat"));
}
public void invalidateCache(Integer accountId) {
Lock stateLock = stateCoordinator.writeLock();
stateLock.lock();
try {
latestRefresh.remove(accountId); // In-flight responses must not restore invalidated content.
Files.deleteIfExists(cachedPath(accountId, "v2"));
Files.deleteIfExists(cachedPath(accountId, "cat"));
} catch (IOException e) {
log.warn("清理失效订阅缓存失败 accountId={}", accountId, e);
} finally {
stateLock.unlock();
}
}
}
@@ -0,0 +1,138 @@
package com.lion.lionwebsite.Service;
import tools.jackson.databind.ObjectMapper;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Message.*;
import com.lion.lionwebsite.Util.CustomUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.zip.GZIPOutputStream;
import java.util.concurrent.locks.Lock;
@Service
@RequiredArgsConstructor
public class SubscriptionStandbySnapshotService {
private final SubMapper subMapper;
private final SubscriptionStateCoordinator stateCoordinator;
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
@Value("${subscription.standby.sync-secret:}")
String syncSecret;
@Value("${subscription.standby.sync-enabled:false}")
boolean syncEnabled;
@Value("${subscription.cache-root:sub/accounts}")
String cacheRoot;
@PostConstruct
void validateConfiguration() {
if (syncEnabled && (syncSecret == null || syncSecret.isBlank()))
throw new IllegalStateException("启用订阅备机同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
}
public SubscriptionSnapshotMessage build() throws IOException {
Lock stateLock = stateCoordinator.readLock();
stateLock.lock();
try {
Map<Integer, SubscriptionAccountSnapshot> accountMap = new HashMap<>();
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
Path v2Path = cachedPath(account.getId(), "v2");
Path clashPath = cachedPath(account.getId(), "cat");
if (!account.isEnabled() || !Files.isRegularFile(v2Path) || !Files.isRegularFile(clashPath))
continue;
byte[] v2 = Files.readAllBytes(v2Path);
byte[] clash = Files.readAllBytes(clashPath);
SubscriptionAccountSnapshot snapshot = new SubscriptionAccountSnapshot();
snapshot.setAccountId(account.getId());
snapshot.setEnabled(true);
snapshot.setFilterHighMultiplier(account.isFilterHighMultiplier());
snapshot.setV2ContentBase64(Base64.getEncoder().encodeToString(v2));
snapshot.setV2Sha256(sha256(v2));
snapshot.setClashContentBase64(Base64.getEncoder().encodeToString(clash));
snapshot.setClashSha256(sha256(clash));
accountMap.put(account.getId(), snapshot);
}
List<SubscriptionAccountSnapshot> accounts = new ArrayList<>(accountMap.values());
accounts.sort(Comparator.comparing(SubscriptionAccountSnapshot::getAccountId));
List<SubscriptionBindingSnapshot> bindings = new ArrayList<>();
for (SubBind bind : subMapper.selectAllSubBind()) {
if (bind.getSubscriptionAccountId() == null || !accountMap.containsKey(bind.getSubscriptionAccountId()))
continue;
SubscriptionBindingSnapshot snapshot = new SubscriptionBindingSnapshot();
snapshot.setPublicKeySha256(sha256(bind.getKey().getBytes(StandardCharsets.UTF_8)));
snapshot.setAccountId(bind.getSubscriptionAccountId());
bindings.add(snapshot);
}
bindings.sort(Comparator.comparing(SubscriptionBindingSnapshot::getPublicKeySha256));
SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload();
payload.setSchemaVersion(1);
payload.setAccounts(accounts);
payload.setBindings(bindings);
byte[] payloadJson = objectMapper.writeValueAsBytes(payload);
byte[] compressed = gzip(payloadJson);
String revision = sha256(payloadJson);
String payloadSha256 = sha256(compressed);
long generatedAt = System.currentTimeMillis();
String signatureInput = "1\n" + revision + "\n" + generatedAt + "\n" + payloadSha256;
SubscriptionSnapshotMessage message = new SubscriptionSnapshotMessage();
message.setSchemaVersion(1);
message.setRevision(revision);
message.setGeneratedAt(generatedAt);
message.setPayloadBase64(Base64.getEncoder().encodeToString(compressed));
message.setPayloadSha256(payloadSha256);
message.setSignature(hmac(signatureInput.getBytes(StandardCharsets.UTF_8)));
return message;
} finally {
stateLock.unlock();
}
}
private String hmac(byte[] input) {
if (syncSecret == null || syncSecret.isBlank())
throw new IllegalStateException("订阅备机同步密钥未配置");
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(syncSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(input));
} catch (Exception e) {
throw new IllegalStateException("生成订阅快照签名失败", e);
}
}
private static byte[] gzip(byte[] input) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(output)) {
gzip.write(input);
}
return output.toByteArray();
}
private static String sha256(byte[] input) {
try { return hex(MessageDigest.getInstance("SHA-256").digest(input)); }
catch (Exception e) { throw new IllegalStateException(e); }
}
private static String hex(byte[] input) { return HexFormat.of().formatHex(input); }
private Path cachedPath(Integer accountId, String client) {
return Path.of(cacheRoot, String.valueOf(accountId), "v2".equals(client) ? "v2ray.txt" : "clash.yaml");
}
}
@@ -0,0 +1,20 @@
package com.lion.lionwebsite.Service;
import org.springframework.stereotype.Component;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/** Coordinates database bindings and the two cache files as one subscription state. */
@Component
public final class SubscriptionStateCoordinator {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public Lock readLock() {
return lock.readLock();
}
public Lock writeLock() {
return lock.writeLock();
}
}
@@ -7,33 +7,26 @@ 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 lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
@Service @Service
public class UserServiceImpl{ @Slf4j
@RequiredArgsConstructor
public class UserService{
@Resource final UserMapper userMapper;
UserMapper userMapper;
@Resource final GalleryMapper galleryMapper;
GalleryMapper galleryMapper;
@Resource final CollectMapper collectMapper;
CollectMapper collectMapper;
@Resource final TaskHandlerInterceptor taskHandlerInterceptor;
TaskHandlerInterceptor taskHandlerInterceptor;
public UserServiceImpl(UserMapper userMapper, GalleryMapper galleryMapper, CollectMapper collectMapper, TaskHandlerInterceptor taskHandlerInterceptor) {
this.userMapper = userMapper;
this.galleryMapper = galleryMapper;
this.collectMapper = collectMapper;
this.taskHandlerInterceptor = taskHandlerInterceptor;
}
public String addAuthCode(String targetAuthCode, String people) { public String addAuthCode(String targetAuthCode, String people) {
Response response = Response.generateResponse(); Response response = Response.generateResponse();
@@ -43,7 +36,7 @@ public class UserServiceImpl{
taskHandlerInterceptor.updateAuthCodes(); taskHandlerInterceptor.updateAuthCodes();
response.success("插入成功"); response.success("插入成功");
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("插入授权码失败", e);
response.failure("插入失败"); response.failure("插入失败");
} }
@@ -62,7 +55,7 @@ public class UserServiceImpl{
response.failure("授权码不存在"); response.failure("授权码不存在");
} }
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("修改授权码失败", e);
response.failure("修改失败"); response.failure("修改失败");
} }
@@ -80,7 +73,7 @@ public class UserServiceImpl{
response.failure("授权码不存在"); response.failure("授权码不存在");
} }
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("修改用户名失败", e);
response.failure("修改失败"); response.failure("修改失败");
} }
@@ -104,7 +97,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("删除失败");
} }
@@ -135,7 +128,7 @@ public class UserServiceImpl{
Response response = Response.generateResponse(); Response response = Response.generateResponse();
User[] users = userMapper.selectAllUser(); User[] users = userMapper.selectAllUser();
response.success(new ObjectMapper().valueToTree(users).toString()); response.success(objectMapper.valueToTree(users).toString());
return response.toJSONString(); return response.toJSONString();
} }
@@ -143,4 +136,18 @@ public class UserServiceImpl{
public int getUserId(String AuthCode){ public int getUserId(String AuthCode){
return userMapper.selectUserByAuthCode(AuthCode).getId(); return userMapper.selectUserByAuthCode(AuthCode).getId();
} }
/**
* 管理员判定。项目没有角色表,历史上以 id=3(狮子)作为管理员,
* 前端也是用 userId === 3 判断;这里把这个约定收敛到一处,
* 权限相关的服务端校验统一走它。
*/
public static final int ADMIN_USER_ID = 3;
public boolean isAdmin(String AuthCode){
if(AuthCode == null)
return false;
User user = userMapper.selectUserByAuthCode(AuthCode);
return user != null && user.getId() == ADMIN_USER_ID;
}
} }
@@ -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,8 +1,9 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
import java.net.ServerSocket; import java.net.ServerSocket;
@@ -13,6 +14,7 @@ import java.util.regex.Pattern;
@Data @Data
@Slf4j
public class CustomUtil { public class CustomUtil {
public static final double ONE_KB = 1024; public static final double ONE_KB = 1024;
@@ -80,12 +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)){
ignored.close(); ignored.close();
return (short) i; return i;
}catch (IOException ignored) { }catch (IOException ignored) {
log.trace("port {} unavailable", i);
} }
} }
return -1; return -1;
@@ -95,7 +98,7 @@ public class CustomUtil {
try{ try{
response.sendError(404); response.sendError(404);
}catch (IOException e){ }catch (IOException e){
e.printStackTrace(); log.warn("sendError 404 failed", e);
} }
} }
} }
@@ -1,122 +1,85 @@
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);
//已传送数据大小 // 关闭响应流:Tomcat 为大响应体创建的临时文件会在流关闭时删除,
long transmitted = 0; // 不关闭会一直堆积到 Full GC。flushBuffer 只能把内容送出去,不能触发清理。
try { try (BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream())) {
randomAccessFile = new RandomAccessFile(file, "r"); byte[] buffer = new byte[8192];
outputStream = new BufferedOutputStream(response.getOutputStream()); while (remaining > 0) {
byte[] buff = new byte[4096]; int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
int len = 0; if (count == -1)
randomAccessFile.seek(startByte); throw new EOFException("File changed during download");
while ((transmitted + len) <= contentLength && (len = randomAccessFile.read(buff)) != -1) { output.write(buffer, 0, count);
outputStream.write(buff, 0, len); remaining -= count;
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) {
// ignore 用户停止下载
// log.trace("用户停止下载: {}-{}, 已传输 {}", startByte, endByte, transmitted);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (randomAccessFile != null) {
randomAccessFile.close();
} }
} catch (ClientAbortException e) {
// The client cancelled its download.
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); log.warn("文件下载失败: {}", path, e);
if (!response.isCommitted()) {
response.reset();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
} }
} }
} }
@@ -1,24 +1,31 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.lion.lionwebsite.Domain.Gallery; import com.lion.lionwebsite.Domain.Gallery;
import com.lion.lionwebsite.Domain.ImageKeyCache; import com.lion.lionwebsite.Domain.ImageKeyCache;
import com.lion.lionwebsite.Exception.ResolutionNotMatchException; import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
import org.apache.http.HttpEntity; import java.nio.file.*;
import org.apache.http.client.entity.EntityBuilder; import java.util.concurrent.TimeUnit;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.http.client.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.http.client.methods.HttpPost; import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.http.entity.ContentType; import org.apache.hc.client5.http.entity.EntityBuilder;
import org.apache.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.util.Timeout;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.*; import java.util.*;
@@ -28,6 +35,8 @@ import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
public class GalleryUtil { public class GalleryUtil {
private static final Logger log = LoggerFactory.getLogger(GalleryUtil.class);
static String POST = "post"; static String POST = "post";
static String GET = "get"; static String GET = "get";
@@ -35,15 +44,40 @@ public class GalleryUtil {
static String JSON = "json"; static String JSON = "json";
static HashMap<String, String> gid2MpvKey = new HashMap<>(); /**
* gid → mpvkey 的内存缓存。key 会轮换,代码里对失配也做了刷新兜底,
* 因此这里只需限制为有界 LRU,避免长期运行无上限增长。
*/
static final Map<String, String> gid2MpvKey = Collections.synchronizedMap(
new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 2048;
}
});
/** Reusable HTTP client —不要每次请求新建 */
private static final CloseableHttpClient httpClient = HttpClients.custom()
.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 没有目标分辨率
*/ */
@@ -54,18 +88,23 @@ public class GalleryUtil {
return null; return null;
} }
//初始化本子 //初始化图片
Integer gid = parseGid(url);
if (gid == null) {
log.warn("链接无法解析出 gid,按无效链接处理");
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(gid);
gallery.setProceeding(0); gallery.setProceeding(0);
//访问本子页面 //访问图片页面
String galleryPage = requests(url, GET, null, 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]));
@@ -117,7 +156,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);
} }
@@ -136,7 +175,7 @@ public class GalleryUtil {
if(downloadDoc.select("#db > p:nth-child(2) > strong").text().startsWith("#")) if(downloadDoc.select("#db > p:nth-child(2) > strong").text().startsWith("#"))
gallery.setStatus("已提交"); gallery.setStatus("已提交");
else { else {
System.out.println(downloadDoc.select("#db")); log.warn("下载提交失败: {}", downloadDoc.select("#db"));
gallery.setStatus("提交失败"); gallery.setStatus("提交失败");
} }
@@ -150,13 +189,17 @@ public class GalleryUtil {
HashMap<String, String> header = new HashMap<>(); HashMap<String, String> header = new HashMap<>();
header.put("Referer", url); header.put("Referer", url);
String content = requests(mpvUrl, "get", header, null); String content = requests(mpvUrl, "get", header, null);
if(content.trim().isEmpty())
return null;
Document document = Jsoup.parse(content); Document document = Jsoup.parse(content);
if(document.select("body > script").size() < 2)
return null;
Element script = document.select("body > script").get(1); Element script = document.select("body > script").get(1);
String[] scripts = script.html().split("\n"); String[] scripts = script.html().split("\n");
ArrayList<ImageKeyCache> imageKeyCaches = new ArrayList<>(); ArrayList<ImageKeyCache> imageKeyCaches = new ArrayList<>();
AtomicInteger page = new AtomicInteger(1); AtomicInteger page = new AtomicInteger(1);
gid2MpvKey.put(gid, scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", "")); gid2MpvKey.put(gid, scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", ""));
JsonNode nodes = objectMapper.readValue(scripts[2].replace("var imagelist = ", ""), JsonNode.class); JsonNode nodes = parseImagelist(scripts[2]);
nodes.forEach((n) -> { nodes.forEach((n) -> {
ImageKeyCache imageKeyCache = new ImageKeyCache(); ImageKeyCache imageKeyCache = new ImageKeyCache();
imageKeyCache.setGid(gid); imageKeyCache.setGid(gid);
@@ -167,11 +210,28 @@ public class GalleryUtil {
return imageKeyCaches; return imageKeyCaches;
} }
public static synchronized String getMpvKey(String url){ /**
* 解析 mpv 页里的 imagelist 行。
* 该行是 JS 赋值语句(`var imagelist = [...];`),行尾分号属 JavaScript 语法而非 JSON,
* 因此先剥掉「var imagelist = 」前缀与行尾分号,再交给 JSON 解析。
* Jackson 3 默认开启 FAIL_ON_TRAILING_TOKENS,若把分号留给它会在解析时报错。
*/
private static JsonNode parseImagelist(String scriptLine) {
String json = scriptLine.replace("var imagelist = ", "").trim();
if (json.endsWith(";"))
json = json.substring(0, json.length() - 1).trim();
return objectMapper.readValue(json, JsonNode.class);
}
public static String getMpvKey(String url){
String gid = String.valueOf(parseGid(url)); String gid = String.valueOf(parseGid(url));
if(!gid2MpvKey.containsKey(gid)) String key = gid2MpvKey.get(gid);
if (key == null) {
// refreshMpvKey writes the cache itself; never call it inside computeIfAbsent.
refreshMpvKey(url); refreshMpvKey(url);
return gid2MpvKey.get(gid); key = gid2MpvKey.get(gid);
}
return key;
} }
public static void refreshMpvKey(String url) { public static void refreshMpvKey(String url) {
@@ -183,8 +243,8 @@ public class GalleryUtil {
try { try {
content = requests(mpvUrl, "get", header, null); content = requests(mpvUrl, "get", header, null);
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); log.error("刷新mpvKey失败, url: {}", url, e);
gid2MpvKey.put(parseGid(url) + "", null); gid2MpvKey.remove(parseGid(url) + "");
return; return;
} }
Document document = Jsoup.parse(content); Document document = Jsoup.parse(content);
@@ -192,7 +252,7 @@ public class GalleryUtil {
String[] scripts = script.html().split("\n"); String[] scripts = script.html().split("\n");
String mpvKey = scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", ""); String mpvKey = scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", "");
gid2MpvKey.put(parseGid(url) + "", mpvKey); gid2MpvKey.put(parseGid(url) + "", mpvKey);
System.out.println("刷新key:" + mpvKey); log.info("刷新key:{}", mpvKey);
} }
public static String getImageUrl(String mpvKey, ImageKeyCache imageKeyCache) { public static String getImageUrl(String mpvKey, ImageKeyCache imageKeyCache) {
@@ -213,12 +273,47 @@ public class GalleryUtil {
return null; return null;
return jsonNode.get("i").asText(); return jsonNode.get("i").asText();
}catch (Exception e){ }catch (Exception e){
System.out.println("获取imgurl失败:" + imageKeyCache.getGid() + ":" + imageKeyCache.getPage()); log.error("获取imgurl失败:{}:{}", imageKeyCache.getGid(), imageKeyCache.getPage(), e);
e.printStackTrace();
return null; return null;
} }
} }
public static String convertImg(String imagePath, String suffix){
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); }
}
}
}
/** /**
* 验证链接 * 验证链接
@@ -245,15 +340,16 @@ public class GalleryUtil {
* @throws IOException 可能会抛出IO错误 * @throws IOException 可能会抛出IO错误
*/ */
public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException { public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse httpResponse; CloseableHttpResponse httpResponse;
if(headers == null) if(headers == null)
headers = new HashMap<>(); headers = new HashMap<>();
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"); headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"); headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
if(url.contains("hentai")) {
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(method.equals(GET)){ if(method.equals(GET)){
HttpGet httpGet = new HttpGet(url); HttpGet httpGet = new HttpGet(url);
@@ -281,30 +377,44 @@ public class GalleryUtil {
} }
httpResponse = httpClient.execute(httpPost); httpResponse = httpClient.execute(httpPost);
} }
try (httpResponse) {
HttpEntity responseEntity = httpResponse.getEntity(); HttpEntity responseEntity = httpResponse.getEntity();
int statusCode = httpResponse.getStatusLine().getStatusCode(); int statusCode = httpResponse.getCode();
StringBuilder stringBuilder = new StringBuilder(); StringBuilder stringBuilder = new StringBuilder();
if (statusCode == 200 && responseEntity != null) {
if(statusCode == 200){ try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) {
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
String str; String str;
while((str = reader.readLine()) != null) while ((str = reader.readLine()) != null)
stringBuilder.append(str).append("\n"); stringBuilder.append(str).append("\n");
} }
else{ } else {
System.out.println(statusCode); log.warn("{}:{}", url, statusCode);
} }
httpClient.close();
httpResponse.close();
return stringBuilder.toString(); return stringBuilder.toString();
} }
}
/**
* 从链接里安全提取 gid。
*
* <p>历史实现只捕 {@code IndexOutOfBoundsException}:非数字段会抛
* {@code NumberFormatException},{@code link == null} 会抛 NPE,两者都会在
* 「无 @ControllerAdvice」的项目里穿透成 500。这里与
* {@code GalleryManageService.parseGidFromLink} 保持一致的宽松语义:
* 任何畸形输入都返回 null,由调用方转成业务失败。
*/
public static Integer parseGid(String link){ public static Integer parseGid(String link){
if (link == null)
return null;
String[] parts = link.split("/g/");
if (parts.length < 2)
return null;
String[] segments = parts[1].split("/");
if (segments.length == 0)
return null;
try { try {
return Integer.parseInt(link.split("/g/")[1].split("/")[0]); return Integer.parseInt(segments[0]);
}catch (IndexOutOfBoundsException e){ } catch (NumberFormatException e) {
return null; 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);
}
});
}
}
@@ -0,0 +1,107 @@
package com.lion.lionwebsite.Util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
/**
* 机器人一键登录票据:与 PersonalHub 共享密钥的 HMAC 自签串。
*
* <p>形状为 {@code v1.<签发秒级时间戳>.<HMAC-SHA256(密钥, "v1.<时间戳>") 的十六进制>}。
* 机器人本地用同一把密钥签出,LionWebsite 侧只做验签与新鲜度判断,因此不需要
* 机器人到主站的网络调用,两端任一侧临时不可达都不影响链接生成。
*
* <p>设计取舍:票据不携带一次性状态,时间窗内可重放(用户明确接受这一点)。
* 窗口外的票据一律拒绝,所以泄露的链接在 {@code ticket-ttl-seconds} 之后自动失效。
*
* <p>票据只签发给主人:密钥只配置在主人自己的机器人上,不随用户分发。
*/
@Component
@Slf4j
public class PanelLoginTicket {
private static final String VERSION = "v1";
private static final String ALGORITHM = "HmacSHA256";
private final byte[] secret;
private final Duration ttl;
public PanelLoginTicket(
@Value("${personal.login.secret:}") String secret,
@Value("${personal.login.ticket-ttl-seconds:300}") long ttlSeconds) {
this.secret = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
this.ttl = Duration.ofSeconds(Math.max(30, ttlSeconds));
if (this.secret.length == 0)
log.warn("未配置 personal.login.secret,一键登录票据将一律拒绝");
}
/** 密钥未配置时无法签发也无法校验,调用方据此给出可读提示。 */
public boolean configured() {
return secret.length > 0;
}
public String issue() {
return issue(Instant.now());
}
/** 供测试注入固定时钟。 */
public String issue(Instant now) {
if (!configured())
throw new IllegalStateException("未配置 personal.login.secret");
String stamp = String.valueOf(now.getEpochSecond());
return VERSION + "." + stamp + "." + sign(stamp);
}
public boolean verify(String ticket) {
return verify(ticket, Instant.now());
}
public boolean verify(String ticket, Instant now) {
if (!configured() || ticket == null)
return false;
String[] parts = ticket.split("\\.", -1);
if (parts.length != 3 || !VERSION.equals(parts[0]) || parts[1].isEmpty() || parts[2].isEmpty())
return false;
if (!MessageDigest.isEqual(sign(parts[1]).getBytes(StandardCharsets.UTF_8),
parts[2].getBytes(StandardCharsets.UTF_8)))
return false;
long issuedAt;
try {
issuedAt = Long.parseLong(parts[1]);
} catch (NumberFormatException e) {
return false;
}
// 只接受 [now - ttl, now]:未来时间戳一律拒绝,避免伪造者用远期时间换取长期有效。
long ageSeconds = now.getEpochSecond() - issuedAt;
return ageSeconds >= 0 && ageSeconds <= ttl.getSeconds();
}
public long ttlSeconds() {
return ttl.getSeconds();
}
private String sign(String stamp) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(secret, ALGORITHM));
return HexFormat.of().formatHex(
mac.doFinal((VERSION + "." + stamp).getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
// 算法名固定且密钥非空,正常不会走到这里;一旦发生必须视为校验失败。
log.error("计算登录票据签名失败", e);
return "";
}
}
}
@@ -1,7 +1,7 @@
package com.lion.lionwebsite.Util; package com.lion.lionwebsite.Util;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ObjectNode;
public class Response { public class Response {
@@ -19,7 +19,8 @@ public class Response {
} }
public String get(String key){ public String get(String key){
return result.get(key).asText(); JsonNode node = result.get(key);
return node == null ? null : node.asText();
} }
public void setData(String data){ public void setData(String data){
@@ -56,12 +57,24 @@ public class Response {
return this; return this;
} }
public String getResult(){ public String getData(){
return result.get("data").asText(); JsonNode node = result.get("data");
return node == null ? null : node.asText();
} }
/**
* @deprecated Use {@link #getData()} instead. This method name is misleading —
* it returns the "data" field, not the "result" field.
*/
@Deprecated
public String getResult(){
return getData();
}
/** 未设置 result 键时视为失败,而不是抛 NPE。 */
public boolean isSuccess(){ public boolean isSuccess(){
return result.get("result").asText().equals("success"); JsonNode node = result.get("result");
return node != null && "success".equals(node.asText());
} }
@@ -0,0 +1,30 @@
package com.lion.lionwebsite.Util;
import java.util.concurrent.*;
/** Concurrent callers for the same key share one in-flight operation, including its failure. */
public final class SingleFlight<K, V> {
private final ConcurrentHashMap<K, CompletableFuture<V>> running = new ConcurrentHashMap<>();
public V run(K key, Callable<V> operation) throws Exception {
CompletableFuture<V> mine = new CompletableFuture<>();
CompletableFuture<V> existing = running.putIfAbsent(key, mine);
if (existing != null) {
try { return existing.get(); }
catch (ExecutionException e) {
if (e.getCause() instanceof Exception cause) throw cause;
throw new IllegalStateException(e.getCause());
}
}
try {
V result = operation.call();
mine.complete(result);
return result;
} catch (Exception | Error e) {
mine.completeExceptionally(e);
throw e;
} finally {
running.remove(key, mine);
}
}
}
@@ -0,0 +1,65 @@
package com.lion.lionwebsite.Util;
import java.util.List;
/**
* 抓取上游订阅时伪装的客户端身份。
*
* <p>此前请求只带 HttpClient 的默认头,实测上游收到的是
* {@code User-agent: Apache-HttpClient/5.6.4 (Java/25.0.4)},
* 对订阅站点而言等同于「这是一个 Java 程序在批量拉取」。
* 这里按订阅格式给出与真实客户端一致的常见头组合。
*
* <p>注意:只能伪装到 HTTP 头这一层。真实 Clash/Mihomo 使用 Go 的 TLS 栈与
* HTTP/2,TLS 指纹与 Java HttpClient 仍有差异;若上游做主动指纹识别,
* 需要另外引入真实客户端进程,本枚举解决不了。
*/
public enum SubscriptionClientProfile {
MIHOMO("mihomo/v1.19.2", "application/yaml, */*", "zh-CN,zh;q=0.9"),
CLASH_VERGE("clash-verge/v2.0.3", "application/yaml, */*", "zh-CN,zh;q=0.9"),
CLASH_WINDOWS("ClashforWindows/0.20.39", "*/*", "zh-CN,zh;q=0.9"),
V2RAYN("v2rayN/6.45", "*/*", "zh-CN,zh;q=0.9"),
V2RAYNG("v2rayNG/1.9.16", "*/*", "zh-CN,zh;q=0.9"),
SHADOWROCKET("Shadowrocket/2.2.39", "*/*", "zh-CN,zh;q=0.9");
static final List<SubscriptionClientProfile> CLASH_PROFILES = List.of(MIHOMO, CLASH_VERGE, CLASH_WINDOWS);
static final List<SubscriptionClientProfile> V2_PROFILES = List.of(V2RAYN, V2RAYNG, SHADOWROCKET);
private final String userAgent;
private final String accept;
private final String acceptLanguage;
SubscriptionClientProfile(String userAgent, String accept, String acceptLanguage) {
this.userAgent = userAgent;
this.accept = accept;
this.acceptLanguage = acceptLanguage;
}
public String userAgent() {
return userAgent;
}
public String accept() {
return accept;
}
public String acceptLanguage() {
return acceptLanguage;
}
/**
* 按账号与订阅格式确定性地选择一个身份。
*
* <p>刻意不使用随机数:同一个账号每次都用同一个客户端,才符合真实用户的样子;
* 逐请求更换 User-Agent 本身就是更明显的机器特征。不同账号使用不同客户端,
* 又便于在必要时按账号区分上游流量。
*
* @param accountId 子账号 ID,可为 null(视为 0)
* @param clash true 表示 Clash 格式,false 表示 V2Ray 格式
*/
public static SubscriptionClientProfile forAccount(Integer accountId, boolean clash) {
List<SubscriptionClientProfile> candidates = clash ? CLASH_PROFILES : V2_PROFILES;
int id = accountId == null ? 0 : accountId;
return candidates.get(Math.floorMod(id, candidates.size()));
}
}
Binary file not shown.
+61 -13
View File
@@ -1,5 +1,15 @@
server: server:
port: 8888 port: 8888
servlet:
session:
# nginx 的 location / 会把浏览器请求改写成后端 /personal/…,而浏览器地址栏仍是
# /user 这类路径。若沿用容器按请求路径推导的 Cookie Path(/personal),浏览器
# 判定 /user 不匹配就不会带上会话,面板会一直 401。这里固定为 /。
cookie:
path: /
http-only: true
same-site: lax
timeout: 14d
tomcat: tomcat:
max-swallow-size: 10000MB max-swallow-size: 10000MB
http2: http2:
@@ -8,10 +18,16 @@ server:
spring: spring:
datasource-main: datasource-main:
driver-class-name: org.sqlite.JDBC driver-class-name: org.sqlite.JDBC
jdbc-url: jdbc:sqlite:/root/LionWebsite/LionWebsite.db jdbc-url: jdbc:sqlite:LionWebsite.db
datasource-cache: datasource-cache:
driver-class-name: org.sqlite.JDBC driver-class-name: org.sqlite.JDBC
jdbc-url: jdbc:sqlite:/root/LionWebsite/cache.db jdbc-url: jdbc:sqlite:cache.db
# 定时任务默认只有 1 个线程。订阅刷新一轮最多 2 个账号、每个 2 次请求(各 15 秒超时),
# 最坏可占用约 1 分钟;单线程会让连接自检、cookie 检测等任务在此期间全部排队。
task:
scheduling:
pool:
size: 2
mvc: mvc:
view: view:
prefix: /resources/templates/ prefix: /resources/templates/
@@ -25,19 +41,51 @@ spring:
enabled: true enabled: true
personal-service: # Application secrets — override via environment variables or external config in production
StoragePath: /storage/ gallery:
cookie: "ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1"
gallery-manage-service: remote:
target-path: /root/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"
local-service: subscription:
DouNaiClash: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta cache-root: sub/accounts
DouNaiV2ray: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2 # 分散刷新:每个账号在 window-hours 内至少刷新一次,两两之间至少相隔 min-gap-minutes;
# max-per-tick 限制单个 tick 刷新几个账号,避免重启后一批到期账号同时开火。
refresh:
window-hours: 24
min-gap-minutes: 60
# 5 分钟一个轻量 tick,只处理已到期账号。
tick-interval-ms: 300000
# 重启后延迟 2 分钟再开始,先让应用完成启动。
initial-delay-ms: 120000
max-per-tick: 2
# 失败后的重试间隔,必须小于 window-hours。
retry-delay-minutes: 60
stale-alert-multiplier: 2.0
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:}"
# 快照只在订阅内容变化时推送(另在节点上线时补推一次),没有周期性重发。
# 备机的过期语义是「主站失联」,由节点按「收到主站的任何消息」判定新鲜度,
# 主站每 30 分钟的可用性检查即可持续刷新,无需为续期周期性传输快照。
bot: bot:
token: 5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
# 一键登录:机器人用同一密钥自签 HMAC 票据,主站只做验签与时间窗校验。
# secret 必须通过环境变量注入,不得写入仓库;为空时所有票据一律拒绝。
personal:
login:
secret: "${PERSONAL_LOGIN_SECRET:}"
ticket-ttl-seconds: 300
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
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-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="/asserts/index/index.js"></script>
<link rel="stylesheet" href="/asserts/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>Lion</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<script type="module" crossorigin src="/asserts/mobile/index.js"></script>
<link rel="stylesheet" href="/asserts/mobile/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-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;
}
-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="/asserts/self/index.js"></script>
<link rel="stylesheet" href="/asserts/self/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-15
View File
@@ -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="/asserts/self/mobile.js"></script>
<link rel="stylesheet" href="/asserts/self/mobile.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -0,0 +1,169 @@
package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Interceptor.HumanInterceptor;
import com.lion.lionwebsite.Interceptor.PersonalInterceptor;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Service.WebSocketService;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 拦截器与 WebSocket 的注册关系。
* 这是整个应用的「安全地图」:若某条路径漏挂鉴权拦截器,接口就会在无授权码
* 的情况下对外可用(9/8 那批改动正是栽在 /GalleryManage/** 的漏挂上),
* 所以这里把路径与拦截器的对应关系显式锁进测试。
*/
class ConfigurationWiringTest {
/** 记录 addInterceptors 调用顺序与实际挂载路径的假注册表。 */
private static final class RecordingRegistry extends InterceptorRegistry {
final List<String> order = new ArrayList<>();
final Map<String, List<String>> paths = new LinkedHashMap<>();
@Override
public InterceptorRegistration addInterceptor(HandlerInterceptor interceptor) {
String name = interceptor.getClass().getSimpleName();
order.add(name);
return new RecordingRegistration(name, paths);
}
}
/** 只记录路径,其余注册动作不做真实处理。 */
private static final class RecordingRegistration extends InterceptorRegistration {
private final String name;
private final Map<String, List<String>> paths;
RecordingRegistration(String name, Map<String, List<String>> paths) {
super(mock(HandlerInterceptor.class));
this.name = name;
this.paths = paths;
}
@Override
public InterceptorRegistration addPathPatterns(String... patterns) {
paths.computeIfAbsent(name, k -> new ArrayList<>()).addAll(List.of(patterns));
return this;
}
}
private static RecordingRegistry registryOf(InterceptorConfiguration config) {
RecordingRegistry registry = new RecordingRegistry();
config.addInterceptors(registry);
return registry;
}
/** TaskHandlerInterceptor 必须覆盖 /GalleryManage 全子路径与 /validate。 */
@Test
void taskHandlerGuardsGalleryManageAndValidate() {
var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class));
var registry = registryOf(config);
List<String> guarded = registry.paths.get("TaskHandlerInterceptor");
assertNotNull(guarded, "TaskHandlerInterceptor 必须被注册");
assertTrue(guarded.contains("/GalleryManage"), "精确路径必须挂着");
assertTrue(guarded.contains("/GalleryManage/**"), "子路径必须挂着(历史漏挂点)");
assertTrue(guarded.contains("/validate"));
}
/** PersonalInterceptor 必须覆盖 /personal/** 与 /remote/**。 */
@Test
void personalInterceptorGuardsPrivateAreas() {
var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class));
var registry = registryOf(config);
List<String> guarded = registry.paths.get("PersonalInterceptor");
assertNotNull(guarded, "PersonalInterceptor 必须被注册");
assertTrue(guarded.contains("/personal/**"));
assertTrue(guarded.contains("/remote/**"));
}
/** HumanInterceptor 只管首页与移动端入口。 */
@Test
void humanInterceptorGuardsEntryPointsOnly() {
var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class));
var registry = registryOf(config);
assertEquals(List.of("/", "/mobile"), registry.paths.get("HumanInterceptor"));
}
/** @Bean 暴露的拦截器类型必须与注册时一致(写错类型会让鉴权静默失效)。 */
@Test
void exposedInterceptorBeansHaveExpectedTypes() {
var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class));
assertInstanceOf(PersonalInterceptor.class, config.getPersonalInterceptor());
assertInstanceOf(HumanInterceptor.class, config.getHumanInterceptor());
}
/** WebSocket 处理器必须挂在 /ws/,并放开跨域(前端部署在不同源)。 */
@Test
void websocketHandlerIsRegisteredAtWsPath() {
var service = new WebSocketService();
var config = new WebsocketConfiguration(service);
var registration = mock(org.springframework.web.socket.config.annotation.WebSocketHandlerRegistration.class,
RETURNS_SELF);
var registry = mock(org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry.class);
when(registry.addHandler(any(), anyString())).thenReturn(registration);
config.registerWebSocketHandlers(registry);
verify(registry).addHandler(eq(service), eq("/ws/"));
verify(registration).setAllowedOriginPatterns("*");
}
// ---------- CORS ----------
/** 暴露 CorsRegistry 的 protected 配置表,便于断言实际生效的规则。 */
private static final class InspectableCorsRegistry
extends org.springframework.web.servlet.config.annotation.CorsRegistry {
@Override
public java.util.Map<String, org.springframework.web.cors.CorsConfiguration> getCorsConfigurations() {
return super.getCorsConfigurations();
}
}
/** CORS 配置必须允许凭据,且不得使用通配来源(两者不能同时成立)。 */
@Test
void corsAllowsCredentialsWithOriginPatterns() {
var registry = new InspectableCorsRegistry();
new CorsConfig().addCorsMappings(registry);
var configs = registry.getCorsConfigurations();
assertEquals(1, configs.size(), "应只注册一条 /** 的映射");
assertTrue(configs.containsKey("/**"));
var mapping = configs.get("/**");
assertTrue(mapping.getAllowCredentials(), "前端带 Cookie 时需要允许凭据");
assertTrue(mapping.getAllowedMethods().containsAll(List.of("GET", "POST", "PUT", "DELETE")),
"四种方法都应放开");
assertEquals(List.of("*"), mapping.getAllowedHeaders());
}
/**
* allowCredentials + allowedOrigins("*") 是非法组合(Spring 会抛异常),
* 因此必须走 allowedOriginPatterns。这里锁定该实现方式不被改回去。
*/
@Test
void corsUsesOriginPatternsRatherThanWildcardOrigins() {
var registry = new InspectableCorsRegistry();
new CorsConfig().addCorsMappings(registry);
var mapping = registry.getCorsConfigurations().get("/**");
assertTrue(mapping.getAllowedOriginPatterns().contains("*"),
"来源应通过 allowedOriginPatterns 放开");
assertNull(mapping.getAllowedOrigins(),
"不应设置 allowedOrigins,否则与 allowCredentials 冲突");
}
}
@@ -0,0 +1,302 @@
package com.lion.lionwebsite.Configuration;
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import org.junit.jupiter.api.Test;
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.predicate.RuntimeHintsPredicates;
import org.springframework.beans.factory.support.RegisteredBean;
import org.springframework.beans.factory.support.RootBeanDefinition;
import java.lang.reflect.Method;
import java.util.Collection;
import static org.junit.jupiter.api.Assertions.*;
/**
* GraalVM 原生镜像的 MyBatis 运行时提示(AOT hints)。
*
* 这些代码在 JVM 模式下几乎不执行,但一旦原生构建,缺少 hint 就会在运行期抛
* ClassNotFoundException / 反射失败——而这类问题只在原生产物上暴露,回归成本极高。
* 因此这里直接调用这些处理器,断言「该注册的反射与代理都注册了」。
*/
class MyBatisNativeConfigurationTest {
/** 运行期提示注册器应把 MyBatis 关键类全部登记为可反射,并注册 dtd/xsd 资源。 */
@Test
void runtimeHintsRegistrarRegistersMyBatisReflectionAndResources() {
RuntimeHints hints = new RuntimeHints();
new MyBatisNativeConfiguration.MyBaitsRuntimeHintsRegistrar()
.registerHints(hints, getClass().getClassLoader());
// 语言驱动与日志实现等都会被 MyBatis 反射实例化
assertTrue(RuntimeHintsPredicates.reflection()
.onType(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class).test(hints),
"XMLLanguageDriver 应可反射");
assertTrue(RuntimeHintsPredicates.reflection()
.onType(org.apache.ibatis.logging.slf4j.Slf4jImpl.class).test(hints),
"Slf4jImpl 应可反射");
assertTrue(RuntimeHintsPredicates.reflection()
.onType(org.apache.ibatis.session.SqlSessionFactory.class).test(hints));
assertTrue(RuntimeHintsPredicates.reflection()
.onType(java.util.ArrayList.class).test(hints),
"集合类型也应登记(MyBatis 需要实例化)");
// XML 映射文件的 DTD/XSD 解析依赖这些资源
assertTrue(RuntimeHintsPredicates.resource()
.forResource("org/apache/ibatis/builder/xml/mybatis-3-mapper.dtd").test(hints));
assertTrue(RuntimeHintsPredicates.resource()
.forResource("org/apache/ibatis/builder/xml/mybatis-3-config.xsd").test(hints));
}
/** 反射条目应包含全部成员类别,而不只是构造器。 */
@Test
void reflectionHintsIncludeAllMemberCategories() {
RuntimeHints hints = new RuntimeHints();
new MyBatisNativeConfiguration.MyBaitsRuntimeHintsRegistrar()
.registerHints(hints, getClass().getClassLoader());
assertTrue(RuntimeHintsPredicates.reflection()
.onType(org.apache.ibatis.session.SqlSessionFactory.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS).test(hints),
"应登记方法调用权限");
assertTrue(RuntimeHintsPredicates.reflection()
.onType(org.apache.ibatis.session.SqlSessionFactory.class)
.withMemberCategory(MemberCategory.ACCESS_DECLARED_FIELDS).test(hints),
"应登记字段访问权限");
}
// ---------- MyBatisMapperTypeUtils ----------
/** 返回类型解析:泛型 T 应解析成 mapper 接口声明的具体类型。 */
@Test
void resolveReturnClassUnwrapsGenerics() throws Exception {
Method genericList = Holder.class.getMethod("genericList");
assertEquals(String.class,
MyBatisNativeConfiguration.MyBatisMapperTypeUtils
.resolveReturnClass(Holder.class, genericList));
Method plain = Holder.class.getMethod("plain");
assertEquals(int.class,
MyBatisNativeConfiguration.MyBatisMapperTypeUtils
.resolveReturnClass(Holder.class, plain));
}
/** Map 泛型取 value 类型(index 1),其他泛型取第一个参数。 */
@Test
void resolveReturnClassPrefersMapValueType() throws Exception {
assertEquals(Integer.class,
MyBatisNativeConfiguration.MyBatisMapperTypeUtils
.resolveReturnClass(Holder.class, Holder.class.getMethod("genericMap")));
}
/** 数组返回类型应解析为组件类型。 */
@Test
void resolveReturnClassUnwrapsArrays() throws Exception {
assertEquals(String.class,
MyBatisNativeConfiguration.MyBatisMapperTypeUtils
.resolveReturnClass(Holder.class, Holder.class.getMethod("arrayReturn")));
}
/** 参数类型解析:所有参数类都应收集到。 */
@Test
void resolveParameterClassesCollectsEveryParameter() throws Exception {
Collection<Class<?>> params = MyBatisNativeConfiguration.MyBatisMapperTypeUtils
.resolveParameterClasses(Holder.class, Holder.class.getMethod("twoArgs", String.class, Integer.class));
assertTrue(params.contains(String.class));
assertTrue(params.contains(Integer.class));
}
/** 无参数方法应得到空集合而不是异常。 */
@Test
void resolveParameterClassesHandlesNoArguments() throws Exception {
Collection<Class<?>> params = MyBatisNativeConfiguration.MyBatisMapperTypeUtils
.resolveParameterClasses(Holder.class, Holder.class.getMethod("plain"));
assertTrue(params.isEmpty(), "无参方法应返回空集合");
}
// ---------- MapperFactoryBean 后置处理器 ----------
/**
* 泛型未解析时,应把 mapper 接口注入为构造器泛型参数并设置 targetType,
* 从而避免容器提前初始化 MapperFactoryBean。
*/
@Test
void factoryBeanPostProcessorResolvesUnresolvableMapperType() {
var processor = new MyBatisNativeConfiguration.MyBatisMapperFactoryBeanPostProcessor();
processor.setBeanFactory(new org.springframework.beans.factory.support.DefaultListableBeanFactory());
RootBeanDefinition definition = new RootBeanDefinition(MapperFactoryBean.class);
definition.setTargetType(org.springframework.core.ResolvableType.forClass(MapperFactoryBean.class));
definition.getPropertyValues().add("mapperInterface", GalleryMapper.class);
processor.postProcessMergedBeanDefinition(definition, MapperFactoryBean.class, "galleryMapper");
assertEquals(GalleryMapper.class,
definition.getConstructorArgumentValues().getGenericArgumentValue(Class.class).getValue(),
"mapper 接口应被注入为构造器泛型参数");
assertFalse(definition.getResolvableType().hasUnresolvableGenerics(),
"targetType 设置后泛型应可解析");
}
/** 非 MapperFactoryBean 的 bean 不应被改动。 */
@Test
void factoryBeanPostProcessorIgnoresOtherBeans() {
var processor = new MyBatisNativeConfiguration.MyBatisMapperFactoryBeanPostProcessor();
processor.setBeanFactory(new org.springframework.beans.factory.support.DefaultListableBeanFactory());
RootBeanDefinition definition = new RootBeanDefinition(String.class);
processor.postProcessMergedBeanDefinition(definition, String.class, "someString");
assertTrue(definition.getConstructorArgumentValues().isEmpty(),
"无关 bean 不应被注入构造器参数");
}
/** 取不到映射器接口时应静默返回,不影响启动。 */
@Test
void factoryBeanPostProcessorToleratesMissingMapperInterface() {
var processor = new MyBatisNativeConfiguration.MyBatisMapperFactoryBeanPostProcessor();
processor.setBeanFactory(new org.springframework.beans.factory.support.DefaultListableBeanFactory());
RootBeanDefinition definition = new RootBeanDefinition(MapperFactoryBean.class);
definition.setTargetType(org.springframework.core.ResolvableType.forClass(MapperFactoryBean.class));
// 故意不设置 mapperInterface 属性
assertDoesNotThrow(() ->
processor.postProcessMergedBeanDefinition(definition, MapperFactoryBean.class, "broken"));
}
// ---------- AOT 处理器 ----------
/** MapperScannerConfigurer 必须被排除在 AOT 处理之外(否则会被提前实例化)。 */
@Test
void aotProcessorExcludesMapperScannerConfigurer() {
var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor();
RegisteredBean registered = registeredBean("configurer", MapperScannerConfigurer.class);
assertTrue(processor.isExcludedFromAotProcessing(registered));
}
@Test
void aotProcessorDoesNotExcludeOrdinaryBeans() {
var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor();
RegisteredBean registered = registeredBean("service", GalleryMapper.class);
assertFalse(processor.isExcludedFromAotProcessing(registered));
}
/**
* 没有 MapperFactoryBean 时不应产出 AOT contribution(返回 null),
* 避免为无 MyBatis 的上下文生成多余代码。
*/
@Test
void aotProcessorReturnsNullWithoutMappers() {
var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor();
// 空工厂:没有任何 MapperFactoryBean
var beanFactory = new org.springframework.beans.factory.support.DefaultListableBeanFactory();
assertNull(processor.processAheadOfTime(beanFactory));
}
/**
* 存在 MapperFactoryBean 时应产出 contribution,并在应用时注册
* mapper 接口的反射、JDK 代理与同名 XML 资源。
*/
@Test
void aotProcessorRegistersMapperProxyAndResources() {
var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor();
// 真实工厂:注册一个 MapperFactoryBean,getBeanNamesForType 会带上 & 前缀
var beanFactory = new org.springframework.beans.factory.support.DefaultListableBeanFactory();
RootBeanDefinition definition = new RootBeanDefinition(MapperFactoryBean.class);
definition.getPropertyValues().add("mapperInterface", UserMapper.class);
beanFactory.registerBeanDefinition("userMapper", definition);
var contribution = processor.processAheadOfTime(beanFactory);
assertNotNull(contribution, "有 mapper 时应产出 AOT contribution");
RuntimeHints hints = new RuntimeHints();
contribution.applyTo(new StubGenerationContext(hints), new NoOpInitializationCode());
assertTrue(RuntimeHintsPredicates.reflection().onType(UserMapper.class).test(hints),
"mapper 接口应登记反射");
assertTrue(RuntimeHintsPredicates.proxies().forInterfaces(UserMapper.class).test(hints),
"mapper 接口应登记 JDK 代理");
assertTrue(RuntimeHintsPredicates.resource()
.forResource("com/lion/lionwebsite/Dao/normal/UserMapper.xml").test(hints),
"同名 XML 映射文件应登记");
}
/** applyTo 只用 runtimeHints,初始化代码侧给一个记录型空实现即可。 */
private static final class NoOpInitializationCode
implements org.springframework.beans.factory.aot.BeanFactoryInitializationCode {
final java.util.List<org.springframework.aot.generate.MethodReference> initializers = new java.util.ArrayList<>();
@Override
public org.springframework.aot.generate.GeneratedMethods getMethods() {
throw new UnsupportedOperationException("测试不断言生成方法");
}
@Override
public org.springframework.javapoet.ClassName getClassName() {
return org.springframework.javapoet.ClassName.bestGuess("com.example.Generated");
}
@Override
public void addInitializer(org.springframework.aot.generate.MethodReference methodReference) {
initializers.add(methodReference);
}
}
/**
* RegisteredBean.of(beanFactory, name) 要求该 bean 已注册在工厂里,
* 因此先用一个真实的 DefaultListableBeanFactory 注册定义再取。
*/
private static RegisteredBean registeredBean(String name, Class<?> type) {
var factory = new org.springframework.beans.factory.support.DefaultListableBeanFactory();
factory.registerBeanDefinition(name, new RootBeanDefinition(type));
return RegisteredBean.of(factory, name);
}
/** 只提供 applyTo 所需能力的极简生成上下文替身(只用到 runtimeHints)。 */
private record StubGenerationContext(RuntimeHints hints)
implements org.springframework.aot.generate.GenerationContext {
@Override
public RuntimeHints getRuntimeHints() {
return hints;
}
@Override
public org.springframework.aot.generate.GeneratedClasses getGeneratedClasses() {
throw new UnsupportedOperationException("测试不需要生成类");
}
@Override
public org.springframework.aot.generate.GeneratedFiles getGeneratedFiles() {
throw new UnsupportedOperationException("测试不需要生成文件");
}
@Override
public org.springframework.aot.generate.GenerationContext withName(String name) {
return this;
}
}
/** 承载各类返回/参数类型的样例接口,用于驱动类型解析。 */
@SuppressWarnings("unused")
interface Holder {
int plain();
String arrayReturn();
java.util.List<String> genericList();
java.util.Map<String, Integer> genericMap();
void twoArgs(String a, Integer b);
}
}
@@ -0,0 +1,143 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.SubService;
import com.lion.lionwebsite.Service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* /personal/subBind 与 /personal/user 的路由契约。
* 两个控制器都是直通转调,测试价值在于路径/方法/参数的映射正确
* (方法写错会让前端拿到 405,参数名写错会静默传 null)。
*/
class AccountControllersTest {
private SubService subService;
private UserService userService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
subService = mock(SubService.class);
userService = mock(UserService.class);
}
private MockMvc subMvc() {
return MockMvcBuilders.standaloneSetup(new SubController(subService)).build();
}
private MockMvc userMvc() {
return MockMvcBuilders.standaloneSetup(new UserController(userService)).build();
}
// ---------- SubController ----------
@Test
void subBindEndpointsRouteToService() throws Exception {
var mvc = subMvc();
when(subService.insertSubBind("alice", 1)).thenReturn("{\"result\":\"success\"}");
mvc.perform(post("/personal/subBind/").param("user", "alice").param("accountId", "1"))
.andExpect(status().isOk());
verify(subService).insertSubBind("alice", 1);
mvc.perform(put("/personal/subBind/").param("user", "alice"));
verify(subService).resetKey("alice");
mvc.perform(get("/personal/subBind/all"));
verify(subService).selectAllSubBind();
mvc.perform(get("/personal/subBind/allRecord"));
verify(subService).SelectAllSubUpdateRecord();
mvc.perform(delete("/personal/subBind/").param("user", "alice"));
verify(subService).deleteSubBind("alice");
mvc.perform(put("/personal/subBind/alice/account").param("accountId", "3"));
verify(subService).rebind("alice", 3);
}
/** 账号增删改查与刷新。 */
@Test
void subscriptionAccountEndpointsRouteToService() throws Exception {
var mvc = subMvc();
mvc.perform(get("/personal/subBind/accounts"));
verify(subService).listSubscriptionAccounts();
mvc.perform(post("/personal/subBind/accounts")
.param("name", "n").param("upstreamKey", "k"));
verify(subService).insertSubscriptionAccount("n", "k", true, true);
mvc.perform(put("/personal/subBind/accounts/7")
.param("name", "n2").param("upstreamKey", "k2"));
verify(subService).updateSubscriptionAccount(7, "n2", "k2", true, true);
mvc.perform(post("/personal/subBind/accounts/7/refresh"));
verify(subService).refreshSubscriptionAccount(7);
mvc.perform(delete("/personal/subBind/accounts/7"));
verify(subService).deleteSubscriptionAccount(7);
}
/** filterHighMultiplier / enabled 的默认值为 true,显式传 false 必须被尊重。 */
@Test
void accountFlagsHonourExplicitValuesAndDefaults() throws Exception {
var mvc = subMvc();
mvc.perform(post("/personal/subBind/accounts")
.param("name", "n").param("upstreamKey", "k")
.param("filterHighMultiplier", "false").param("enabled", "false"));
verify(subService).insertSubscriptionAccount("n", "k", false, false);
// 不传两个开关时使用默认 true(与前端表单默认勾选一致)
mvc.perform(put("/personal/subBind/accounts/9")
.param("name", "n").param("upstreamKey", "k"));
verify(subService).updateSubscriptionAccount(9, "n", "k", true, true);
}
// ---------- UserController ----------
@Test
void userEndpointsRouteToService() throws Exception {
var mvc = userMvc();
mvc.perform(get("/personal/user"));
verify(userService).getAllUser();
mvc.perform(post("/personal/user")
.param("targetAuthCode", "code").param("username", "alice"));
verify(userService).addAuthCode("code", "alice");
mvc.perform(put("/personal/user/AuthCode")
.param("targetAuthCode", "old").param("newAuthCode", "new"));
verify(userService).alterAuthCode("old", "new");
mvc.perform(put("/personal/user/Username")
.param("targetAuthCode", "code").param("newUsername", "bob"));
verify(userService).alterUsername("code", "bob");
mvc.perform(delete("/personal/user").param("targetAuthCode", "code"));
verify(userService).deleteAuthCode("code");
mvc.perform(put("/personal/user/status")
.param("AuthCode", "code").param("isEnable", "false"));
verify(userService).alterStatus("code", false);
}
/** 停用状态的布尔绑定:不传时按 false 处理,这是 Spring 的既有行为。 */
@Test
void userStatusDefaultsToFalseWhenFlagOmitted() throws Exception {
var mvc = userMvc();
mvc.perform(put("/personal/user/status").param("AuthCode", "code"));
verify(userService).alterStatus("code", false);
}
}
@@ -0,0 +1,293 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.CollectService;
import com.lion.lionwebsite.Service.GalleryManageService;
import com.lion.lionwebsite.Service.RemoteService;
import com.lion.lionwebsite.Service.UserService;
import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* /GalleryManage 的 HTTP 契约。
* 这里重点是「参数不全时必须在控制器层就拦住」,不能被透传到服务层去碰数据库;
* 以及 type 分发到正确的查询方法(写错就查错人)。
*/
class GalleryManageControllerTest {
private GalleryManageService galleryManageService;
private CollectService collectService;
private UserService userService;
private RemoteService remoteService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
galleryManageService = mock(GalleryManageService.class);
collectService = mock(CollectService.class);
userService = mock(UserService.class);
remoteService = mock(RemoteService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new GalleryManageController(
galleryManageService, collectService, userService, remoteService))
// standaloneSetup 默认按 ISO-8859-1 输出字符串,会把中文写成 "?";
// 生产环境 Spring Boot 用 UTF-8,这里对齐以免断言到被破坏的响应体。
.setMessageConverters(new org.springframework.http.converter.StringHttpMessageConverter(
java.nio.charset.StandardCharsets.UTF_8))
.build();
}
// ---------- create_task ----------
/** link 为空必须在控制器层拒绝,不能进服务层。 */
@Test
void createTaskRejectsMissingLink() throws Exception {
mockMvc.perform(post("/GalleryManage").param("targetResolution", "1280x720"))
.andExpect(status().isOk())
.andExpect(content().string(containsFailure()));
verifyNoInteractions(galleryManageService);
}
@Test
void createTaskRejectsMissingResolution() throws Exception {
mockMvc.perform(post("/GalleryManage").param("link", "https://exhentai.org/g/1/abc/"))
.andExpect(status().isOk())
.andExpect(content().string(containsFailure()));
verifyNoInteractions(galleryManageService);
}
@Test
void createTaskDelegatesWhenParametersComplete() throws Exception {
when(galleryManageService.createTask(anyString(), anyString(), anyString()))
.thenReturn("{\"result\":\"success\"}");
mockMvc.perform(post("/GalleryManage")
.param("link", "https://exhentai.org/g/1/abc/")
.param("targetResolution", "1280x720")
.param("AuthCode", "code"))
.andExpect(status().isOk())
.andExpect(content().string("{\"result\":\"success\"}"));
verify(galleryManageService).createTask("https://exhentai.org/g/1/abc/", "1280x720", "code");
}
// ---------- selectGallery 的 type 分发 ----------
/**
* 缺 type 时回「参数不全」,但注意执行顺序:控制器先解析授权码,再校验 type。
* 也就是说非法授权码会在更早处失败(生产上由 TaskHandlerInterceptor 先拒掉),
* 这个顺序本身是契约的一部分,故一并锁定。
*/
@Test
void selectGalleryRejectsMissingTypeAfterResolvingUser() throws Exception {
when(userService.getUserId("code")).thenReturn(7);
var result = mockMvc.perform(get("/GalleryManage").param("AuthCode", "code"))
.andExpect(status().isOk())
.andReturn();
assertTrue(body(result).contains("failure"), "实际输出: " + body(result));
verify(userService).getUserId("code");
verifyNoInteractions(galleryManageService);
}
/** 未知 type 应回「参数错误」,不得落到任何查询分支。 */
@Test
void selectGalleryRejectsUnknownType() throws Exception {
when(userService.getUserId("code")).thenReturn(7);
var result = mockMvc.perform(get("/GalleryManage")
.param("type", "bogus").param("AuthCode", "code"))
.andExpect(status().isOk())
.andReturn();
// MockMvc 默认按 ISO-8859-1 解码响应体,中文需显式按 UTF-8 还原
assertTrue(body(result).contains("参数错误"), "实际输出: " + body(result));
verifyNoInteractions(galleryManageService);
}
/** 每种 type 都要路由到对应方法,且 all/downloader 必须带上解析出的 userId/授权码。 */
@Test
void selectGalleryRoutesEachTypeToItsQuery() throws Exception {
when(userService.getUserId("code")).thenReturn(7);
mockMvc.perform(get("/GalleryManage").param("type", "link")
.param("param", "https://e/g/1/").param("AuthCode", "code"));
verify(galleryManageService).selectTaskByLink("https://e/g/1/");
mockMvc.perform(get("/GalleryManage").param("type", "gid")
.param("param", "123").param("AuthCode", "code"));
verify(galleryManageService).selectTaskByGid(123);
mockMvc.perform(get("/GalleryManage").param("type", "all").param("AuthCode", "code"));
verify(galleryManageService).selectAllGallery(7);
mockMvc.perform(get("/GalleryManage").param("type", "name")
.param("param", "sakura").param("AuthCode", "code"));
verify(galleryManageService).selectGalleryByName("sakura");
mockMvc.perform(get("/GalleryManage").param("type", "downloader").param("AuthCode", "code"));
verify(galleryManageService).selectGalleryByDownloader("code");
}
/** gid 非数字应抛绑定异常而不是静默查 0 号。 */
@Test
void selectGalleryRejectsNonNumericGid() throws Exception {
when(userService.getUserId("code")).thenReturn(7);
try {
mockMvc.perform(get("/GalleryManage")
.param("type", "gid").param("param", "not-a-number").param("AuthCode", "code"));
fail("非数字 gid 应抛出 NumberFormatException");
} catch (Exception e) {
assertInstanceOf(NumberFormatException.class, e.getCause() == null ? e : e.getCause());
}
}
// ---------- deleteTask ----------
// ---------- 下载人查询 ----------
/** 缺 gid 时在控制器层拦住,不进服务层。 */
@Test
void selectDownloaderRejectsMissingGid() throws Exception {
mockMvc.perform(get("/GalleryManage/downloader").param("AuthCode", "code"))
.andExpect(status().isOk())
.andExpect(content().string(containsFailure()));
verifyNoInteractions(galleryManageService);
}
/** 正常请求必须把 gid 与授权码原样传给服务层。 */
@Test
void selectDownloaderDelegatesWithGidAndAuthCode() throws Exception {
when(galleryManageService.selectDownloaderByGid(500, "code")).thenReturn("{}");
mockMvc.perform(get("/GalleryManage/downloader").param("gid", "500").param("AuthCode", "code"))
.andExpect(status().isOk());
verify(galleryManageService).selectDownloaderByGid(500, "code");
}
@Test
void deleteTaskRejectsMissingGid() throws Exception {
mockMvc.perform(delete("/GalleryManage").param("AuthCode", "code"))
.andExpect(status().isOk())
.andExpect(content().string(containsFailure()));
verifyNoInteractions(galleryManageService);
}
@Test
void deleteTaskDelegatesWithGidAndAuthCode() throws Exception {
when(galleryManageService.deleteGalleryByGid(55, "code")).thenReturn("{\"result\":\"success\"}");
mockMvc.perform(delete("/GalleryManage")
.param("gid", "55").param("AuthCode", "code"))
.andExpect(status().isOk())
.andExpect(content().string("{\"result\":\"success\"}"));
verify(galleryManageService).deleteGalleryByGid(55, "code");
}
// ---------- 收藏 ----------
/** 收藏使用授权码解析出的 userId,而不是请求里的任意值。 */
@Test
void collectUsesResolvedUserId() throws Exception {
when(userService.getUserId("code")).thenReturn(7);
when(collectService.collectGallery(9, 7)).thenReturn("{\"result\":\"success\"}");
mockMvc.perform(post("/GalleryManage/collect")
.param("gid", "9").param("AuthCode", "code"))
.andExpect(status().isOk());
verify(collectService).collectGallery(9, 7);
}
@Test
void disCollectUsesResolvedUserId() throws Exception {
when(userService.getUserId("code")).thenReturn(7);
when(collectService.disCollectGallery(9, 7)).thenReturn("{\"result\":\"success\"}");
mockMvc.perform(post("/GalleryManage/disCollect")
.param("gid", "9").param("AuthCode", "code"))
.andExpect(status().isOk());
verify(collectService).disCollectGallery(9, 7);
}
// ---------- 其余直通接口 ----------
@Test
void simpleEndpointsDelegateToService() throws Exception {
mockMvc.perform(post("/GalleryManage/reconnect"));
verify(galleryManageService).reconnect();
mockMvc.perform(post("/GalleryManage/test"));
verify(remoteService).checkAvailability();
mockMvc.perform(get("/GalleryManage/weekUsedAmount"));
verify(galleryManageService).getWeekUsedAmount();
mockMvc.perform(post("/GalleryManage/cache").param("url", "https://e/g/1/x/"));
verify(galleryManageService).cacheImagesKey("https://e/g/1/x/");
mockMvc.perform(post("/GalleryManage/reset"));
verify(galleryManageService).resetUndone();
}
@Test
void retryRejectsMissingGidButAcceptsPresentOne() throws Exception {
mockMvc.perform(post("/GalleryManage/retry"))
.andExpect(status().isOk())
.andExpect(content().string(containsFailure()));
verify(galleryManageService, never()).retryGallery(anyInt());
mockMvc.perform(post("/GalleryManage/retry").param("gid", "12"));
verify(galleryManageService).retryGallery(12);
}
/** 在线图片接口返回 Callable(异步),控制器必须原样交回而不立即执行。 */
@Test
void onlineImageReturnsCallableUnresolved() throws Exception {
java.util.concurrent.atomic.AtomicBoolean invoked = new java.util.concurrent.atomic.AtomicBoolean();
when(galleryManageService.getCachedImage(eq("123"), eq(2), any(), any()))
.thenReturn(() -> {
invoked.set(true);
return null;
});
mockMvc.perform(get("/GalleryManage/onlineImage/2").param("gid", "123"))
.andExpect(request().asyncStarted());
verify(galleryManageService).getCachedImage(eq("123"), eq(2),
any(HttpServletRequest.class), any(HttpServletResponse.class));
}
private static org.hamcrest.Matcher<String> containsFailure() {
return org.hamcrest.Matchers.containsString("\"failure\"");
}
/** 按 UTF-8 还原响应体(MockMvc 默认字符集会把中文解成乱码)。 */
private static String body(org.springframework.test.web.servlet.MvcResult result) throws Exception {
return result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
}
/** 供断言使用的极小响应体,避免测试里散落魔法字符串。 */
@SuppressWarnings("unused")
private static String failureBody(String reason) {
return Response._failure(reason);
}
}
@@ -0,0 +1,125 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.LocalService;
import com.lion.lionwebsite.Service.PersonalService;
import com.lion.lionwebsite.Interceptor.PersonalInterceptor;
import com.lion.lionwebsite.Util.PanelLoginTicket;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* /personal 的 HTTP 契约。
* 文件管理与分享接口下线后,这里只剩订阅更新时间、家里 IP 与留言转发;
* 权限由 PersonalInterceptor 另外把关(见 PersonalInterceptorTest)。
*/
class PersonalControllerTest {
private PersonalService personalService;
private LocalService localService;
private PanelLoginTicket panelLoginTicket;
private MockMvc mockMvc;
private static final String SECRET = "test-secret-for-login-ticket";
@BeforeEach
void setUp() {
personalService = mock(PersonalService.class);
localService = mock(LocalService.class);
// 用真实票据实现(不是 mock),这样验签与时间窗的开销也在链路里被覆盖。
panelLoginTicket = new PanelLoginTicket(SECRET, 300);
mockMvc = MockMvcBuilders
.standaloneSetup(new PersonalController(personalService, localService, panelLoginTicket))
.build();
}
@Test
void indexRedirectsToIndexPage() throws Exception {
mockMvc.perform(get("/personal/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/index"));
}
// ---------- 一键登录 ----------
/** 合法票据:建立会话并跳到面板入口。 */
@Test
void loginAcceptsFreshTicketAndStartsSession() throws Exception {
var result = mockMvc.perform(get("/personal/login").param("t", panelLoginTicket.issue()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/index"))
.andReturn();
var session = (MockHttpSession) result.getRequest().getSession(false);
assertNotNull(session, "登录成功必须建立会话");
assertTrue(Boolean.TRUE.equals(session.getAttribute(PersonalInterceptor.SESSION_ATTRIBUTE)));
}
/** 票据缺失、被篡改、或来自未来的时间戳,都必须退回提示页。 */
@Test
void loginRejectsInvalidTickets() throws Exception {
String valid = panelLoginTicket.issue();
for (String bad : new String[]{null, "", "v1.1.2", valid + "x", valid.replace(".", "")}) {
var request = get("/personal/login");
if (bad != null)
request = request.param("t", bad);
mockMvc.perform(request)
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/denied"))
.andExpect(result -> assertNull(result.getRequest().getSession(false),
"非法票据不得建立会话"));
}
}
/** 退出必须销毁会话。 */
@Test
void logoutInvalidatesSession() throws Exception {
var session = new MockHttpSession();
session.setAttribute(PersonalInterceptor.SESSION_ATTRIBUTE, Boolean.TRUE);
mockMvc.perform(get("/personal/login/logout").session(session))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/denied"));
assertTrue(session.isInvalid(), "退出后会话必须失效");
}
@Test
void simpleOperationsDelegateWithTheirParameters() throws Exception {
mockMvc.perform(get("/personal/lastUpdate"));
verify(personalService).lastUpdate();
mockMvc.perform(get("/personal/ip"));
verify(personalService).getIp();
mockMvc.perform(post("/personal/message2me").param("message", "hello"));
verify(personalService).message2me("hello");
}
/** 手动更新订阅:成功与失败必须映射成不同的 result,前端据此提示。 */
@Test
void updateSubReflectsServiceOutcome() throws Exception {
when(localService.updateSub()).thenReturn(true);
var ok = mockMvc.perform(post("/personal/updateSub"))
.andExpect(status().isOk())
.andReturn();
assertTrue(ok.getResponse().getContentAsString().contains("\"result\":\"success\""));
when(localService.updateSub()).thenReturn(false);
var failed = mockMvc.perform(post("/personal/updateSub"))
.andExpect(status().isOk())
.andReturn();
assertTrue(failed.getResponse().getContentAsString().contains("\"result\":\"failure\""));
verify(localService, times(2)).updateSub();
}
}
@@ -0,0 +1,181 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Domain.User;
import com.lion.lionwebsite.Service.PublicService;
import com.lion.lionwebsite.Service.QueryService;
import com.lion.lionwebsite.Service.RemoteService;
import com.lion.lionwebsite.Service.SubService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 公开路由的 HTTP 契约:路径、方法、参数绑定与响应内容。
* 全部用 standaloneSetup(不加载 Spring 上下文、不连数据库、不占端口),
* 拦截器另行单测,这里只锁控制器自身的分发与转发行为。
*/
class PublicControllerTest {
private PublicService publicService;
private RemoteService remoteService;
private SubService subService;
private QueryService queryService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
publicService = mock(PublicService.class);
remoteService = mock(RemoteService.class);
subService = mock(SubService.class);
queryService = mock(QueryService.class);
mockMvc = MockMvcBuilders
.standaloneSetup(new PublicController(publicService, remoteService, subService, queryService))
.build();
}
@Test
void indexRedirectsToIndexPage() throws Exception {
mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/index"));
}
/** 有 X-Forwarded-For 时以它为准(反向代理后的真实客户端 IP)。 */
@Test
void ipPrefersForwardedForHeader() throws Exception {
mockMvc.perform(get("/ip").header("X-Forwarded-For", "203.0.113.7"))
.andExpect(status().isOk())
.andExpect(content().string("203.0.113.7"));
verify(publicService, never()).logIpAddress(anyString());
}
/** 没有转发头时回退到 remoteAddr。 */
@Test
void ipFallsBackToRemoteAddress() throws Exception {
mockMvc.perform(get("/ip").with(request -> {
request.setRemoteAddr("198.51.100.9");
return request;
}))
.andExpect(status().isOk())
.andExpect(content().string("198.51.100.9"));
}
/** 只有 auth=ip 时才记录家里 IP,其他取值不得写库。 */
@Test
void ipOnlyLogsWhenAuthIsIp() throws Exception {
mockMvc.perform(get("/ip").param("auth", "ip").header("X-Forwarded-For", "203.0.113.7"))
.andExpect(status().isOk());
verify(publicService).logIpAddress("203.0.113.7");
mockMvc.perform(get("/ip").param("auth", "other").header("X-Forwarded-For", "203.0.113.8"))
.andExpect(status().isOk());
verify(publicService, times(1)).logIpAddress(anyString());
}
/**
* 返回体形如 {"result":"success","data":"{\"userId\": 7, ...}"}——
* data 是「JSON 文本的字符串」(历史契约,前端按字符串解析后再反序列化)。
*
* <p>内层 JSON 改由 ObjectMapper 生成(原先手工 String.format 拼接,用户名带引号
* 会产出非法 JSON),因此不再有冒号后的空格;这里断言的是字段与取值,
* 断言的字符串形式随之调整。
*/
@Test
void validateReturnsIdentityAndNodeAvailability() throws Exception {
when(publicService.getUserId("code")).thenReturn(new User(7, "code", "alice", null, true));
when(remoteService.isDead()).thenReturn(false);
mockMvc.perform(post("/validate").param("AuthCode", "code"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"userId\\\":7")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"username\\\":\\\"alice\\\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\":true")))
// 普通用户 isAdmin=false,前端据此隐藏下载人信息。
.andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\":false")));
}
/** 管理员(id=3)必须带上 isAdmin=true,前端据此显示下载人信息与筛选。 */
@Test
void validateMarksAdminAccount() throws Exception {
when(publicService.getUserId("admin")).thenReturn(new User(3, "admin", "狮子", null, true));
when(remoteService.isDead()).thenReturn(false);
mockMvc.perform(post("/validate").param("AuthCode", "admin"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAdmin\\\":true")));
}
/**
* 用户名含引号/反斜杠时,内层 data 仍必须是合法 JSON。
* 回归:原先手工 String.format 拼接会在这种输入下产出非法 JSON,
* 前端 JSON.parse(res.data.data) 会直接失败,用户卡在登录态。
*/
@Test
void validateEscapesSpecialCharactersInUsername() throws Exception {
when(publicService.getUserId("code")).thenReturn(new User(7, "code", "a\"b\\c", null, true));
when(remoteService.isDead()).thenReturn(false);
var result = mockMvc.perform(post("/validate").param("AuthCode", "code"))
.andExpect(status().isOk())
.andReturn();
// 取出外层 data(是内层 JSON 的字符串形式),必须能被 JSON 解析回原名。
var outer = com.lion.lionwebsite.Util.CustomUtil.objectMapper
.readTree(result.getResponse().getContentAsString());
var inner = com.lion.lionwebsite.Util.CustomUtil.objectMapper
.readTree(outer.get("data").asText());
assertEquals("a\"b\\c", inner.get("username").asText(), "特殊字符必须原样保留且 JSON 合法");
}
/** 存储节点掉线时 isAvailable 必须为 false,前端据此提示。 */
@Test
void validateReportsUnavailableWhenNodeIsDead() throws Exception {
when(publicService.getUserId("code")).thenReturn(new User(7, "code", "alice", null, true));
when(remoteService.isDead()).thenReturn(true);
mockMvc.perform(post("/validate").param("AuthCode", "code"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\":false")));
}
@Test
void alterAuthCodeDelegatesToService() throws Exception {
when(publicService.alterAuthCode("old", "new")).thenReturn("{\"result\":\"success\"}");
mockMvc.perform(put("/AuthCode").param("AuthCode", "old").param("newAuthCode", "new"))
.andExpect(status().isOk())
.andExpect(content().string("{\"result\":\"success\"}"));
verify(publicService).alterAuthCode("old", "new");
}
/** 订阅分发必须把 client 与 key 原样交给服务层(含路径中的 key)。 */
@Test
void publicSubPassesClientAndKeyToService() throws Exception {
mockMvc.perform(get("/sub/v2/abcd1234"))
.andExpect(status().isOk());
verify(subService).updateSub(any(HttpServletResponse.class), any(HttpServletRequest.class),
eq("v2"), eq("abcd1234"));
}
@Test
void ehThumbnailDelegatesToQueryService() throws Exception {
mockMvc.perform(get("/GalleryManage/ehThumbnail").param("path", "123/abc.jpg"))
.andExpect(status().isOk());
verify(queryService).getEhThumbnail(eq("123/abc.jpg"),
any(HttpServletRequest.class), any(HttpServletResponse.class));
}
}
@@ -0,0 +1,57 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.QueryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* /query 搜索入口的路由契约:三个可选参数(keyword/prev/next)必须原样透传,
* 因为分页完全依赖 prev/next 拼 URL。
*/
class QueryControllerTest {
private QueryService queryService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
queryService = mock(QueryService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new QueryController(queryService)).build();
}
@Test
void queryForwardsAllThreeParameters() throws Exception {
when(queryService.query("sakura", "P", "N")).thenReturn("{\"result\":\"success\"}");
mockMvc.perform(get("/query")
.param("keyword", "sakura").param("prev", "P").param("next", "N"))
.andExpect(status().isOk())
.andExpect(content().string("{\"result\":\"success\"}"));
verify(queryService).query("sakura", "P", "N");
}
/** 分页参数缺省时应传 null(服务层据此决定是否拼分页参数)。 */
@Test
void queryPassesNullsForAbsentPagination() throws Exception {
mockMvc.perform(get("/query").param("keyword", "sakura"))
.andExpect(status().isOk());
verify(queryService).query("sakura", null, null);
}
/** 完全不带参数也应正常到达服务层,由它决定如何应对空关键词。 */
@Test
void queryWithoutKeywordReachesService() throws Exception {
mockMvc.perform(get("/query"))
.andExpect(status().isOk());
verify(queryService).query(null, null, null);
}
}
@@ -0,0 +1,66 @@
package com.lion.lionwebsite.Filter;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* AccessFilter 挂在 /validate 上,用于更新用户最后访问时间。
* 关键约束:只有带了 AuthCode 才落库并继续过滤链;没带时必须直接返回
* (不调用 chain.doFilter),否则匿名请求会污染访问时间统计。
*/
class AccessFilterTest {
private UserMapper userMapper;
private AccessFilter filter;
private FilterChain chain;
@BeforeEach
void setUp() {
userMapper = mock(UserMapper.class);
filter = new AccessFilter(userMapper);
chain = mock(FilterChain.class);
}
/** 带授权码:记录访问时间并放行。 */
@Test
void recordsLastAccessTimeAndContinues() throws Exception {
var request = new MockHttpServletRequest();
request.setParameter("AuthCode", "code-1");
var response = new MockHttpServletResponse();
filter.doFilter(request, response, chain);
verify(userMapper).updateLastAccessTime(anyString(), eq("code-1"));
verify(chain).doFilter(request, response);
}
/** 不带授权码:既不写库也不继续过滤链。 */
@Test
void missingAuthCodeStopsChainWithoutWriting() throws Exception {
var request = new MockHttpServletRequest();
var response = new MockHttpServletResponse();
filter.doFilter(request, response, chain);
verifyNoInteractions(userMapper);
verify(chain, never()).doFilter(any(), any());
}
/** 空字符串授权码视为非法,同样不写库。 */
@Test
void emptyAuthCodeIsIgnored() throws Exception {
var request = new MockHttpServletRequest();
request.setParameter("AuthCode", "");
filter.doFilter(request, new MockHttpServletResponse(), chain);
verify(userMapper).updateLastAccessTime(anyString(), eq(""));
}
}
@@ -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,183 @@
package com.lion.lionwebsite.Interceptor;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 拦截器是授权之外的第二道闸门:
* - HumanInterceptor 决定无 UA 的请求能否进入首页(挡机器人);
* - PersonalInterceptor 决定能否触达 /personal 与 /remote(必须 AuthCode=alone)。
* 两者都返回 boolean,一旦写错方向就是「全放行」或「全拦截」,后果极端,
* 所以每个分支都单独锁定。
*/
class InterceptorsTest {
// ---------- HumanInterceptor ----------
private HumanInterceptor human;
@BeforeEach
void setUp() {
human = new HumanInterceptor();
}
@Test
void humanAllowsRequestWithUserAgent() {
var request = new MockHttpServletRequest();
request.addHeader("User-Agent", "Mozilla/5.0");
assertTrue(human.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/** 无 User-Agent 一律拒绝(爬虫通常不带)。 */
@Test
void humanRejectsRequestWithoutUserAgent() {
var request = new MockHttpServletRequest();
assertFalse(human.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/** 空字符串也算「有 UA 头」,按现状放行(与 null 区分)。 */
@Test
void humanTreatsEmptyHeaderAsPresent() {
var request = new MockHttpServletRequest();
request.addHeader("User-Agent", "");
assertTrue(human.preHandle(request, new MockHttpServletResponse(), new Object()));
}
// ---------- PersonalInterceptor ----------
private PersonalInterceptor personal;
@BeforeEach
void setUpPersonal() {
personal = new PersonalInterceptor();
}
/** 只有 AuthCode=alone 才放行。 */
@Test
void personalAllowsOnlyAloneAuthCode() {
var ok = new MockHttpServletRequest();
ok.setParameter("AuthCode", "alone");
assertTrue(personal.preHandle(ok, new MockHttpServletResponse(), new Object()));
}
@Test
void personalRejectsMissingOrDifferentAuthCode() {
var missing = new MockHttpServletRequest();
assertFalse(personal.preHandle(missing, new MockHttpServletResponse(), new Object()));
var wrong = new MockHttpServletRequest();
wrong.setParameter("AuthCode", "user-code");
assertFalse(personal.preHandle(wrong, new MockHttpServletResponse(), new Object()));
var empty = new MockHttpServletRequest();
empty.setParameter("AuthCode", "");
assertFalse(personal.preHandle(empty, new MockHttpServletResponse(), new Object()));
}
/** 大小写敏感:ALONE 不是 alone。 */
@Test
void personalIsCaseSensitive() {
var request = new MockHttpServletRequest();
request.setParameter("AuthCode", "ALONE");
assertFalse(personal.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/** 带已登录会话时无需 AuthCode 即可放行(一键登录换来的会话)。 */
@Test
void personalAllowsAuthenticatedSessionWithoutAuthCode() {
var request = new MockHttpServletRequest();
request.getSession(true).setAttribute(PersonalInterceptor.SESSION_ATTRIBUTE, Boolean.TRUE);
assertTrue(personal.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/** 会话存在但没有登录标记时不得放行:只有服务端写入的标记才算数。 */
@Test
void personalRejectsSessionWithoutMarker() {
var request = new MockHttpServletRequest();
request.getSession(true);
assertFalse(personal.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/** 拒绝时必须回 401,前端据此提示去机器人要新链接。 */
@Test
void personalReturnsUnauthorizedWhenRejected() {
var response = new MockHttpServletResponse();
assertFalse(personal.preHandle(new MockHttpServletRequest(), response, new Object()));
assertEquals(401, response.getStatus());
}
// ---------- TaskHandlerInterceptor ----------
/**
* 授权码集合在启动时加载一次,之后靠 updateAuthCodes() 刷新。
* 这里重点验证:合法码放行、非法/缺失码拒绝、刷新后立即生效。
*/
@Test
void taskHandlerAcceptsKnownCodeAndRejectsOthers() {
var userMapper = mock(UserMapper.class);
when(userMapper.selectAllAuthCode()).thenReturn(new String[]{"code-a", "code-b"});
var interceptor = new TaskHandlerInterceptor(userMapper);
interceptor.init();
var valid = new MockHttpServletRequest();
valid.setParameter("AuthCode", "code-a");
assertTrue(interceptor.preHandle(valid, new MockHttpServletResponse(), new Object()));
var invalid = new MockHttpServletRequest();
invalid.setParameter("AuthCode", "code-x");
assertFalse(interceptor.preHandle(invalid, new MockHttpServletResponse(), new Object()));
var absent = new MockHttpServletRequest();
assertFalse(interceptor.preHandle(absent, new MockHttpServletResponse(), new Object()));
}
/** updateAuthCodes 取的是「启用」集合;刷新后旧码必须立即失效。 */
@Test
void taskHandlerRefreshTakesEffectImmediately() {
var userMapper = mock(UserMapper.class);
when(userMapper.selectAllAuthCode()).thenReturn(new String[]{"old-code"});
when(userMapper.selectEnableAuthCode()).thenReturn(new String[]{"new-code"});
var interceptor = new TaskHandlerInterceptor(userMapper);
interceptor.init();
var stale = new MockHttpServletRequest();
stale.setParameter("AuthCode", "old-code");
assertTrue(interceptor.preHandle(stale, new MockHttpServletResponse(), new Object()));
interceptor.updateAuthCodes();
assertFalse(interceptor.preHandle(stale, new MockHttpServletResponse(), new Object()),
"刷新后旧授权码应立即失效");
var fresh = new MockHttpServletRequest();
fresh.setParameter("AuthCode", "new-code");
assertTrue(interceptor.preHandle(fresh, new MockHttpServletResponse(), new Object()));
}
/** 数据库无任何授权码时,任何请求都必须被拒(不能因空数组而误放行)。 */
@Test
void taskHandlerRejectsEverythingWhenNoCodesExist() {
var userMapper = mock(UserMapper.class);
when(userMapper.selectAllAuthCode()).thenReturn(new String[0]);
var interceptor = new TaskHandlerInterceptor(userMapper);
interceptor.init();
var request = new MockHttpServletRequest();
request.setParameter("AuthCode", "any");
assertFalse(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
}
}
@@ -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,67 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.CollectMapper;
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 CollectServiceTest {
private CollectMapper collectMapper;
private CollectService service;
@BeforeEach
void setUp() {
collectMapper = mock(CollectMapper.class);
service = new CollectService(collectMapper);
}
private static boolean ok(String json) {
return json.contains("\"result\":\"success\"");
}
@Test
void collectWritesWhenNotYetCollected() {
when(collectMapper.isCollect(100, 5)).thenReturn(0);
assertTrue(ok(service.collectGallery(100, 5)));
verify(collectMapper).collect(100, 5);
}
/** 重复收藏必须拒绝且不得重复落库。 */
@Test
void collectRejectsDuplicate() {
when(collectMapper.isCollect(100, 5)).thenReturn(1);
String json = service.collectGallery(100, 5);
assertFalse(ok(json));
assertTrue(json.contains("已经收藏了"));
verify(collectMapper, never()).collect(anyInt(), anyInt());
}
@Test
void disCollectRemovesWhenCollected() {
when(collectMapper.isCollect(100, 5)).thenReturn(1);
assertTrue(ok(service.disCollectGallery(100, 5)));
verify(collectMapper).disCollect(100, 5);
}
/** 取消一个没收藏的画廊必须拒绝,且不得落库。 */
@Test
void disCollectRejectsWhenNotCollected() {
when(collectMapper.isCollect(100, 5)).thenReturn(0);
String json = service.disCollectGallery(100, 5);
assertFalse(ok(json));
assertTrue(json.contains("没有收藏该图片"));
verify(collectMapper, never()).disCollect(anyInt(), anyInt());
}
}
@@ -0,0 +1,602 @@
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(ImageCacheMapper.class), remote, push);
User user = new User();
user.setId(7);
user.setUsername("tester");
when(users.selectUserByAuthCode("code")).thenReturn(user);
}
// ---------- cacheImagesKey 的异常兜底 ----------
/**
* 回归修复验证(缺陷于 2026-09-14 Jackson 2→3 迁移引入,2026-09-15 修复)。
*
* 线上真实 mpv 页的 imagelist 是 JS 语句、行尾带分号;解析失败曾以
* `JacksonException`(Jackson 3 中继承 RuntimeException,不再是 IOException)穿透
* `catch (IOException)`,导致新画廊在线看图 500。
* 修复后:解析成功并落库,不再抛异常。
*/
@Test
void cacheImagesKeyParsesRealPageFormatAndCachesKeys() throws Exception {
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null); // 缓存未命中
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
configurationMapper, users,
imageCacheMapper, remote, push);
// imagelist 行以分号结尾——即线上真实页面格式
String realMpvPage = "<html><body><script>x</script><script>\n"
+ "var gid=1596929;\n"
+ "var mpvkey = \"nfa9l8ianjg\";\n"
+ "var imagelist = [{\"n\":\"a.png\",\"k\":\"bd9015\",\"t\":\"(x) -0px 0\"}];\n"
+ "</script></body></html>";
try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString()))
.thenCallRealMethod();
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil
.requests(anyString(), anyString(), any(), any()))
.thenReturn(realMpvPage);
String json = svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/");
assertTrue(json.contains("\"result\":\"success\""), "应缓存成功,实际: " + json);
}
verify(imageCacheMapper).insertGidToKey(any());
verify(imageCacheMapper).insertImageKeyCache(any());
}
/**
* 第三方页面格式异常时必须回业务失败,不能再穿透成 500。
* 这里让 requests 返回畸形 JSON,验证 catch 兜住解析异常。
*/
@Test
void cacheImagesKeyConvertsParseFailureToBusinessFailure() throws Exception {
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null);
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
configurationMapper, users,
imageCacheMapper, remote, push);
String brokenPage = "<html><body><script>x</script><script>\n"
+ "var gid=1;\n"
+ "var mpvkey = \"k\";\n"
+ "var imagelist = [{{not valid json;\n"
+ "</script></body></html>";
try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString()))
.thenCallRealMethod();
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil
.requests(anyString(), anyString(), any(), any()))
.thenReturn(brokenPage);
String json = assertDoesNotThrow(() ->
svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/"));
assertFalse(json.contains("\"result\":\"success\""), "不应报成功: " + json);
assertTrue(json.contains("网络波动或其他异常"), "实际: " + json);
}
verify(imageCacheMapper, never()).insertGidToKey(any());
verify(imageCacheMapper, never()).insertImageKeyCache(any());
}
/** 畸形链接(段数不足)必须回业务失败,不得抛 ArrayIndexOutOfBoundsException。 */
@Test
void cacheImagesKeyRejectsMalformedLink() {
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
configurationMapper, users,
imageCacheMapper, remote, push);
for (String bad : new String[]{"abc", "https://exhentai.org/g/1/", null}) {
String json = assertDoesNotThrow(() -> svc.cacheImagesKey(bad),
"畸形链接不应抛异常,实际输入: " + bad);
assertTrue(json.contains("链接错误"), "实际输出: " + json);
}
verify(imageCacheMapper, never()).insertGidToKey(any());
}
/**
* 回归:索引写入不是原子的。若 gidToKey 写入成功、逐页写 ImageKeyCache 时失败,
* 会留下「gidToKey 命中但页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。
* 现在任何异常都要把已写入的部分清掉再回业务失败。
*/
@Test
void cacheImagesKeyRollsBackWhenPageInsertFails() throws Exception {
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null);
doThrow(new RuntimeException("db down")).when(imageCacheMapper).insertImageKeyCache(any());
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
configurationMapper, users,
imageCacheMapper, remote, push);
String realMpvPage = "<html><body><script>x</script><script>\n"
+ "var gid=1596929;\n"
+ "var mpvkey = \"nfa9l8ianjg\";\n"
+ "var imagelist = [{\"n\":\"a.png\",\"k\":\"bd9015\",\"t\":\"(x) -0px 0\"}];\n"
+ "</script></body></html>";
try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString()))
.thenCallRealMethod();
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil
.requests(anyString(), anyString(), any(), any()))
.thenReturn(realMpvPage);
String json = assertDoesNotThrow(() ->
svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/"));
assertFalse(json.contains("\"result\":\"success\""), "写入失败不应报成功: " + json);
}
verify(imageCacheMapper).insertGidToKey(any());
verify(imageCacheMapper).deleteImageKeyCacheByGid(anyString());
verify(imageCacheMapper).deleteGidToKey(anyString());
}
/** 已缓存的索引若页 key 数对不上(半截缓存),必须重建而不是直接返回。 */
@Test
void cacheImagesKeyRebuildsIncompleteCache() throws Exception {
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
var stale = new com.lion.lionwebsite.Domain.GidToKey();
stale.setGid("1596929");
stale.setKey("f08534d87d");
stale.setPages(3);
when(imageCacheMapper.selectKeyByGid("1596929")).thenReturn(stale);
when(imageCacheMapper.countImageKeyCacheByGid("1596929")).thenReturn(1); // 只剩 1 页
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
configurationMapper, users,
imageCacheMapper, remote, push);
String realMpvPage = "<html><body><script>x</script><script>\n"
+ "var gid=1596929;\n"
+ "var mpvkey = \"nfa9l8ianjg\";\n"
+ "var imagelist = [{\"n\":\"a.png\",\"k\":\"bd9015\",\"t\":\"(x) -0px 0\"}];\n"
+ "</script></body></html>";
try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString()))
.thenCallRealMethod();
parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil
.requests(anyString(), anyString(), any(), any()))
.thenReturn(realMpvPage);
String json = svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/");
assertTrue(json.contains("\"result\":\"success\""), "应重建成功: " + json);
}
verify(imageCacheMapper).deleteImageKeyCacheByGid("1596929");
verify(imageCacheMapper).deleteGidToKey("1596929");
verify(imageCacheMapper).insertGidToKey(any());
}
// ---------- 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\""));
}
}
/**
* 回归:GalleryUtil.parseGid 以前只捕 IndexOutOfBoundsException,
* 「非数字 gid」与「null」都会穿透成 500。按链接查询会直接把用户输入喂进来,
* 这里锁住真实实现(不 mock)在这些输入下都安全返回失败。
*/
@Test
void selectTaskByLinkHandlesMalformedGidWithoutThrowing() {
for (String bad : new String[]{"https://exhentai.org/g/not-a-number/key/", "garbage", null}) {
String response = assertDoesNotThrow(() -> service.selectTaskByLink(bad),
"畸形链接不应抛异常,实际输入: " + bad);
assertFalse(response.contains("\"result\":\"success\""), "实际输出: " + response);
}
verify(galleries, never()).selectGalleryByGid(anyInt());
}
@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);
}
}

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