Compare commits
43
Commits
29f1532dea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776eb8cf53 | ||
|
|
e03a2e007b | ||
|
|
5f1066cdab | ||
|
|
e3d4220997 | ||
|
|
42921ba97b | ||
|
|
584db277a6 | ||
|
|
628f3ad0a2 | ||
|
|
dfb647bcd3 | ||
|
|
00e27152fe | ||
|
|
654181d2ea | ||
|
|
bee2f11fd6 | ||
|
|
305036ddfe | ||
|
|
d949415e4d | ||
|
|
3adb42c538 | ||
|
|
a0959618ca | ||
|
|
a288846552 | ||
|
|
850819ed86 | ||
|
|
576092578d | ||
|
|
213ed4e7f6 | ||
|
|
d5b97b82a0 | ||
|
|
4623fca8b0 | ||
|
|
f5ef8ca487 | ||
|
|
50e513b8cc | ||
|
|
d3b18f90fa | ||
|
|
a9ba631847 | ||
|
|
60facae9b5 | ||
|
|
b15eeaf45e | ||
|
|
1e6e3a1557 | ||
|
|
7f823b6150 | ||
|
|
f65c5ad860 | ||
|
|
3c8be3e7c9 | ||
|
|
fb327ba00c | ||
|
|
10e6d44570 | ||
|
|
44995afe78 | ||
|
|
bc4ee64a83 | ||
|
|
14dd12d9ad | ||
|
|
fc8548f90f | ||
|
|
9475237731 | ||
|
|
ccc05fda19 | ||
|
|
72f8ab597a | ||
|
|
5dd09e003b | ||
|
|
7ca12d7999 | ||
|
|
2f10d7a868 |
+43
@@ -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
|
||||||
@@ -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,未纳入版本控制;重新部署时需按本文恢复。
|
||||||
@@ -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` 已追加待发布说明;本文件是这部分接口行为变更的仓库内交接记录。
|
||||||
@@ -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
@@ -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
|
||||||
|
================================================================================
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.3.2</version>
|
<version>4.1.1</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>com.lion</groupId>
|
<groupId>com.lion</groupId>
|
||||||
@@ -19,18 +19,18 @@
|
|||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mybatis.spring.boot</groupId>
|
<groupId>org.mybatis.spring.boot</groupId>
|
||||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||||
<version>3.0.3</version>
|
<version>4.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.30</version>
|
<version>1.18.46</version>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -41,20 +41,20 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mybatis.spring.boot</groupId>
|
<groupId>org.mybatis.spring.boot</groupId>
|
||||||
<artifactId>mybatis-spring-boot-starter-test</artifactId>
|
<artifactId>mybatis-spring-boot-starter-test</artifactId>
|
||||||
<version>3.0.3</version>
|
<version>4.1.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.15.3</version>
|
<version>1.23.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-all</artifactId>
|
<artifactId>hutool-all</artifactId>
|
||||||
<version>5.8.26</version>
|
<version>5.8.47</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -63,15 +63,8 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||||
<artifactId>httpclient</artifactId>
|
<artifactId>httpclient5</artifactId>
|
||||||
<version>4.5.14</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
|
||||||
<artifactId>httpmime</artifactId>
|
|
||||||
<version>4.5.14</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -82,24 +75,45 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.commons</groupId>
|
<groupId>org.apache.commons</groupId>
|
||||||
<artifactId>commons-compress</artifactId>
|
<artifactId>commons-compress</artifactId>
|
||||||
<version>1.26.1</version>
|
<version>1.28.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>commons-io</groupId>
|
<groupId>commons-io</groupId>
|
||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
<version>2.11.0</version>
|
<version>2.22.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.netty</groupId>
|
<groupId>io.netty</groupId>
|
||||||
<artifactId>netty-all</artifactId>
|
<!--
|
||||||
|
只保留实际用到的模块。原先的 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>7.9.1</version>
|
<version>10.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -110,16 +124,48 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.46</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>0.8.13</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>prepare-agent</id>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>report</id>
|
||||||
|
<phase>test</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>report</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.graalvm.buildtools</groupId>
|
<groupId>org.graalvm.buildtools</groupId>
|
||||||
<artifactId>native-maven-plugin</artifactId>
|
<artifactId>native-maven-plugin</artifactId>
|
||||||
<version>0.10.3</version>
|
<version>1.1.8</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<imageName>lionwebsite</imageName>
|
<imageName>lionwebsite</imageName>
|
||||||
<buildArgs>
|
<buildArgs>
|
||||||
<arg>--gc=G1</arg>
|
|
||||||
<arg>-H:+ReportExceptionStackTraces</arg>
|
<arg>-H:+ReportExceptionStackTraces</arg>
|
||||||
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.sqlite.util.ProcessRunner</arg>
|
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.apache.commons.logging.impl.WeakHashtable,org.sqlite.util.ProcessRunner</arg>
|
||||||
</buildArgs>
|
</buildArgs>
|
||||||
<metadataRepository>
|
<metadataRepository>
|
||||||
<enabled>true</enabled>
|
<enabled>true</enabled>
|
||||||
|
|||||||
Executable
+18
@@ -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"
|
||||||
@@ -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;
|
||||||
Executable
+29
@@ -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"
|
||||||
@@ -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,27 +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.*;
|
import com.pengrad.telegrambot.model.*;
|
||||||
import com.pengrad.telegrambot.model.User;
|
import com.pengrad.telegrambot.model.User;
|
||||||
import com.pengrad.telegrambot.response.SendResponse;
|
import com.pengrad.telegrambot.response.SendResponse;
|
||||||
import com.zaxxer.hikari.HikariConfig;
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
|
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
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, AvailableCheckMessage.class, LinkPreviewOptions.class})
|
IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, AvailableCheckMessage.class,
|
||||||
|
SubscriptionSnapshotMessage.class, SubscriptionSnapshotPayload.class, SubscriptionAccountSnapshot.class,
|
||||||
|
SubscriptionBindingSnapshot.class, LinkPreviewOptions.class})
|
||||||
public class CustomBean {
|
public class CustomBean {
|
||||||
|
|
||||||
|
@Value("${bot.token:5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA}")
|
||||||
|
private String botToken;
|
||||||
|
|
||||||
|
@Value("${gallery.cookie:ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1}")
|
||||||
|
private String ehentaiCookie;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initGalleryCookie() {
|
||||||
|
GalleryUtil.setEhentaiCookie(ehentaiCookie);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public TelegramBot getTelegramBot(){
|
public TelegramBot getTelegramBot(){
|
||||||
return new TelegramBot("5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA");
|
return new TelegramBot(botToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅分散刷新的排程器。
|
||||||
|
*
|
||||||
|
* <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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.lion.lionwebsite.Configuration;
|
package com.lion.lionwebsite.Configuration;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Service.WebSocketService;
|
import com.lion.lionwebsite.Service.WebSocketService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
@@ -8,12 +9,10 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class WebsocketConfiguration implements WebSocketConfigurer {
|
public class WebsocketConfiguration implements WebSocketConfigurer {
|
||||||
|
|
||||||
WebSocketService webSocketService;
|
final WebSocketService webSocketService;
|
||||||
public WebsocketConfiguration(WebSocketService webSocketService) {
|
|
||||||
this.webSocketService = webSocketService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package com.lion.lionwebsite.Controller;
|
|||||||
import com.lion.lionwebsite.Service.CollectService;
|
import com.lion.lionwebsite.Service.CollectService;
|
||||||
import com.lion.lionwebsite.Service.GalleryManageService;
|
import com.lion.lionwebsite.Service.GalleryManageService;
|
||||||
import com.lion.lionwebsite.Service.RemoteService;
|
import com.lion.lionwebsite.Service.RemoteService;
|
||||||
import com.lion.lionwebsite.Service.UserServiceImpl;
|
import com.lion.lionwebsite.Service.UserService;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -15,21 +16,15 @@ import java.util.concurrent.Callable;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/GalleryManage")
|
@RequestMapping("/GalleryManage")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class GalleryManageController {
|
public class GalleryManageController {
|
||||||
GalleryManageService galleryManageService;
|
final GalleryManageService galleryManageService;
|
||||||
|
|
||||||
CollectService collectService;
|
final CollectService collectService;
|
||||||
|
|
||||||
UserServiceImpl userService;
|
final UserService userService;
|
||||||
|
|
||||||
RemoteService remoteService;
|
final RemoteService remoteService;
|
||||||
|
|
||||||
public GalleryManageController(GalleryManageService galleryManageService, CollectService collectService, UserServiceImpl userService, RemoteService remoteService) {
|
|
||||||
this.galleryManageService = galleryManageService;
|
|
||||||
this.collectService = collectService;
|
|
||||||
this.userService = userService;
|
|
||||||
this.remoteService = remoteService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("")
|
@PostMapping("")
|
||||||
public String create_task(String link, String targetResolution, String AuthCode){
|
public String create_task(String link, String targetResolution, String AuthCode){
|
||||||
@@ -72,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){
|
||||||
@@ -102,4 +105,11 @@ public class GalleryManageController {
|
|||||||
public String resetUndone(){
|
public String resetUndone(){
|
||||||
return galleryManageService.resetUndone();
|
return galleryManageService.resetUndone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/retry")
|
||||||
|
public String retryGallery(Integer gid){
|
||||||
|
if(gid == null)
|
||||||
|
return Response._failure("参数不全");
|
||||||
|
return galleryManageService.retryGallery(gid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +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.PanelLoginTicket;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import 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("/")
|
@GetMapping("/")
|
||||||
public void index(HttpServletResponse resp) throws IOException {
|
public void index(HttpServletResponse resp) throws IOException {
|
||||||
resp.sendRedirect("/index");
|
resp.sendRedirect("/index");
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/files")
|
/**
|
||||||
public String file(String path){
|
* 一键登录:校验机器人签发的 HMAC 票据,通过后建立会话并跳到个人面板。
|
||||||
return personalService.getFiles(path);
|
*
|
||||||
|
* <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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先作废旧会话再建新的,避免会话固定攻击。
|
||||||
|
HttpSession existing = request.getSession(false);
|
||||||
|
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();
|
||||||
@@ -91,7 +130,7 @@ public class PersonalController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/ip")
|
@GetMapping("/ip")
|
||||||
public String ip() throws JsonProcessingException {
|
public String ip() {
|
||||||
return personalService.getIp();
|
return personalService.getIp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,41 +2,35 @@ package com.lion.lionwebsite.Controller;
|
|||||||
|
|
||||||
|
|
||||||
import com.lion.lionwebsite.Domain.User;
|
import com.lion.lionwebsite.Domain.User;
|
||||||
import com.lion.lionwebsite.Service.PublicServiceImpl;
|
import com.lion.lionwebsite.Service.PublicService;
|
||||||
import com.lion.lionwebsite.Service.QueryService;
|
import com.lion.lionwebsite.Service.QueryService;
|
||||||
import com.lion.lionwebsite.Service.RemoteService;
|
import com.lion.lionwebsite.Service.RemoteService;
|
||||||
import com.lion.lionwebsite.Service.SubService;
|
import com.lion.lionwebsite.Service.SubService;
|
||||||
|
import com.lion.lionwebsite.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;
|
||||||
|
|
||||||
QueryService queryService;
|
|
||||||
|
|
||||||
public PublicController(PublicServiceImpl publicService, RemoteService remoteService, SubService subService, QueryService queryService) {
|
|
||||||
this.publicService = publicService;
|
|
||||||
this.remoteService = remoteService;
|
|
||||||
this.subService = subService;
|
|
||||||
this.queryService = queryService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
public void index(HttpServletResponse resp) throws IOException {
|
public void index(HttpServletResponse resp) throws IOException {
|
||||||
@@ -64,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.removeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
@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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.lion.lionwebsite.Controller;
|
package com.lion.lionwebsite.Controller;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Service.QueryService;
|
import com.lion.lionwebsite.Service.QueryService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -8,13 +9,10 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/query")
|
@RequestMapping("/query")
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class QueryController {
|
public class QueryController {
|
||||||
|
|
||||||
QueryService queryService;
|
final QueryService queryService;
|
||||||
|
|
||||||
public QueryController(QueryService queryService) {
|
|
||||||
this.queryService = queryService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
public String query(String keyword, String prev, String next){
|
public String query(String keyword, String prev, String next){
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
package com.lion.lionwebsite.Controller;
|
package com.lion.lionwebsite.Controller;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Service.SubService;
|
import com.lion.lionwebsite.Service.SubService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/personal/subBind/")
|
@RequestMapping("/personal/subBind/")
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class SubController {
|
public class SubController {
|
||||||
SubService subService;
|
final SubService subService;
|
||||||
|
|
||||||
public SubController(SubService subService) {
|
|
||||||
this.subService = subService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("")
|
@PostMapping("")
|
||||||
public String addSubBind(String user){
|
public String addSubBind(String user, Integer accountId){
|
||||||
return subService.insertSubBind(user);
|
return subService.insertSubBind(user, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("")
|
@PutMapping("")
|
||||||
@@ -36,4 +34,38 @@ public class SubController {
|
|||||||
public String deleteSubBind(String user){
|
public String deleteSubBind(String user){
|
||||||
return subService.deleteSubBind(user);
|
return subService.deleteSubBind(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("accounts")
|
||||||
|
public String getAccounts(){
|
||||||
|
return subService.listSubscriptionAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("accounts")
|
||||||
|
public String addAccount(String name, String upstreamKey,
|
||||||
|
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
|
||||||
|
@RequestParam(defaultValue = "true") boolean enabled){
|
||||||
|
return subService.insertSubscriptionAccount(name, upstreamKey, filterHighMultiplier, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("accounts/{id}")
|
||||||
|
public String updateAccount(@PathVariable Integer id, String name, String upstreamKey,
|
||||||
|
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
|
||||||
|
@RequestParam(defaultValue = "true") boolean enabled){
|
||||||
|
return subService.updateSubscriptionAccount(id, name, upstreamKey, filterHighMultiplier, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("accounts/{id}/refresh")
|
||||||
|
public String refreshAccount(@PathVariable Integer id){
|
||||||
|
return subService.refreshSubscriptionAccount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("accounts/{id}")
|
||||||
|
public String deleteAccount(@PathVariable Integer id){
|
||||||
|
return subService.deleteSubscriptionAccount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("{user}/account")
|
||||||
|
public String rebind(@PathVariable String user, Integer accountId){
|
||||||
|
return subService.rebind(user, accountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package com.lion.lionwebsite.Controller;
|
package com.lion.lionwebsite.Controller;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Service.UserServiceImpl;
|
import com.lion.lionwebsite.Service.UserService;
|
||||||
import jakarta.annotation.Resource;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/personal/user")
|
@RequestMapping("/personal/user")
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class UserController {
|
public class UserController {
|
||||||
UserServiceImpl userService;
|
final UserService userService;
|
||||||
|
|
||||||
public UserController(UserServiceImpl userService) {
|
|
||||||
this.userService = userService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
public String getAllUser(){
|
public String getAllUser(){
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -38,6 +76,16 @@ public interface SubMapper {
|
|||||||
@Delete("delete from sub_update_record where user=#{user}")
|
@Delete("delete from sub_update_record where user=#{user}")
|
||||||
void deleteSubUpdateRecord(String user);
|
void deleteSubUpdateRecord(String user);
|
||||||
|
|
||||||
|
@Select("select count(user) from sub_update_record where user=#{user}")
|
||||||
|
Integer selectUpdateRecordCount(String user);
|
||||||
|
|
||||||
|
@Select("select min(id) from sub_update_record where user=#{user}")
|
||||||
|
Integer selectMinUpdateRecordId(String user);
|
||||||
|
|
||||||
|
/** 后台补齐归属地:只更新该用户最近一条记录,避免覆盖更早的历史记录。 */
|
||||||
|
@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")
|
||||||
|
|||||||
@@ -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; //收藏人
|
||||||
|
|||||||
@@ -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,12 +4,18 @@ import jakarta.servlet.*;
|
|||||||
import jakarta.servlet.annotation.WebFilter;
|
import jakarta.servlet.annotation.WebFilter;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Calendar;
|
import java.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 {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
|
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
|
||||||
HttpServletRequest request = (HttpServletRequest) servletRequest;
|
HttpServletRequest request = (HttpServletRequest) servletRequest;
|
||||||
@@ -20,14 +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,
|
||||||
|
hasAuthCode ? "present" : "absent", UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent);
|
||||||
|
|
||||||
//如果是验证,则直接跳转
|
//如果是验证,则直接跳转
|
||||||
if(ServletPath.equals("/validate"))
|
if(ServletPath.equals("/validate"))
|
||||||
@@ -35,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
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class AbstractMessage {
|
|||||||
|
|
||||||
public static final byte AVAILABLE_CHECK_MESSAGE = 8;
|
public static final byte AVAILABLE_CHECK_MESSAGE = 8;
|
||||||
|
|
||||||
|
public static final byte SUBSCRIPTION_SNAPSHOT_MESSAGE = 9;
|
||||||
|
|
||||||
public byte messageType;
|
public byte messageType;
|
||||||
|
|
||||||
public int messageId;
|
public int messageId;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.lion.lionwebsite.Message;
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import io.netty.buffer.ByteBuf;
|
import io.netty.buffer.ByteBuf;
|
||||||
import io.netty.channel.ChannelHandlerContext;
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
import io.netty.handler.codec.ByteToMessageCodec;
|
import io.netty.handler.codec.ByteToMessageCodec;
|
||||||
@@ -44,6 +44,7 @@ public class MessageCodec extends ByteToMessageCodec<AbstractMessage> {
|
|||||||
case AbstractMessage.IDENTITY_MESSAGE -> objectMapper.readValue(metadata, IdentityMessage.class);
|
case AbstractMessage.IDENTITY_MESSAGE -> objectMapper.readValue(metadata, IdentityMessage.class);
|
||||||
case AbstractMessage.MAINTAIN_MESSAGE -> objectMapper.readValue(metadata, MaintainMessage.class);
|
case AbstractMessage.MAINTAIN_MESSAGE -> objectMapper.readValue(metadata, MaintainMessage.class);
|
||||||
case AbstractMessage.AVAILABLE_CHECK_MESSAGE -> objectMapper.readValue(metadata, AvailableCheckMessage.class);
|
case AbstractMessage.AVAILABLE_CHECK_MESSAGE -> objectMapper.readValue(metadata, AvailableCheckMessage.class);
|
||||||
|
case AbstractMessage.SUBSCRIPTION_SNAPSHOT_MESSAGE -> objectMapper.readValue(metadata, SubscriptionSnapshotMessage.class);
|
||||||
default -> null;
|
default -> null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class SubscriptionAccountSnapshot {
|
||||||
|
private Integer accountId;
|
||||||
|
private boolean enabled;
|
||||||
|
private boolean filterHighMultiplier;
|
||||||
|
private String v2ContentBase64;
|
||||||
|
private String v2Sha256;
|
||||||
|
private String clashContentBase64;
|
||||||
|
private String clashSha256;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class SubscriptionBindingSnapshot {
|
||||||
|
private String publicKeySha256;
|
||||||
|
private Integer accountId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@ToString(exclude = {"payloadBase64", "signature"})
|
||||||
|
public class SubscriptionSnapshotMessage extends AbstractMessage {
|
||||||
|
{
|
||||||
|
messageType = SUBSCRIPTION_SNAPSHOT_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int schemaVersion;
|
||||||
|
private String revision;
|
||||||
|
private long generatedAt;
|
||||||
|
private String payloadBase64;
|
||||||
|
private String payloadSha256;
|
||||||
|
private String signature;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.lion.lionwebsite.Message;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class SubscriptionSnapshotPayload {
|
||||||
|
private int schemaVersion;
|
||||||
|
private List<SubscriptionAccountSnapshot> accounts = new ArrayList<>();
|
||||||
|
private List<SubscriptionBindingSnapshot> bindings = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -8,11 +8,7 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
@Data
|
@Data
|
||||||
public class CollectService {
|
public class CollectService {
|
||||||
CollectMapper collectMapper;
|
final CollectMapper collectMapper;
|
||||||
|
|
||||||
public CollectService(CollectMapper collectMapper){
|
|
||||||
this.collectMapper = collectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String collectGallery(int gid, int collector){
|
public String collectGallery(int gid, int collector){
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
|||||||
import com.lion.lionwebsite.Error.ErrorCode;
|
import com.lion.lionwebsite.Error.ErrorCode;
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.FileDownload;
|
import com.lion.lionwebsite.Util.FileDownload;
|
||||||
|
import com.lion.lionwebsite.Util.ImageFileCache;
|
||||||
|
import java.nio.file.Path;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.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;
|
||||||
@@ -30,33 +32,19 @@ import static com.lion.lionwebsite.Util.GalleryUtil.*;
|
|||||||
public class GalleryManageService {
|
public class GalleryManageService {
|
||||||
String cachePath = "/storage/galleryCache/onlineImages/";
|
String cachePath = "/storage/galleryCache/onlineImages/";
|
||||||
|
|
||||||
GalleryMapper galleryMapper;
|
final GalleryMapper galleryMapper;
|
||||||
|
|
||||||
CollectMapper collectMapper;
|
final CollectMapper collectMapper;
|
||||||
|
|
||||||
CustomConfigurationMapper configurationMapper;
|
final CustomConfigurationMapper configurationMapper;
|
||||||
|
|
||||||
UserMapper userMapper;
|
final UserMapper userMapper;
|
||||||
|
|
||||||
ShareFileMapper shareFileMapper;
|
final 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建任务
|
* 创建任务
|
||||||
@@ -70,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;
|
||||||
@@ -88,7 +84,7 @@ public class GalleryManageService {
|
|||||||
//判断数据库中是否有这个任务,有则返回状态
|
//判断数据库中是否有这个任务,有则返回状态
|
||||||
if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) {
|
if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) {
|
||||||
response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务");
|
response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务");
|
||||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
pushService.taskCreateReport(user.getUsername(), gallery.getName(), response);
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,47 +94,59 @@ public class GalleryManageService {
|
|||||||
if (gallery == null || !gallery.getStatus().equals("已提交")) {
|
if (gallery == null || !gallery.getStatus().equals("已提交")) {
|
||||||
log.error("创建任务失败: {},", link);
|
log.error("创建任务失败: {},", link);
|
||||||
response.failure("提交任务失败,未知原因,请检查链接是否正常");
|
response.failure("提交任务失败,未知原因,请检查链接是否正常");
|
||||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
} else {
|
} else {
|
||||||
|
taskName = gallery.getName();
|
||||||
log.info("创建任务: {} 目标分辨率:{}", link, targetResolution);
|
log.info("创建任务: {} 目标分辨率:{}", link, targetResolution);
|
||||||
|
// Persist before dispatch: the node sends its current status before its ACK.
|
||||||
|
gallery.setDownloader(user.getId());
|
||||||
|
gallery.set_download(true);
|
||||||
|
galleryMapper.insertGallery(gallery);
|
||||||
|
configurationMapper.incrementConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, gallery.getFileSize());
|
||||||
if (remoteService.addGalleryToQueue(gallery) != 0) {
|
if (remoteService.addGalleryToQueue(gallery) != 0) {
|
||||||
log.error("传送任务{}失败, 未知原因", gallery.getName());
|
log.error("传送任务{}失败, 未知原因", gallery.getName());
|
||||||
response.failure("任务传送失败,未知原因,尝试点击重连按钮看看");
|
response.failure("任务已保存,但节点未确认接收;请刷新任务列表后重试");
|
||||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ResolutionNotMatchException e) {
|
} catch (ResolutionNotMatchException e) {
|
||||||
response.failure("提交失败,分辨率不存在");
|
response.failure("提交失败,分辨率不存在");
|
||||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error(e.getMessage());
|
log.error(e.getMessage());
|
||||||
response.failure("IO错误,可能是网络波动");
|
response.failure("IO错误,可能是网络波动");
|
||||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
//处理下载结果,将任务插入数据库并且更新每周用量
|
// Do not overwrite an immediate node status with the original submitted state.
|
||||||
if (gallery.getStatus().equals("已提交")) {
|
Gallery current = galleryMapper.selectGalleryByGid(gallery.getGid());
|
||||||
response.success(gallery.toString());
|
response.success((current == null ? gallery : current).toString());
|
||||||
gallery.setDownloader(user.getId());
|
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||||
gallery.set_download(true);
|
|
||||||
galleryMapper.insertGallery(gallery);
|
|
||||||
|
|
||||||
long usedAmount = Long.parseLong(configurationMapper.selectConfiguration(CustomConfiguration.WEEK_USED_AMOUNT).getValue());
|
|
||||||
usedAmount += gallery.getFileSize();
|
|
||||||
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, String.valueOf(usedAmount));
|
|
||||||
} else {
|
|
||||||
response.failure("提交失败,未知原因");
|
|
||||||
galleryMapper.deleteGalleryByGid(gallery.getGid());
|
|
||||||
}
|
|
||||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
|
||||||
|
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从任务链接中安全提取 gid。任何畸形输入(null、段数不足、非数字)都返回 null,
|
||||||
|
* 由调用方转成业务失败,避免异常穿透为 500。
|
||||||
|
*/
|
||||||
|
static Integer parseGidFromLink(String link) {
|
||||||
|
if (link == null)
|
||||||
|
return null;
|
||||||
|
String[] segments = link.split("/");
|
||||||
|
if (segments.length <= 4)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(segments[4]);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试重新连接
|
* 尝试重新连接
|
||||||
* @return 重连结果
|
* @return 重连结果
|
||||||
@@ -176,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) {
|
||||||
@@ -185,7 +193,7 @@ public class GalleryManageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else
|
else
|
||||||
response.success(new ObjectMapper().valueToTree(gallery).toString());
|
response.success(objectMapper.valueToTree(gallery).toString());
|
||||||
|
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
@@ -224,26 +232,62 @@ public class GalleryManageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ArrayList<Integer> galleryIds = collectMapper.selectGidByCollector(userId);
|
ArrayList<Integer> galleryIds = collectMapper.selectGidByCollector(userId);
|
||||||
Iterator<Integer> idIterator;
|
// 原先对每个画廊线性扫描收藏列表(O(n*m))并就地删除迭代器元素;
|
||||||
|
// 改成集合查找后同样是「命中即标记」,复杂度降到 O(n+m)。
|
||||||
|
if (!galleryIds.isEmpty()) {
|
||||||
|
Set<Integer> collected = new HashSet<>(galleryIds);
|
||||||
|
for (Gallery gallery : galleries)
|
||||||
|
if (collected.contains(gallery.getGid()))
|
||||||
|
gallery.setCollect(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (!galleryIds.isEmpty()) //如果该用户收藏了图片
|
// 下载人昵称只对管理员下发;一次建表避免逐条查询。
|
||||||
galleryLoop:for (Gallery gallery : galleries) { //遍历图片
|
if (userId == UserService.ADMIN_USER_ID)
|
||||||
idIterator = galleryIds.iterator();
|
fillDownloaderNames(galleries);
|
||||||
while (idIterator.hasNext()) { //遍历收藏的gid
|
|
||||||
Integer id = idIterator.next();
|
|
||||||
if (id.equals(gallery.getGid())) { //如果找到对应的gid,修改对应图片的属性,删除当前gid,判断是否需要跳出或者结束循环
|
|
||||||
gallery.setCollect(true);
|
|
||||||
idIterator.remove();
|
|
||||||
|
|
||||||
if (galleryIds.isEmpty())
|
response.success(objectMapper.valueToTree(galleries).toString());
|
||||||
break galleryLoop;
|
return response.toJSONString();
|
||||||
else
|
}
|
||||||
continue galleryLoop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response.success(new ObjectMapper().valueToTree(galleries).toString());
|
/** 给任务列表补上下载人昵称,找不到用户时保留空值而不是报错。 */
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某个任务的实际下载人昵称。
|
||||||
|
* 只有管理员可调用,普通用户一律拒绝,避免暴露他人身份。
|
||||||
|
*
|
||||||
|
* @param gid 任务 gid
|
||||||
|
* @param AuthCode 调用方授权码
|
||||||
|
*/
|
||||||
|
public String selectDownloaderByGid(int gid, String AuthCode) {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
User requester = userMapper.selectUserByAuthCode(AuthCode);
|
||||||
|
if (requester == null || requester.getId() != UserService.ADMIN_USER_ID) {
|
||||||
|
response.failure("无权查看下载人");
|
||||||
|
return response.toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||||
|
if (gallery == null) {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +302,7 @@ 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("没有找到该名字的图片");
|
||||||
|
|
||||||
@@ -276,7 +320,7 @@ 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("您未下载图片");
|
||||||
|
|
||||||
@@ -297,22 +341,36 @@ public class GalleryManageService {
|
|||||||
response.failure("删除失败,该图片不存在");
|
response.failure("删除失败,该图片不存在");
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
if (user == null) {
|
||||||
|
response.failure("删除失败,授权码无效");
|
||||||
|
return response.toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
|
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
|
||||||
if (!(collector.isEmpty() || collector.size() == 1 && collector.getFirst().equals(user.getId()) //判断收藏
|
// 拒绝条件与提示文案一致:被别人收藏,或者请求者不是下载人。
|
||||||
&& gallery.getDownloader() == user.getId())) //判断下载
|
// 注意不能写成 collector.isEmpty() || ...:那样在「无任何收藏」时会短路放行,
|
||||||
|
// 从而完全跳过下载者校验,导致任何有效授权码都能删除他人任务。
|
||||||
|
boolean collectedByOthers = collector.stream().anyMatch(id -> id != user.getId());
|
||||||
|
boolean isDownloader = gallery.getDownloader() == user.getId();
|
||||||
|
if (collectedByOthers || !isDownloader) {
|
||||||
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
|
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
|
||||||
else {
|
log.info("拒绝删除 gid={}:collectedByOthers={} isDownloader={}", gid, collectedByOthers, isDownloader);
|
||||||
log.info("删除图片{}", gallery.getName());
|
return response.toJSONString();
|
||||||
galleryMapper.deleteGalleryByGid(gallery.getGid()); //删除图片记录
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 通过授权后才落库并通知节点,避免被拒请求仍删除节点文件。
|
||||||
|
log.info("删除图片{}", gallery.getName());
|
||||||
|
galleryMapper.deleteGalleryByGid(gallery.getGid());
|
||||||
switch (remoteService.deleteGallery(gallery)) {
|
switch (remoteService.deleteGallery(gallery)) {
|
||||||
case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
|
case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
|
||||||
case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
|
case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
|
||||||
case 0 -> response.success();
|
case 0 -> response.success();
|
||||||
|
// 节点无响应/超时会返回 -1 等非枚举值;必须显式判失败,
|
||||||
|
// 否则 result 保持未设置,末尾的 response.get("result") 会抛 NPE。
|
||||||
|
default -> response.failure("图片:" + gallery.getName() + "删除失败,节点无响应");
|
||||||
}
|
}
|
||||||
if (response.get("result").equals("failure"))
|
if (!response.isSuccess())
|
||||||
log.info(response.getResult());
|
log.info(response.getData());
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,110 +384,126 @@ 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)
|
if(imageKeyCaches == null)
|
||||||
return response.failure("该图片已下架或已被删除").toJSONString();
|
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 = {".avif", ".gif"};
|
/** 页 key 行数与 gidToKey 记录不一致时视为半截缓存,需要重建。 */
|
||||||
public Callable<?> 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;
|
||||||
|
}
|
||||||
|
|
||||||
//检查对应图片是否存在,存在则直接返回
|
/** 删除某个 gid 已写入的索引(页 key 与 gidToKey),用于回滚或重建。 */
|
||||||
for (String suffix : suffixes) {
|
private void rollbackImageIndex(String gid) {
|
||||||
if(new File(cachePath + gid + "/" + page + suffix).exists()){
|
try {
|
||||||
FileDownload.export(request, response, cachePath + gid + "/" + page + suffix);
|
imageCacheMapper.deleteImageKeyCacheByGid(gid);
|
||||||
return null;
|
imageCacheMapper.deleteGidToKey(gid);
|
||||||
}
|
} catch (Exception e) {
|
||||||
|
log.warn("回滚图片索引缓存失败 gid={}", gid, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//检查该图片缓存是否存在
|
public Callable<?> getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) {
|
||||||
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
|
Path directory = Path.of(cachePath, gid);
|
||||||
if(gidToKey == null)
|
String name = String.valueOf(page);
|
||||||
try {
|
Path cached = ImageFileCache.find(directory, name);
|
||||||
log.error("未缓存gid:" + gid);
|
if (cached != null) {
|
||||||
response.sendError(404);
|
FileDownload.export(request, response, cached.toString());
|
||||||
return null;
|
return null;
|
||||||
}catch (IOException ignored){
|
}
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () -> {
|
return () -> {
|
||||||
if(response.isCommitted()) {
|
if (response.isCommitted()) return null;
|
||||||
log.info("连接已关闭: gid=" + gid + " page=" + page);
|
try {
|
||||||
return null;
|
Path image = ImageFileCache.get(directory, name, () -> {
|
||||||
}
|
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
|
||||||
|
ImageKeyCache imageKey = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page);
|
||||||
String imageUrl = null;
|
if (gidToKey == null || imageKey == null)
|
||||||
//获取该图片key
|
throw new IOException("图片索引不存在");
|
||||||
ImageKeyCache imageKeyCache = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page);
|
for (int attempt = 0; attempt < 2; attempt++) {
|
||||||
if (imageKeyCache == null) {
|
String url = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKey);
|
||||||
CustomUtil.fourZeroFour(response);
|
if (url != null) return url;
|
||||||
return null;
|
GalleryUtil.refreshMpvKey(gidToKey.toUrl());
|
||||||
}
|
}
|
||||||
|
throw new IOException("无法获取图片地址");
|
||||||
//获取图片地址
|
});
|
||||||
for (int i = 0; i < 2; i++) {
|
FileDownload.export(request, response, image.toString());
|
||||||
imageUrl = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKeyCache);
|
} catch (InterruptedException e) {
|
||||||
if (imageUrl != null)
|
Thread.currentThread().interrupt();
|
||||||
break;
|
if (!response.isCommitted()) response.sendError(503);
|
||||||
GalleryUtil.refreshMpvKey(gidToKey.toUrl());
|
} catch (Exception e) {
|
||||||
}
|
log.warn("获取在线图片失败: gid={} page={} errorType={}", gid, page, e.getClass().getSimpleName());
|
||||||
|
if (!response.isCommitted()) response.sendError(404);
|
||||||
if (imageUrl == null) {
|
}
|
||||||
CustomUtil.fourZeroFour(response);
|
return null;
|
||||||
log.error("获取图片url失败:gid=" + gid + " page=" + page + " imageKey=" + imageKeyCache.getImgkey());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
//下载图片,转格式并返回
|
|
||||||
String suffix = imageUrl.substring(imageUrl.lastIndexOf("."));
|
|
||||||
String imagePath = cachePath + gid + "/" + page + suffix;
|
|
||||||
try {
|
|
||||||
new URI(imageUrl).toURL().openConnection().getInputStream().transferTo(new FileOutputStream(imagePath));
|
|
||||||
}catch (Exception e){
|
|
||||||
log.error("下载图片失败:url" + imageUrl);
|
|
||||||
e.printStackTrace();
|
|
||||||
CustomUtil.fourZeroFour(response);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!suffix.equals(".gif"))
|
|
||||||
imagePath = GalleryUtil.convertImg(imagePath, suffix);
|
|
||||||
FileDownload.export(request, response, imagePath);
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,4 +527,19 @@ public class GalleryManageService {
|
|||||||
|
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String retryGallery(int gid){
|
||||||
|
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||||
|
if(gallery == null)
|
||||||
|
return Response._failure("任务不存在");
|
||||||
|
if("下载完成".equals(gallery.getStatus()))
|
||||||
|
return Response._success("下载完成");
|
||||||
|
if(remoteService.isDead())
|
||||||
|
return Response._failure("节点不在线,无法重试");
|
||||||
|
|
||||||
|
RemoteService.RetryResult retryResult = remoteService.retryGallery(gallery);
|
||||||
|
if(retryResult.success())
|
||||||
|
return Response._success(retryResult.message());
|
||||||
|
return Response._failure(retryResult.message());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,301 +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 com.lion.lionwebsite.Util.GalleryUtil;
|
|
||||||
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 java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
PushService pushService;
|
|
||||||
|
|
||||||
RemoteService remoteService;
|
|
||||||
|
|
||||||
public LocalServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, GalleryMapper galleryMapper, PushService pushService, RemoteService remoteService) {
|
|
||||||
this.shareFileMapper = shareFileMapper;
|
|
||||||
this.configurationMapper = configurationMapper;
|
|
||||||
this.galleryMapper = galleryMapper;
|
|
||||||
this.pushService = pushService;
|
|
||||||
this.remoteService = remoteService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查连接是否有效,如果无效自动重连
|
|
||||||
*/
|
|
||||||
@Scheduled(cron = "0 0/30 * * * *")
|
|
||||||
public void CheckConnectionAvailability(){
|
|
||||||
if (remoteService.isDead()){
|
|
||||||
remoteService.initChannel();
|
|
||||||
pushService.sendToMe("主动检测连接已断开,自动进行重连");
|
|
||||||
System.out.println("主动检测连接已断开,自动进行重连");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -1 为对方没有返回
|
|
||||||
if (remoteService.checkAvailability() == -1) {
|
|
||||||
String result = switch (remoteService.reconnect()){
|
|
||||||
case 0 -> "重连成功";
|
|
||||||
case -1 -> "重连失败";
|
|
||||||
case -2 -> "当前未连接,不进行重连";
|
|
||||||
default -> "未知错误";
|
|
||||||
};
|
|
||||||
pushService.sendToMe("主动检测连接无数据返回,自动进行重连:" + result);
|
|
||||||
System.out.println("主动检测连接无数据返回,自动进行重连:" + 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定时更新订阅
|
|
||||||
* @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();
|
|
||||||
Pattern pattern = Pattern.compile("-?\\d+(\\.\\d+)?");
|
|
||||||
|
|
||||||
//过滤高倍率节点
|
|
||||||
for(String node: v2rayPlain){
|
|
||||||
String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8);
|
|
||||||
if(name.contains("流量")){
|
|
||||||
Matcher matcher = pattern.matcher(name.substring(name.indexOf("(") + 1, name.indexOf(")")));
|
|
||||||
|
|
||||||
if (matcher.find()) {
|
|
||||||
// 将匹配到的数字添加到列表中
|
|
||||||
float ratio = Float.parseFloat(matcher.group());
|
|
||||||
if(ratio <= 2) {
|
|
||||||
stringBuilder.append(node).append("\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,375 +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.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;
|
|
||||||
|
|
||||||
PushService pushService;
|
|
||||||
|
|
||||||
public PersonalServiceImpl(CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper, TaskHandlerInterceptor taskHandlerInterceptor, PushService pushService){
|
|
||||||
this.configurationMapper = configurationMapper;
|
|
||||||
this.userMapper = userMapper;
|
|
||||||
this.shareFileMapper = shareFileMapper;
|
|
||||||
this.taskHandlerInterceptor = taskHandlerInterceptor;
|
|
||||||
this.pushService = pushService;
|
|
||||||
compressThreadPool = Executors.newFixedThreadPool(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取文件列表,同时带上分享码以及过期时间
|
|
||||||
* @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 {
|
|
||||||
TarArchiveEntry 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 {
|
|
||||||
TarArchiveEntry entry = new TarArchiveEntry(file.toFile(), dirPath.relativize(file).toString());
|
|
||||||
aos.putArchiveEntry(entry);
|
|
||||||
IOUtils.copy(Files.newInputStream(file.toFile().toPath()), aos);
|
|
||||||
aos.closeArchiveEntry();
|
|
||||||
return super.visitFile(file, attrs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
File targetFile = new File(finalPath + ".tar***undone");
|
|
||||||
log.info("打包成功,重命名:" + targetFile.renameTo(new File(finalPath + ".tar")));
|
|
||||||
}catch (IOException e){
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String message2me(String message){
|
|
||||||
Response response = Response.generateResponse();
|
|
||||||
pushService.sendToMe(message);
|
|
||||||
return response.success().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,111 +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.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Calendar;
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,36 +3,37 @@ package com.lion.lionwebsite.Service;
|
|||||||
import com.lion.lionwebsite.Domain.Gallery;
|
import com.lion.lionwebsite.Domain.Gallery;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.pengrad.telegrambot.TelegramBot;
|
import com.pengrad.telegrambot.TelegramBot;
|
||||||
import com.pengrad.telegrambot.UpdatesListener;
|
|
||||||
import com.pengrad.telegrambot.model.Update;
|
|
||||||
import com.pengrad.telegrambot.request.SendMessage;
|
import com.pengrad.telegrambot.request.SendMessage;
|
||||||
import jakarta.annotation.Resource;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class PushService {
|
public class PushService {
|
||||||
|
|
||||||
long self = 686839482;
|
long self = 686839482;
|
||||||
|
|
||||||
@Resource
|
private static final DateTimeFormatter COMPLETION_TIME_FORMATTER =
|
||||||
TelegramBot bot;
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
|
||||||
|
|
||||||
public PushService(TelegramBot bot) {
|
final TelegramBot bot;
|
||||||
this.bot = bot;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void taskCreateReport(String username, String link, Response response){
|
public void taskCreateReport(String username, String taskName, Response response){
|
||||||
if(response.isSuccess())
|
if(response.isSuccess())
|
||||||
sendToMe(String.format("用户%s提交下载%s", username, link));
|
sendToMe(String.format("用户%s提交下载任务:%s", username, taskName));
|
||||||
else
|
else
|
||||||
sendToMe(String.format("用户%s提交下载%s , 下载失败:%s", username, link, response.get("data")));
|
sendToMe(String.format("用户%s提交下载任务:%s,下载失败:%s", username, taskName, response.get("data")));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void downloadComplete(Gallery gallery){
|
public void downloadComplete(Gallery gallery){
|
||||||
sendToMe(gallery.getLink() + " 下载完成");
|
String completionTime = COMPLETION_TIME_FORMATTER.format(Instant.now());
|
||||||
|
sendToMe(String.format("任务下载完成:%s\n完成时间:%s", gallery.getName(), completionTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void storageNodeOnline(){
|
public void storageNodeOnline(){
|
||||||
@@ -44,7 +45,7 @@ public class PushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void sendToMe(String text){
|
public void sendToMe(String text){
|
||||||
System.out.println(text);
|
log.info(text);
|
||||||
SendMessage sendMessage = new SendMessage(self, text);
|
SendMessage sendMessage = new SendMessage(self, text);
|
||||||
bot.execute(sendMessage);
|
bot.execute(sendMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package com.lion.lionwebsite.Service;
|
|||||||
import com.lion.lionwebsite.Domain.GalleryForQuery;
|
import com.lion.lionwebsite.Domain.GalleryForQuery;
|
||||||
import com.lion.lionwebsite.Util.FileDownload;
|
import com.lion.lionwebsite.Util.FileDownload;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
|
import com.lion.lionwebsite.Util.ImageFileCache;
|
||||||
|
import java.nio.file.Path;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import jakarta.servlet.ServletOutputStream;
|
import jakarta.servlet.ServletOutputStream;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -18,9 +19,12 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
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.fourZeroFour;
|
||||||
|
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -31,17 +35,20 @@ public class QueryService {
|
|||||||
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 result;
|
String result;
|
||||||
String param = "?f_search=" + keyword.replace(" ", "+") + "&f_sft=on&f_sfu=on&f_sfl=on";
|
// 关键词来自用户输入,必须编码后再拼进查询串:未编码的 &、#、中文、空格
|
||||||
|
// 会被上游当成额外的查询参数或截断,搜索行为不可预期。
|
||||||
|
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{
|
||||||
result = GalleryUtil.requests("https://exhentai.org/" + param, "get", null, null);
|
result = GalleryUtil.requests("https://exhentai.org/" + param, "get", null, null);
|
||||||
}catch (IOException e){
|
}catch (IOException e){
|
||||||
e.printStackTrace();
|
log.error("query failure", e);
|
||||||
response.failure("query failure");
|
response.failure("query failure");
|
||||||
return response.toJSONString();
|
return response.toJSONString();
|
||||||
}
|
}
|
||||||
@@ -75,7 +82,7 @@ public class QueryService {
|
|||||||
galleries.add(gallery);
|
galleries.add(gallery);
|
||||||
}
|
}
|
||||||
|
|
||||||
response.success(new ObjectMapper().valueToTree(galleries).toString());
|
response.success(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"));
|
||||||
@@ -103,22 +110,16 @@ public class QueryService {
|
|||||||
String fileName = path.substring(path.lastIndexOf("/") + 1);
|
String fileName = path.substring(path.lastIndexOf("/") + 1);
|
||||||
String suffix = fileName.substring(fileName.lastIndexOf("."));
|
String suffix = fileName.substring(fileName.lastIndexOf("."));
|
||||||
fileName = fileName.substring(0, fileName.lastIndexOf("."));
|
fileName = fileName.substring(0, fileName.lastIndexOf("."));
|
||||||
File image = new File(CachePath, fileName + ".avif");
|
String sourceUrl = "https://ehgt.org/" + path;
|
||||||
|
try {
|
||||||
if(image.isFile()){
|
Path image = ImageFileCache.get(Path.of(CachePath), fileName, () -> sourceUrl);
|
||||||
FileDownload.export(request, response, image.getAbsolutePath());
|
FileDownload.export(request, response, image.toString());
|
||||||
return;
|
} catch (InterruptedException e) {
|
||||||
}
|
Thread.currentThread().interrupt();
|
||||||
|
response.setStatus(503);
|
||||||
path = "https://ehgt.org/" + path;
|
} catch (Exception e) {
|
||||||
try(ServletOutputStream outputStream = response.getOutputStream()){
|
log.warn("获取缩略图失败: errorType={}", e.getClass().getSimpleName());
|
||||||
new URI(path).toURL().openConnection().getInputStream().transferTo(new FileOutputStream(CachePath + fileName + suffix));
|
if (!response.isCommitted()) response.setStatus(404);
|
||||||
GalleryUtil.convertImg(CachePath + fileName + suffix, suffix);
|
|
||||||
FileInputStream inputStream = new FileInputStream(image.getAbsoluteFile()); //如果放到括号里,会导致图片未创建时创建文件流失败报错
|
|
||||||
outputStream.write(inputStream.readAllBytes());
|
|
||||||
inputStream.close();
|
|
||||||
}catch (IOException | URISyntaxException e){
|
|
||||||
System.out.println(e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,87 +12,151 @@ 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.io.OutputStream;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Data
|
@Data
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class RemoteService {
|
public class RemoteService {
|
||||||
|
|
||||||
ChannelFuture channelFuture;
|
volatile ChannelFuture channelFuture;
|
||||||
|
|
||||||
Channel channel;
|
volatile Channel channel;
|
||||||
|
|
||||||
String ip = "5.255.110.45";
|
@Value("${remote.ip:5.255.110.45}")
|
||||||
|
String ip;
|
||||||
|
|
||||||
short port = 26321;
|
short port = 26321;
|
||||||
|
|
||||||
GalleryMapper galleryMapper;
|
final GalleryMapper galleryMapper;
|
||||||
|
|
||||||
PushService pushService;
|
final PushService pushService;
|
||||||
|
|
||||||
HashMap<Integer, Promise<AbstractMessage>> promiseHashMap;
|
ConcurrentHashMap<Integer, Promise<AbstractMessage>> promiseHashMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
EventLoop eventLoopGroup;
|
ConcurrentHashMap<Integer, CopyOnWriteArrayList<CompletableFuture<String>>> retryStatusWaiters =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
ExecutorService downloadThread;
|
final EventLoop eventLoopGroup = new DefaultEventLoop();
|
||||||
|
final EventLoopGroup networkGroup = new NioEventLoopGroup(2);
|
||||||
|
final AtomicBoolean connecting = new AtomicBoolean();
|
||||||
|
final AtomicBoolean monitoring = new AtomicBoolean();
|
||||||
|
volatile boolean stopping;
|
||||||
|
volatile ServerSocket monitorSocket;
|
||||||
|
|
||||||
Thread monitor;
|
Thread monitor;
|
||||||
|
|
||||||
AtomicInteger atomicInteger;
|
AtomicInteger atomicInteger = new AtomicInteger(0);
|
||||||
|
|
||||||
WebSocketService webSocketService;
|
final WebSocketService webSocketService;
|
||||||
|
|
||||||
public RemoteService(GalleryMapper galleryMapper, PushService pushService, WebSocketService webSocketService){
|
final SubscriptionStandbySnapshotService subscriptionStandbySnapshotService;
|
||||||
this.galleryMapper = galleryMapper;
|
|
||||||
this.pushService = pushService;
|
|
||||||
atomicInteger = new AtomicInteger(0);
|
|
||||||
eventLoopGroup = new DefaultEventLoop();
|
|
||||||
downloadThread = Executors.newCachedThreadPool();
|
|
||||||
promiseHashMap = new HashMap<>();
|
|
||||||
this.webSocketService = webSocketService;
|
|
||||||
|
|
||||||
if(!initChannel()){ //如果远程服务器连接失败,则开启本地监听
|
final ExecutorService subscriptionSyncExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||||
monitor = new Thread(this::monitorFunc);
|
Thread thread = new Thread(r, "subscription-standby-sync");
|
||||||
monitor.start();
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理节点上报的任务状态。原先这些 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 {
|
try {
|
||||||
|
if (!isDead())
|
||||||
|
return true;
|
||||||
int i;
|
int i;
|
||||||
for(i=0; i<20; i++) {
|
for(i=0; i<20; i++) {
|
||||||
try {
|
try {
|
||||||
channelFuture = new Bootstrap()
|
channelFuture = new Bootstrap()
|
||||||
.channel(NioSocketChannel.class)
|
.channel(NioSocketChannel.class)
|
||||||
.group(new NioEventLoopGroup())
|
.group(networkGroup)
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000)
|
||||||
.handler(new ChannelInitializer<NioSocketChannel>() {
|
.handler(new ChannelInitializer<NioSocketChannel>() {
|
||||||
@Override
|
@Override
|
||||||
protected void initChannel(NioSocketChannel channel) {
|
protected void initChannel(NioSocketChannel channel) {
|
||||||
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
|
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
|
||||||
channel.pipeline().addLast(new MessageCodec());
|
channel.pipeline().addLast(new MessageCodec());
|
||||||
channel.pipeline().addLast(new LoggingHandler());
|
// 只记录事件和字节数,避免把订阅正文、签名等消息内容写入日志。
|
||||||
|
channel.pipeline().addLast(new LoggingHandler(io.netty.handler.logging.LogLevel.DEBUG, ByteBufFormat.SIMPLE));
|
||||||
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
|
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
|
||||||
}
|
}
|
||||||
}).connect(new InetSocketAddress(ip, port + i)).sync();
|
}).connect(new InetSocketAddress(ip, port + i)).sync();
|
||||||
break;
|
break;
|
||||||
}catch (Exception e){
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
log.error("连接storageNode失败,端口偏移量(重试次数):{}", i);
|
log.error("连接storageNode失败,端口偏移量(重试次数):{}", i);
|
||||||
}
|
}
|
||||||
|
if (stopping)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
//超过二十次连不上,主动抛出错误,由下方catch
|
//超过二十次连不上,主动抛出错误,由下方catch
|
||||||
@@ -100,20 +164,31 @@ public class RemoteService {
|
|||||||
throw new Exception();
|
throw new Exception();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stopping) {
|
||||||
|
channelFuture.channel().close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
log.info("connect success");
|
log.info("connect success");
|
||||||
if(pushService != null)
|
if(pushService != null)
|
||||||
pushService.storageNodeOnline();
|
pushService.storageNodeOnline();
|
||||||
|
|
||||||
channel = channelFuture.channel();
|
channel = channelFuture.channel();
|
||||||
|
closeMonitorSocket();
|
||||||
channel.writeAndFlush(new IdentityMessage("lionwebsite"));
|
channel.writeAndFlush(new IdentityMessage("lionwebsite"));
|
||||||
|
|
||||||
//子节点上线时,发送未完成的任务
|
//子节点上线时,发送未完成的任务
|
||||||
resetUndone();
|
resetUndone();
|
||||||
|
// 节点刚上线时可能没有任何快照(例如刚重启),必须推一次;
|
||||||
|
// 这同时会刷新节点「最近收到快照」的时刻,避免内容未变时被判过期。
|
||||||
|
requestSubscriptionSync();
|
||||||
return true;
|
return true;
|
||||||
}catch (Exception e){
|
}catch (Exception e){
|
||||||
e.printStackTrace();
|
log.error("connect node failed, wait for node back online", e);
|
||||||
log.info("connect node failed, wait for node back online");
|
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
connecting.set(false);
|
||||||
|
if (isDead())
|
||||||
|
startMonitor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +198,7 @@ public class RemoteService {
|
|||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
channelFuture.channel().close();
|
channelFuture.channel().close().awaitUninterruptibly();
|
||||||
|
|
||||||
if(initChannel()){
|
if(initChannel()){
|
||||||
return 0;
|
return 0;
|
||||||
@@ -133,27 +208,110 @@ public class RemoteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public byte checkAvailability(){
|
public byte checkAvailability(){
|
||||||
AvailableCheckMessage acm = new AvailableCheckMessage();
|
return sendRequest(new AvailableCheckMessage(), 10, TimeUnit.SECONDS);
|
||||||
acm.setMessageId(atomicInteger.getAndIncrement());
|
}
|
||||||
|
|
||||||
channel.writeAndFlush(acm);
|
byte sendRequest(AbstractMessage message, long timeout, TimeUnit unit) {
|
||||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
Channel target = channel;
|
||||||
promiseHashMap.put(acm.messageId, promise);
|
if (stopping || target == null || !target.isActive())
|
||||||
try {
|
|
||||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
|
||||||
if(result){
|
|
||||||
ResponseMessage rsm = (ResponseMessage)promise.getNow();
|
|
||||||
return rsm.getResult();
|
|
||||||
}
|
|
||||||
else return -1;
|
|
||||||
}catch (InterruptedException e){
|
|
||||||
e.printStackTrace();
|
|
||||||
return -1;
|
return -1;
|
||||||
|
message.setMessageId(atomicInteger.getAndIncrement());
|
||||||
|
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||||
|
promiseHashMap.put(message.messageId, promise);
|
||||||
|
try {
|
||||||
|
target.writeAndFlush(message).addListener(future -> {
|
||||||
|
if (!future.isSuccess())
|
||||||
|
promise.tryFailure(future.cause() == null ? new IOException("节点发送失败") : future.cause());
|
||||||
|
});
|
||||||
|
if (promise.await(timeout, unit) && promise.isSuccess()
|
||||||
|
&& promise.getNow() instanceof ResponseMessage response)
|
||||||
|
return response.getResult();
|
||||||
|
return -1;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return -1;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("节点请求失败: messageId={}", message.messageId, e);
|
||||||
|
return -1;
|
||||||
|
} finally {
|
||||||
|
promiseHashMap.remove(message.messageId, promise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 请求将当前全部订阅状态异步同步到存储节点,短时间内的多次请求会合并。 */
|
||||||
|
public void requestSubscriptionSync() {
|
||||||
|
if (stopping || !subscriptionSyncEnabled)
|
||||||
|
return;
|
||||||
|
subscriptionSyncQueued.set(true);
|
||||||
|
if (!subscriptionSyncRunning.compareAndSet(false, true))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
subscriptionSyncExecutor.execute(this::drainSubscriptionSyncQueue);
|
||||||
|
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||||
|
subscriptionSyncRunning.set(false);
|
||||||
|
if (!stopping) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里刻意没有「定期重发整份快照」的定时任务:
|
||||||
|
// 快照只在订阅内容变化时推送(各处 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(){
|
public void resetUndone(){
|
||||||
@@ -173,69 +331,98 @@ public class RemoteService {
|
|||||||
GalleryTask galleryTask = new GalleryTask();
|
GalleryTask galleryTask = new GalleryTask();
|
||||||
galleryTask.setGid(gallery.getGid());
|
galleryTask.setGid(gallery.getGid());
|
||||||
galleryTask.setName(gallery.getName());
|
galleryTask.setName(gallery.getName());
|
||||||
|
DownloadPostMessage message = new DownloadPostMessage();
|
||||||
|
message.setGalleryTask(galleryTask);
|
||||||
|
return sendRequest(message, 10, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
DownloadPostMessage dpm = new DownloadPostMessage();
|
public RetryResult retryGallery(Gallery gallery){
|
||||||
dpm.messageId = atomicInteger.getAndIncrement();
|
CompletableFuture<String> statusFuture = new CompletableFuture<>();
|
||||||
dpm.setGalleryTask(galleryTask);
|
retryStatusWaiters.computeIfAbsent(gallery.getGid(), ignored -> new CopyOnWriteArrayList<>())
|
||||||
channel.writeAndFlush(dpm);
|
.add(statusFuture);
|
||||||
|
|
||||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
|
||||||
promiseHashMap.put(dpm.messageId, promise);
|
|
||||||
try {
|
try {
|
||||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
byte submitResult = addGalleryToQueue(gallery);
|
||||||
if(result){
|
if(submitResult != 0 && !statusFuture.isDone())
|
||||||
ResponseMessage rsm = (ResponseMessage)promise.getNow();
|
return new RetryResult(false, "节点未接受重试请求");
|
||||||
return rsm.getResult();
|
return new RetryResult(true, statusFuture.get(10, TimeUnit.SECONDS));
|
||||||
}
|
}catch (TimeoutException e){
|
||||||
else return -1;
|
return new RetryResult(false, "节点已收到重试请求,但未及时返回任务状态");
|
||||||
|
}catch (ExecutionException e){
|
||||||
|
log.warn("等待重试状态失败, gid={}", gallery.getGid(), e);
|
||||||
|
return new RetryResult(false, "获取任务状态失败");
|
||||||
}catch (InterruptedException e){
|
}catch (InterruptedException e){
|
||||||
e.printStackTrace();
|
log.warn("等待重试状态被中断, gid={}", gallery.getGid(), e);
|
||||||
return -1;
|
Thread.currentThread().interrupt();
|
||||||
|
return new RetryResult(false, "获取任务状态被中断");
|
||||||
|
}finally {
|
||||||
|
retryStatusWaiters.computeIfPresent(gallery.getGid(), (gid, waiters) -> {
|
||||||
|
waiters.remove(statusFuture);
|
||||||
|
return waiters.isEmpty() ? null : waiters;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte deleteGallery(Gallery gallery){
|
private void completeRetryStatusWaiters(int gid, String status){
|
||||||
DeleteGalleryMessage dgm = new DeleteGalleryMessage();
|
CopyOnWriteArrayList<CompletableFuture<String>> waiters = retryStatusWaiters.remove(gid);
|
||||||
dgm.setGalleryName(gallery.getName());
|
if(waiters != null)
|
||||||
dgm.messageId = atomicInteger.getAndIncrement();
|
waiters.forEach(waiter -> waiter.complete(status));
|
||||||
|
}
|
||||||
|
|
||||||
channel.writeAndFlush(dgm);
|
public record RetryResult(boolean success, String message) {}
|
||||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
|
||||||
promiseHashMap.put(dgm.messageId, promise);
|
public byte deleteGallery(Gallery gallery){
|
||||||
try{
|
DeleteGalleryMessage message = new DeleteGalleryMessage();
|
||||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
message.setGalleryName(gallery.getName());
|
||||||
if(result){
|
return sendRequest(message, 10, TimeUnit.SECONDS);
|
||||||
ResponseMessage rsm = (ResponseMessage) promise.getNow();
|
}
|
||||||
return rsm.getResult();
|
|
||||||
}else return -1;
|
private void startMonitor() {
|
||||||
}catch (InterruptedException e){
|
if (stopping || !monitoring.compareAndSet(false, true))
|
||||||
return -1;
|
return;
|
||||||
|
monitor = new Thread(this::monitorFunc, "storage-node-monitor");
|
||||||
|
monitor.setDaemon(true);
|
||||||
|
monitor.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeMonitorSocket() {
|
||||||
|
ServerSocket socket = monitorSocket;
|
||||||
|
if (socket != null) {
|
||||||
|
try { socket.close(); }
|
||||||
|
catch (IOException e) { log.debug("关闭节点监听失败", e); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void monitorFunc(){
|
public void monitorFunc(){
|
||||||
int real_port = CustomUtil._findIdlePort(port + 1);
|
try (ServerSocket socket = new ServerSocket(CustomUtil._findIdlePort(port + 1))) {
|
||||||
log.info("监听端口: {}等待节点上线", real_port);
|
monitorSocket = socket;
|
||||||
try(ServerSocket socket = new ServerSocket(real_port)) {
|
if (stopping || !isDead())
|
||||||
Socket client;
|
return;
|
||||||
while(true){
|
log.info("监听端口: {}等待节点上线", socket.getLocalPort());
|
||||||
client = socket.accept();
|
while (!stopping) {
|
||||||
|
try (Socket client = socket.accept()) {
|
||||||
if(client.getInetAddress().getHostAddress().equals("5.255.110.45")){
|
if (!client.getInetAddress().getHostAddress().equals(ip))
|
||||||
//连接之后发送lionwebsite,否则存储节点不能确认这个端口是否有效
|
continue;
|
||||||
OutputStream outputStream = client.getOutputStream();
|
OutputStream output = client.getOutputStream();
|
||||||
outputStream.write("lionwebsite".getBytes());
|
output.write("lionwebsite".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
outputStream.flush();
|
output.flush();
|
||||||
outputStream.close();
|
client.shutdownOutput();
|
||||||
log.info("尝试连接");
|
if (initChannel())
|
||||||
initChannel();
|
break;
|
||||||
client.close();
|
|
||||||
socket.close();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
if (!stopping && isDead())
|
||||||
|
log.warn("等待节点上线失败", e);
|
||||||
|
} finally {
|
||||||
|
monitorSocket = null;
|
||||||
|
monitoring.set(false);
|
||||||
|
// A connection may flap while the previous monitor is still exiting.
|
||||||
|
if (!stopping && isDead()) {
|
||||||
|
try { networkGroup.next().schedule(this::startMonitor, 5, TimeUnit.SECONDS); }
|
||||||
|
catch (java.util.concurrent.RejectedExecutionException e) {
|
||||||
|
if (!stopping) log.warn("安排节点监听重试失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,45 +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();
|
||||||
for (GalleryTask galleryTask : galleryTasks) {
|
// 落库与通知可能较慢(含 Telegram 推送),交给单线程顺序执行,
|
||||||
Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid());
|
// 不再占用 Netty IO 线程;异常在此兜住,避免打进 IO 线程。
|
||||||
gallery.setProceeding(galleryTask.getProceeding());
|
statusApplyExecutor.execute(() -> {
|
||||||
|
try {
|
||||||
if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
|
applyReportedStatus(galleryTasks);
|
||||||
gallery.setName(galleryTask.getName());
|
} catch (Exception e) {
|
||||||
|
log.warn("处理节点上报的任务状态失败", e);
|
||||||
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
|
|
||||||
gallery.setStatus("下载完成");
|
|
||||||
pushService.downloadComplete(gallery);
|
|
||||||
}
|
}
|
||||||
else if(galleryTask.getStatus() == GalleryTask.COMPRESSING)
|
});
|
||||||
gallery.setStatus("压缩中");
|
|
||||||
else if(galleryTask.getProceeding() != 0)
|
|
||||||
gallery.setStatus("下载中");
|
|
||||||
|
|
||||||
log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages());
|
|
||||||
galleryMapper.updateGallery(gallery);
|
|
||||||
}
|
|
||||||
webSocketService.updateTaskProcessing(galleryTasks);
|
|
||||||
}
|
}
|
||||||
else if(msg instanceof ResponseMessage rsm)
|
else if(msg instanceof ResponseMessage rsm) {
|
||||||
promiseHashMap.get(rsm.messageId).setSuccess(rsm);
|
Promise<AbstractMessage> promise = promiseHashMap.remove(rsm.messageId);
|
||||||
|
if(promise != null)
|
||||||
|
promise.trySuccess(rsm);
|
||||||
|
else
|
||||||
|
log.warn("收到无等待者的响应消息: messageId={}", rsm.messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把节点上报的任务状态写回库里并推送给前端,按上报顺序逐个处理。 */
|
||||||
|
private void applyReportedStatus(GalleryTask[] galleryTasks) {
|
||||||
|
for (GalleryTask galleryTask : galleryTasks) {
|
||||||
|
Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid());
|
||||||
|
if (gallery == null) {
|
||||||
|
log.warn("收到节点上报的未知任务状态,已忽略: gid={}, name={}",
|
||||||
|
galleryTask.getGid(), galleryTask.getName());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
gallery.setProceeding(galleryTask.getProceeding());
|
||||||
|
|
||||||
|
if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
|
||||||
|
gallery.setName(galleryTask.getName());
|
||||||
|
|
||||||
|
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
|
||||||
|
boolean justCompleted = !"下载完成".equals(gallery.getStatus());
|
||||||
|
gallery.setStatus("下载完成");
|
||||||
|
if (justCompleted)
|
||||||
|
pushService.downloadComplete(gallery);
|
||||||
|
}
|
||||||
|
else if(galleryTask.getStatus() == GalleryTask.COMPRESSING)
|
||||||
|
gallery.setStatus("压缩中");
|
||||||
|
else if(galleryTask.getStatus() == GalleryTask.DOWNLOAD_COMPLETE)
|
||||||
|
gallery.setStatus("等待压缩");
|
||||||
|
else if(galleryTask.getStatus() == GalleryTask.DOWNLOADING)
|
||||||
|
gallery.setStatus("下载中");
|
||||||
|
|
||||||
|
log.info(gallery.getName() + "下载进度:" + gallery.getProceeding() + "/" + gallery.getPages());
|
||||||
|
galleryMapper.updateGallery(gallery);
|
||||||
|
completeRetryStatusWaiters(gallery.getGid(), gallery.getStatus());
|
||||||
|
}
|
||||||
|
webSocketService.updateTaskProcessing(galleryTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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();
|
startMonitor();
|
||||||
monitor = new Thread(RemoteService.this::monitorFunc);
|
}
|
||||||
monitor.start();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package com.lion.lionwebsite.Service;
|
|||||||
|
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
import com.lion.lionwebsite.Domain.SubBind;
|
import com.lion.lionwebsite.Domain.SubBind;
|
||||||
import com.lion.lionwebsite.Domain.SubUpdateRecord;
|
import com.lion.lionwebsite.Domain.SubUpdateRecord;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.FileDownload;
|
import com.lion.lionwebsite.Util.FileDownload;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
import org.jsoup.select.Elements;
|
import org.jsoup.select.Elements;
|
||||||
@@ -17,111 +20,315 @@ 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
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class SubService {
|
@RequiredArgsConstructor
|
||||||
SubMapper subMapper;
|
public class SubService {
|
||||||
|
final SubMapper subMapper;
|
||||||
|
final UserMapper userMapper;
|
||||||
|
final SubscriptionRefreshService refreshService;
|
||||||
|
final RemoteService remoteService;
|
||||||
|
final SubscriptionStateCoordinator stateCoordinator;
|
||||||
|
|
||||||
public SubService(SubMapper subMapper) {
|
/**
|
||||||
this.subMapper = subMapper;
|
* 归属地查询是同步外呼(最长十几秒),且失败只影响记录里的一列展示。
|
||||||
|
* 若放在订阅分发路径上,用户取一次订阅就得先等它完成,因此改为后台补齐。
|
||||||
|
*
|
||||||
|
* <p>非 final:测试可替换为受控执行器,避免在单测里触发真实外呼。
|
||||||
|
*/
|
||||||
|
Executor locationExecutor = newFixedLocationExecutor();
|
||||||
|
|
||||||
|
private static ExecutorService newFixedLocationExecutor() {
|
||||||
|
return Executors.newFixedThreadPool(2, r -> {
|
||||||
|
Thread thread = new Thread(r, "sub-location-resolver");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
SubscriptionAccount account;
|
||||||
|
Lock lock = stateCoordinator.writeLock();
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
|
||||||
|
return response.failure("名称和上游 key 不能为空").toJSONString();
|
||||||
|
if (subMapper.countSubscriptionAccountName(name.trim()) > 0 || subMapper.countSubscriptionAccountKey(upstreamKey.trim()) > 0)
|
||||||
|
return response.failure("名称或上游 key 已存在").toJSONString();
|
||||||
|
account = new SubscriptionAccount();
|
||||||
|
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 insertSubBind(String user){
|
public String listSubscriptionAccounts() {
|
||||||
|
ArrayList<SubscriptionAccount> accounts = subMapper.selectAllSubscriptionAccounts();
|
||||||
|
for (SubscriptionAccount account : accounts) {
|
||||||
|
account.setV2Url(refreshService.v2Url(account));
|
||||||
|
account.setClashUrl(refreshService.clashUrl(account));
|
||||||
|
}
|
||||||
|
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(accounts)).toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String updateSubscriptionAccount(Integer id, String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
|
SubscriptionAccount account;
|
||||||
|
Lock lock = stateCoordinator.writeLock();
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
account = subMapper.selectSubscriptionAccount(id);
|
||||||
|
if (account == null)
|
||||||
|
return response.failure("子账号不存在").toJSONString();
|
||||||
|
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
|
||||||
|
return response.failure("名称和上游 key 不能为空").toJSONString();
|
||||||
|
for (SubscriptionAccount existing : subMapper.selectAllSubscriptionAccounts()) {
|
||||||
|
if (!existing.getId().equals(id) && (existing.getName().equals(name.trim()) || existing.getUpstreamKey().equals(upstreamKey.trim())))
|
||||||
|
return response.failure("名称或上游 key 已存在").toJSONString();
|
||||||
|
}
|
||||||
|
boolean changed = !account.getUpstreamKey().equals(upstreamKey.trim())
|
||||||
|
|| account.isFilterHighMultiplier() != filterHighMultiplier || account.isEnabled() != enabled;
|
||||||
|
account.setName(name.trim());
|
||||||
|
account.setUpstreamKey(upstreamKey.trim());
|
||||||
|
account.setFilterHighMultiplier(filterHighMultiplier);
|
||||||
|
account.setEnabled(enabled);
|
||||||
|
subMapper.updateSubscriptionAccount(account);
|
||||||
|
if (changed)
|
||||||
|
refreshService.invalidateCache(id);
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
if (enabled)
|
||||||
|
refreshService.refresh(id);
|
||||||
|
remoteService.requestSubscriptionSync();
|
||||||
|
return response.success(accountJson(account)).toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String deleteSubscriptionAccount(Integer id) {
|
||||||
|
return withWriteLock(() -> deleteSubscriptionAccountUnlocked(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deleteSubscriptionAccountUnlocked(Integer id) {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
|
||||||
|
if (account == null)
|
||||||
|
return response.failure("子账号不存在").toJSONString();
|
||||||
|
if (account.getBoundUserCount() != null && account.getBoundUserCount() > 0)
|
||||||
|
return response.failure("子账号仍绑定用户,请先改绑").toJSONString();
|
||||||
|
subMapper.deleteSubscriptionAccount(id);
|
||||||
|
refreshService.invalidateCache(id);
|
||||||
|
remoteService.requestSubscriptionSync();
|
||||||
|
return response.success("删除成功").toJSONString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String refreshSubscriptionAccount(Integer id) {
|
||||||
|
boolean success = refreshService.refresh(id);
|
||||||
|
remoteService.requestSubscriptionSync();
|
||||||
|
return success ? Response._success("刷新成功") : Response._failure("刷新失败,请查看子账号错误状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String insertSubBind(String user, Integer accountId) {
|
||||||
|
return withWriteLock(() -> insertSubBindUnlocked(user, accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String insertSubBindUnlocked(String user, Integer accountId) {
|
||||||
|
Response response = Response.generateResponse();
|
||||||
|
if (user == null || user.isBlank() || userMapper.selectUserByUsername(user) == null)
|
||||||
|
return response.failure("用户不存在").toJSONString();
|
||||||
|
SubscriptionAccount account = accountId == null ? firstEnabledAccount() : subMapper.selectSubscriptionAccount(accountId);
|
||||||
|
if (account == null || !account.isEnabled())
|
||||||
|
return response.failure("子账号不存在或已停用").toJSONString();
|
||||||
|
if (!refreshService.hasCompleteCache(account.getId()))
|
||||||
|
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
|
||||||
|
if (subMapper.countSubBindByUser(user) > 0)
|
||||||
|
return response.failure("用户已绑定子账号,请使用改绑").toJSONString();
|
||||||
String key = RandomUtil.randomString(8);
|
String key = RandomUtil.randomString(8);
|
||||||
while (subMapper.selectSubBindExist(key))
|
while (subMapper.selectSubBindExist(key))
|
||||||
key = RandomUtil.randomString(8);
|
key = RandomUtil.randomString(8);
|
||||||
|
subMapper.insertSubBind(new SubBind(key, user, account.getId(), account.getName(), account.isEnabled(), account.isFilterHighMultiplier()));
|
||||||
SubBind subBind = new SubBind(key, user);
|
remoteService.requestSubscriptionSync();
|
||||||
subMapper.insertSubBind(subBind);
|
|
||||||
|
|
||||||
return response.success("添加成功").toJSONString();
|
return response.success("添加成功").toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resetKey(String user){
|
public String resetKey(String user) {
|
||||||
Response response = Response.generateResponse();
|
return withWriteLock(() -> resetKeyUnlocked(user));
|
||||||
|
}
|
||||||
|
|
||||||
subMapper.deleteSubBind(user);
|
private String resetKeyUnlocked(String user) {
|
||||||
subMapper.deleteSubUpdateRecord(user);
|
Response response = Response.generateResponse();
|
||||||
|
if (subMapper.countSubBindByUser(user) == 0)
|
||||||
|
return response.failure("绑定不存在").toJSONString();
|
||||||
String key = RandomUtil.randomString(8);
|
String key = RandomUtil.randomString(8);
|
||||||
while (subMapper.selectSubBindExist(key))
|
while (subMapper.selectSubBindExist(key))
|
||||||
key = RandomUtil.randomString(8);
|
key = RandomUtil.randomString(8);
|
||||||
SubBind subBind = new SubBind(key, user);
|
subMapper.updateSubBindKey(user, key);
|
||||||
subMapper.insertSubBind(subBind);
|
subMapper.deleteSubUpdateRecord(user);
|
||||||
|
remoteService.requestSubscriptionSync();
|
||||||
return response.success().toJSONString();
|
return response.success().toJSONString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String selectAllSubBind(){
|
public String rebind(String user, Integer accountId) {
|
||||||
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());
|
||||||
|
}
|
||||||
|
|
||||||
String location;
|
private static String resolveClientIp(HttpServletRequest request) {
|
||||||
|
String remoteAddr = headerAddress(request.getRemoteAddr());
|
||||||
|
if (isLoopback(remoteAddr)) {
|
||||||
|
String forwardedFor = headerAddress(request.getHeader("X-Forwarded-For"));
|
||||||
|
if (forwardedFor != null)
|
||||||
|
return forwardedFor;
|
||||||
|
String realIp = headerAddress(request.getHeader("X-Real-IP"));
|
||||||
|
if (realIp != null)
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
return remoteAddr == null ? "unknown" : remoteAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String headerAddress(String value) {
|
||||||
|
if (value == null || value.isBlank())
|
||||||
|
return null;
|
||||||
|
String address = value.split(",", 2)[0].trim();
|
||||||
|
return address.isBlank() || "unknown".equalsIgnoreCase(address) ? null : address;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLoopback(String address) {
|
||||||
|
return "127.0.0.1".equals(address) || "::1".equals(address)
|
||||||
|
|| "0:0:0:0:0:0:0:1".equals(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordUpdate(String user, String ip, String ua) {
|
||||||
|
// 先落一条 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 {
|
||||||
|
String location = resolveLocation(ip);
|
||||||
|
if (location == null || location.isBlank() || "unknown".equals(location))
|
||||||
|
return;
|
||||||
|
subMapper.updateLatestSubUpdateRecordLocation(user, location);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("补齐订阅更新记录归属地失败 user={}", user, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 IP 归属地。第三方站点结构变化或网络异常都只降级为 unknown,
|
||||||
|
* 绝不让定位失败影响订阅分发本身。
|
||||||
|
*/
|
||||||
|
static String resolveLocation(String ip) {
|
||||||
try {
|
try {
|
||||||
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
|
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
|
||||||
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
|
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
|
||||||
if(tds.size() > 3)
|
return tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim()
|
||||||
location = tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim();
|
: (tds.isEmpty() ? "unknown" : tds.get(1).text());
|
||||||
else
|
} catch (Exception e) {
|
||||||
location = tds.get(1).text();
|
return "unknown";
|
||||||
}catch (IOException e){
|
|
||||||
log.error("获取ip地址信息失败: {}", e.getMessage());
|
|
||||||
location = "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
SubUpdateRecord subUpdateRecord = new SubUpdateRecord(0, subBind.getUser(), ip, UA, new Date(), location);
|
|
||||||
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){
|
public String deleteSubBind(String user) {
|
||||||
Response response = Response.generateResponse();
|
return withWriteLock(() -> deleteSubBindUnlocked(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deleteSubBindUnlocked(String user) {
|
||||||
subMapper.deleteSubBind(user);
|
subMapper.deleteSubBind(user);
|
||||||
subMapper.deleteSubUpdateRecord(user);
|
subMapper.deleteSubUpdateRecord(user);
|
||||||
return response.success("删除成功").toJSONString();
|
remoteService.requestSubscriptionSync();
|
||||||
|
return Response._success("删除成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String withWriteLock(Supplier<String> action) {
|
||||||
|
Lock stateLock = stateCoordinator.writeLock();
|
||||||
|
stateLock.lock();
|
||||||
|
try {
|
||||||
|
return action.get();
|
||||||
|
} finally {
|
||||||
|
stateLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionAccount firstEnabledAccount() {
|
||||||
|
return subMapper.selectAllSubscriptionAccounts().stream().filter(SubscriptionAccount::isEnabled).findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String accountJson(SubscriptionAccount account) {
|
||||||
|
account.setV2Url(refreshService.v2Url(account));
|
||||||
|
account.setClashUrl(refreshService.clashUrl(account));
|
||||||
|
return CustomUtil.objectMapper.valueToTree(account).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendStatus(HttpServletResponse response, int status, String message) {
|
||||||
|
try {
|
||||||
|
response.sendError(status, message);
|
||||||
|
} catch (IOException ignored) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-23
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import tools.jackson.databind.node.ObjectNode;
|
||||||
import com.lion.lionwebsite.Domain.GalleryTask;
|
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.socket.*;
|
import org.springframework.web.socket.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class WebSocketService implements WebSocketHandler {
|
public class WebSocketService implements WebSocketHandler {
|
||||||
|
|
||||||
ArrayList<WebSocketSession> sessions;
|
CopyOnWriteArrayList<WebSocketSession> sessions;
|
||||||
|
|
||||||
ObjectMapper objectMapper;
|
ObjectMapper objectMapper;
|
||||||
|
|
||||||
public WebSocketService() {
|
public WebSocketService() {
|
||||||
sessions = new ArrayList<>();
|
sessions = new CopyOnWriteArrayList<>();
|
||||||
objectMapper = CustomUtil.objectMapper;
|
objectMapper = CustomUtil.objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,8 @@ public class WebSocketService implements WebSocketHandler {
|
|||||||
sessions.forEach(s -> {
|
sessions.forEach(s -> {
|
||||||
try {
|
try {
|
||||||
s.sendMessage(new TextMessage("{\"type\": \"fullUpdate\"}"));
|
s.sendMessage(new TextMessage("{\"type\": \"fullUpdate\"}"));
|
||||||
} catch (Exception ignore) {
|
} catch (Exception e) {
|
||||||
|
log.warn("WebSocket send fullUpdate failed", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -39,11 +42,13 @@ public class WebSocketService implements WebSocketHandler {
|
|||||||
ObjectNode objectNode = objectMapper.createObjectNode();
|
ObjectNode objectNode = objectMapper.createObjectNode();
|
||||||
objectNode.put("type", "updateTasks");
|
objectNode.put("type", "updateTasks");
|
||||||
objectNode.set("data", objectMapper.valueToTree(galleryTasks));
|
objectNode.set("data", objectMapper.valueToTree(galleryTasks));
|
||||||
System.out.println(objectNode);
|
log.debug("{}", objectNode);
|
||||||
sessions.forEach(s -> {
|
sessions.forEach(s -> {
|
||||||
try {
|
try {
|
||||||
s.sendMessage(new TextMessage(objectNode.toString()));
|
s.sendMessage(new TextMessage(objectNode.toString()));
|
||||||
}catch (Exception ignore){}
|
}catch (Exception e){
|
||||||
|
log.warn("WebSocket send updateTasks failed", e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.lion.lionwebsite.Util;
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
@@ -13,6 +14,7 @@ import java.util.regex.Pattern;
|
|||||||
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
@Slf4j
|
||||||
public class CustomUtil {
|
public class CustomUtil {
|
||||||
|
|
||||||
public static final double ONE_KB = 1024;
|
public static final double ONE_KB = 1024;
|
||||||
@@ -86,6 +88,7 @@ public class CustomUtil {
|
|||||||
ignored.close();
|
ignored.close();
|
||||||
return i;
|
return i;
|
||||||
}catch (IOException ignored) {
|
}catch (IOException ignored) {
|
||||||
|
log.trace("port {} unavailable", i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
@@ -95,7 +98,7 @@ public class CustomUtil {
|
|||||||
try{
|
try{
|
||||||
response.sendError(404);
|
response.sendError(404);
|
||||||
}catch (IOException e){
|
}catch (IOException e){
|
||||||
e.printStackTrace();
|
log.warn("sendError 404 failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,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;
|
||||||
String range = request.getHeader(HttpHeaders.RANGE);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
// 判断 range 的类型
|
|
||||||
if (ranges.length == 1) {
|
|
||||||
// 类型一:bytes=-2343
|
|
||||||
if (range.startsWith(rangeSeparator)) {
|
|
||||||
endByte = Long.parseLong(ranges[0]);
|
|
||||||
}
|
|
||||||
// 类型二:bytes=2343-
|
|
||||||
else if (range.endsWith(rangeSeparator)) {
|
|
||||||
startByte = Long.parseLong(ranges[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 类型三:bytes=22-2343
|
|
||||||
else if (ranges.length == 2) {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
// Size and content refer to the same opened file, even if a cache is replaced.
|
||||||
//要下载的长度(endByte 为总长度 -1,这时候要加回去)
|
try (RandomAccessFile input = new RandomAccessFile(file, "r")) {
|
||||||
long contentLength = endByte - startByte + 1;
|
long size = input.length();
|
||||||
//文件类型
|
long start = 0;
|
||||||
String contentType = request.getServletContext().getMimeType(fileName);
|
long end = size - 1;
|
||||||
|
boolean partial = false;
|
||||||
if (StrUtil.isEmpty(contentType)) {
|
String range = request.getHeader(HttpHeaders.RANGE);
|
||||||
contentType = "attachment";
|
if (range != null && range.startsWith("bytes=")) {
|
||||||
}
|
try {
|
||||||
|
List<HttpRange> ranges = HttpRange.parseRanges(range);
|
||||||
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
// Multiple ranges are intentionally ignored; send the full representation.
|
||||||
response.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
|
if (ranges.size() == 1) {
|
||||||
// 这里文件名换你想要的,inline 表示浏览器可以直接使用
|
if (size == 0) throw new IllegalArgumentException("empty file");
|
||||||
// 参考资料:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition
|
start = ranges.getFirst().getRangeStart(size);
|
||||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentType + ";filename=\"" + URLUtil.encode(fileName) + "\"");
|
end = ranges.getFirst().getRangeEnd(size);
|
||||||
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength));
|
if (start < 0 || start >= size || end < start)
|
||||||
// [要下载的开始位置]-[结束位置]/[文件总大小]
|
throw new IllegalArgumentException("unsatisfiable range");
|
||||||
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + startByte + rangeSeparator + endByte + "/" + file.length());
|
partial = true;
|
||||||
|
}
|
||||||
BufferedOutputStream outputStream;
|
} catch (IllegalArgumentException e) {
|
||||||
RandomAccessFile randomAccessFile = null;
|
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||||
//已传送数据大小
|
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + size);
|
||||||
long transmitted = 0;
|
response.setContentLengthLong(0);
|
||||||
try {
|
return;
|
||||||
randomAccessFile = new RandomAccessFile(file, "r");
|
}
|
||||||
outputStream = new BufferedOutputStream(response.getOutputStream());
|
|
||||||
byte[] buff = new byte[4096];
|
|
||||||
int len = 0;
|
|
||||||
randomAccessFile.seek(startByte);
|
|
||||||
while ((transmitted + len) <= contentLength && (len = randomAccessFile.read(buff)) != -1) {
|
|
||||||
outputStream.write(buff, 0, len);
|
|
||||||
transmitted += len;
|
|
||||||
// 本地测试, 防止下载速度过快
|
|
||||||
// Thread.sleep(1);
|
|
||||||
}
|
}
|
||||||
// 处理不足 buff.length 部分
|
long remaining = end - start + 1;
|
||||||
if (transmitted < contentLength) {
|
response.setStatus(partial ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
|
||||||
len = randomAccessFile.read(buff, 0, (int) (contentLength - transmitted));
|
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||||
outputStream.write(buff, 0, len);
|
String mime = request.getServletContext().getMimeType(file.getName());
|
||||||
|
response.setContentType(mime == null ? "application/octet-stream" : mime);
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
ContentDisposition.inline().filename(file.getName(), StandardCharsets.UTF_8).build().toString());
|
||||||
|
response.setContentLengthLong(remaining);
|
||||||
|
if (partial)
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + size);
|
||||||
|
if ("HEAD".equalsIgnoreCase(request.getMethod()))
|
||||||
|
return;
|
||||||
|
input.seek(start);
|
||||||
|
// 关闭响应流:Tomcat 为大响应体创建的临时文件会在流关闭时删除,
|
||||||
|
// 不关闭会一直堆积到 Full GC。flushBuffer 只能把内容送出去,不能触发清理。
|
||||||
|
try (BufferedOutputStream output = new BufferedOutputStream(response.getOutputStream())) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (remaining > 0) {
|
||||||
|
int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
|
||||||
|
if (count == -1)
|
||||||
|
throw new EOFException("File changed during download");
|
||||||
|
output.write(buffer, 0, count);
|
||||||
|
remaining -= count;
|
||||||
|
}
|
||||||
|
output.flush();
|
||||||
|
response.flushBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
outputStream.flush();
|
|
||||||
response.flushBuffer();
|
|
||||||
randomAccessFile.close();
|
|
||||||
// log.trace("下载完毕: {}-{}, 已传输 {}", startByte, endByte, transmitted);
|
|
||||||
} catch (ClientAbortException e) {
|
} catch (ClientAbortException e) {
|
||||||
// ignore 用户停止下载
|
// The client cancelled its download.
|
||||||
// log.trace("用户停止下载: {}-{}, 已传输 {}", startByte, endByte, transmitted);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
log.warn("文件下载失败: {}", path, e);
|
||||||
} finally {
|
if (!response.isCommitted()) {
|
||||||
try {
|
response.reset();
|
||||||
if (randomAccessFile != null) {
|
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||||
randomAccessFile.close();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
package com.lion.lionwebsite.Util;
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import com.lion.lionwebsite.Domain.Gallery;
|
import com.lion.lionwebsite.Domain.Gallery;
|
||||||
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
||||||
import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
||||||
import org.apache.http.HttpEntity;
|
import java.nio.file.*;
|
||||||
import org.apache.http.client.entity.EntityBuilder;
|
import java.util.concurrent.TimeUnit;
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
import org.apache.http.client.methods.HttpGet;
|
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||||
import org.apache.http.client.methods.HttpPost;
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
import org.apache.http.entity.ContentType;
|
import org.apache.hc.client5.http.entity.EntityBuilder;
|
||||||
import org.apache.http.entity.mime.MultipartEntityBuilder;
|
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
|
||||||
import org.apache.http.impl.client.CloseableHttpClient;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
import org.apache.http.impl.client.HttpClients;
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.core5.http.ContentType;
|
||||||
|
import org.apache.hc.core5.http.HttpEntity;
|
||||||
|
import org.apache.hc.core5.util.Timeout;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
import org.jsoup.nodes.Document;
|
import org.jsoup.nodes.Document;
|
||||||
import org.jsoup.nodes.Element;
|
import org.jsoup.nodes.Element;
|
||||||
import org.jsoup.select.Elements;
|
import org.jsoup.select.Elements;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -29,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";
|
||||||
|
|
||||||
@@ -36,7 +44,32 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,10 +89,15 @@ public class GalleryUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//初始化图片
|
//初始化图片
|
||||||
|
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);
|
||||||
|
|
||||||
//访问图片页面
|
//访问图片页面
|
||||||
@@ -118,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,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("提交失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +199,7 @@ public class GalleryUtil {
|
|||||||
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);
|
||||||
@@ -172,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) {
|
||||||
@@ -188,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);
|
||||||
@@ -197,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) {
|
||||||
@@ -218,22 +273,44 @@ public class GalleryUtil {
|
|||||||
return null;
|
return null;
|
||||||
return jsonNode.get("i").asText();
|
return jsonNode.get("i").asText();
|
||||||
}catch (Exception e){
|
}catch (Exception e){
|
||||||
System.out.println("获取imgurl失败:" + imageKeyCache.getGid() + ":" + imageKeyCache.getPage());
|
log.error("获取imgurl失败:{}:{}", imageKeyCache.getGid(), imageKeyCache.getPage(), e);
|
||||||
e.printStackTrace();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String convertImg(String imagePath, String suffix){
|
public static String convertImg(String imagePath, String suffix){
|
||||||
Runtime rt = Runtime.getRuntime();
|
Path source = Path.of(imagePath);
|
||||||
|
Path target = source.resolveSibling(source.getFileName().toString().replaceFirst("\\Q" + suffix + "\\E$", ".avif"));
|
||||||
|
if (source.equals(target)) return imagePath;
|
||||||
|
Path temporary = null;
|
||||||
|
Process process = null;
|
||||||
try {
|
try {
|
||||||
Process exec = rt.exec(new String[]{"convert", imagePath, imagePath.replace(suffix, ".avif")});
|
temporary = Files.createTempFile(target.toAbsolutePath().getParent(), ".convert-", ".avif");
|
||||||
exec.waitFor();
|
process = new ProcessBuilder("convert", source.toString(), temporary.toString())
|
||||||
boolean ignored = new File(imagePath).delete();
|
.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
|
||||||
return imagePath.replace(suffix, ".avif");
|
if (!process.waitFor(60, TimeUnit.SECONDS))
|
||||||
} catch (IOException| InterruptedException e) {
|
throw new IOException("图片转换超时");
|
||||||
System.out.println("文件" + imagePath + "转换失败");
|
if (process.exitValue() != 0 || Files.size(temporary) == 0)
|
||||||
|
throw new IOException("图片转换失败");
|
||||||
|
try {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
Files.deleteIfExists(source);
|
||||||
|
return target.toString();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
return null;
|
return null;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("文件{}转换失败", imagePath, e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||||
|
if (temporary != null) {
|
||||||
|
try { Files.deleteIfExists(temporary); }
|
||||||
|
catch (IOException e) { log.warn("清理图片转换临时文件失败", e); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +340,6 @@ public class GalleryUtil {
|
|||||||
* @throws IOException 可能会抛出IO错误
|
* @throws IOException 可能会抛出IO错误
|
||||||
*/
|
*/
|
||||||
public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException {
|
public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException {
|
||||||
CloseableHttpClient httpClient = HttpClients.createDefault();
|
|
||||||
CloseableHttpResponse httpResponse;
|
CloseableHttpResponse httpResponse;
|
||||||
if(headers == null)
|
if(headers == null)
|
||||||
headers = new HashMap<>();
|
headers = new HashMap<>();
|
||||||
@@ -271,7 +347,7 @@ public class GalleryUtil {
|
|||||||
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
|
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
|
||||||
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
|
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
|
||||||
if(url.contains("hentai")) {
|
if(url.contains("hentai")) {
|
||||||
headers.put("Cookie", "ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1");
|
headers.put("Cookie", ehentaiCookie);
|
||||||
headers.put("Upgrade-Insecure-Requests", "1");
|
headers.put("Upgrade-Insecure-Requests", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,29 +377,44 @@ public class GalleryUtil {
|
|||||||
}
|
}
|
||||||
httpResponse = httpClient.execute(httpPost);
|
httpResponse = httpClient.execute(httpPost);
|
||||||
}
|
}
|
||||||
HttpEntity responseEntity = httpResponse.getEntity();
|
try (httpResponse) {
|
||||||
int statusCode = httpResponse.getStatusLine().getStatusCode();
|
HttpEntity responseEntity = httpResponse.getEntity();
|
||||||
StringBuilder stringBuilder = new StringBuilder();
|
int statusCode = httpResponse.getCode();
|
||||||
|
StringBuilder stringBuilder = new StringBuilder();
|
||||||
if(statusCode == 200){
|
if (statusCode == 200 && responseEntity != null) {
|
||||||
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) {
|
||||||
String str;
|
String str;
|
||||||
while((str = reader.readLine()) != null)
|
while ((str = reader.readLine()) != null)
|
||||||
stringBuilder.append(str).append("\n");
|
stringBuilder.append(str).append("\n");
|
||||||
} else{
|
}
|
||||||
System.out.println(url + ":" + statusCode);
|
} else {
|
||||||
|
log.warn("{}:{}", url, statusCode);
|
||||||
|
}
|
||||||
|
return stringBuilder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient.close();
|
|
||||||
httpResponse.close();
|
|
||||||
|
|
||||||
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -12,6 +22,12 @@ spring:
|
|||||||
datasource-cache:
|
datasource-cache:
|
||||||
driver-class-name: org.sqlite.JDBC
|
driver-class-name: org.sqlite.JDBC
|
||||||
jdbc-url: jdbc:sqlite: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:
|
|
||||||
# target-path: /root/gallery/
|
remote:
|
||||||
# cache-size: 100
|
ip: "5.255.110.45"
|
||||||
#
|
|
||||||
#remote-service:
|
local:
|
||||||
# ip: 5.255.110.45
|
dou-nai-clash: "https://aaaa.gay/link/{key}?client=clashmeta"
|
||||||
#
|
dou-nai-v2ray: "https://aaaa.gay/link/{key}?client=v2"
|
||||||
#local-service:
|
|
||||||
# DouNaiClash: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta
|
subscription:
|
||||||
# DouNaiV2ray: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2
|
cache-root: sub/accounts
|
||||||
#
|
# 分散刷新:每个账号在 window-hours 内至少刷新一次,两两之间至少相隔 min-gap-minutes;
|
||||||
#bot:
|
# max-per-tick 限制单个 tick 刷新几个账号,避免重启后一批到期账号同时开火。
|
||||||
# token: 5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA
|
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:
|
||||||
|
token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
|
||||||
|
|
||||||
|
# 一键登录:机器人用同一密钥自签 HMAC 票据,主站只做验签与时间窗校验。
|
||||||
|
# secret 必须通过环境变量注入,不得写入仓库;为空时所有票据一律拒绝。
|
||||||
|
personal:
|
||||||
|
login:
|
||||||
|
secret: "${PERSONAL_LOGIN_SECRET:}"
|
||||||
|
ticket-ttl-seconds: 300
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
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 com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务查询层与其收藏标记、未完成任务重投。
|
||||||
|
* 这些方法决定前端列表里「哪些是已收藏」「哪些任务能重试」,
|
||||||
|
* 以及重置时会不会把不该重投的任务再推给节点(会重复下载、浪费额度)。
|
||||||
|
*/
|
||||||
|
class GalleryQueryTest {
|
||||||
|
|
||||||
|
private GalleryMapper galleries;
|
||||||
|
private CollectMapper collectMapper;
|
||||||
|
private UserMapper users;
|
||||||
|
private RemoteService remote;
|
||||||
|
private GalleryManageService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
galleries = mock(GalleryMapper.class);
|
||||||
|
collectMapper = mock(CollectMapper.class);
|
||||||
|
users = mock(UserMapper.class);
|
||||||
|
remote = mock(RemoteService.class);
|
||||||
|
service = new GalleryManageService(galleries, collectMapper,
|
||||||
|
mock(CustomConfigurationMapper.class), users,
|
||||||
|
mock(ImageCacheMapper.class), remote, mock(PushService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gallery gallery(int gid, String name, String status) {
|
||||||
|
Gallery g = new Gallery();
|
||||||
|
g.setGid(gid);
|
||||||
|
g.setName(name);
|
||||||
|
g.setStatus(status);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- selectAllGallery ----------
|
||||||
|
|
||||||
|
/** 无收藏时返回全部任务,且不误标 collect。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryWithoutCollections() {
|
||||||
|
Gallery[] all = {gallery(1, "A", "下载中"), gallery(2, "B", "下载完成")};
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(all);
|
||||||
|
when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
String json = service.selectAllGallery(7);
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("A"));
|
||||||
|
assertTrue(json.contains("B"));
|
||||||
|
assertFalse(all[0].isCollect(), "无收藏时不应标记为已收藏");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏过的任务必须被标记 collect=true,其余保持 false。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryMarksCollectedGalleries() {
|
||||||
|
Gallery[] all = {gallery(1, "A", "x"), gallery(2, "B", "x"), gallery(3, "C", "x")};
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(all);
|
||||||
|
when(collectMapper.selectGidByCollector(7))
|
||||||
|
.thenReturn(new ArrayList<>(List.of(1, 3)));
|
||||||
|
|
||||||
|
String json = service.selectAllGallery(7);
|
||||||
|
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertTrue(all[0].isCollect(), "gid=1 应标记已收藏");
|
||||||
|
assertFalse(all[1].isCollect(), "gid=2 未收藏");
|
||||||
|
assertTrue(all[2].isCollect(), "gid=3 应标记已收藏");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏列表非空但没有任何一项命中任务列表时,不应标记任何任务。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryIgnoresCollectionsForMissingGalleries() {
|
||||||
|
Gallery[] all = {gallery(1, "A", "x")};
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(all);
|
||||||
|
when(collectMapper.selectGidByCollector(7))
|
||||||
|
.thenReturn(new ArrayList<>(List.of(999)));
|
||||||
|
|
||||||
|
assertTrue(ok(service.selectAllGallery(7)));
|
||||||
|
assertFalse(all[0].isCollect());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏标记改为集合查找后的批量正确性:每个画廊只依据自己的 gid 是否在收藏集合中。
|
||||||
|
* 用「任务远多于收藏」的规模锁住「不漏标、不误标」两点。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryMarksCollectionsCorrectlyAtScale() {
|
||||||
|
int total = 500;
|
||||||
|
Gallery[] all = new Gallery[total];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
all[i] = gallery(i, "G" + i, "下载中");
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(all);
|
||||||
|
// 收藏偶数 gid,构造一半命中、一半不命中
|
||||||
|
ArrayList<Integer> collected = new ArrayList<>();
|
||||||
|
for (int i = 0; i < total; i += 2)
|
||||||
|
collected.add(i);
|
||||||
|
when(collectMapper.selectGidByCollector(7)).thenReturn(collected);
|
||||||
|
|
||||||
|
assertTrue(ok(service.selectAllGallery(7)));
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
assertEquals(i % 2 == 0, all[i].isCollect(), "gid=" + i + " 的收藏标记不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询结果为 null 时应回业务失败而不是 NPE。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryReportsFailureWhenNull() {
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.selectAllGallery(7);
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("没有找到图片"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空数组应正常返回成功(与 null 区别对待)。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryHandlesEmptyArray() {
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(new Gallery[0]);
|
||||||
|
when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
assertTrue(ok(service.selectAllGallery(7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 下载人昵称与筛选(仅管理员) ----------
|
||||||
|
|
||||||
|
/** 管理员查询时每条任务都要带上下载人昵称,前端详情直接显示。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryFillsDownloaderNameForAdmin() {
|
||||||
|
Gallery[] all = {gallery(1, "A", "下载完成"), gallery(2, "B", "下载完成")};
|
||||||
|
all[0].setDownloader(24);
|
||||||
|
all[1].setDownloader(26);
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(all);
|
||||||
|
when(collectMapper.selectGidByCollector(3)).thenReturn(new ArrayList<>());
|
||||||
|
when(users.selectAllUser()).thenReturn(new User[]{
|
||||||
|
user(3, "狮子"), user(24, "bot叔叔"), user(26, "贱笑")});
|
||||||
|
|
||||||
|
String json = service.selectAllGallery(3);
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertEquals("bot叔叔", all[0].getDownloaderName());
|
||||||
|
assertEquals("贱笑", all[1].getDownloaderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 普通用户不得拿到任何下载人昵称,避免泄露他人身份。 */
|
||||||
|
@Test
|
||||||
|
void selectAllGalleryOmitsDownloaderNameForRegularUser() {
|
||||||
|
Gallery[] all = {gallery(1, "A", "下载完成")};
|
||||||
|
all[0].setDownloader(24);
|
||||||
|
when(galleries.selectAllGallery()).thenReturn(all);
|
||||||
|
when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
String json = service.selectAllGallery(7);
|
||||||
|
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertNull(all[0].getDownloaderName(), "普通用户不应填充下载人昵称");
|
||||||
|
assertFalse(json.contains("downloaderName"), "响应体不应出现 downloaderName: " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User user(int id, String username) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
u.setUsername(username);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- selectDownloaderByGid(管理员专用) ----------
|
||||||
|
|
||||||
|
/** 管理员按 gid 查到实际下载人昵称。 */
|
||||||
|
@Test
|
||||||
|
void selectDownloaderByGidResolvesNameForAdmin() {
|
||||||
|
Gallery g = gallery(500, "A", "下载完成");
|
||||||
|
g.setDownloader(24);
|
||||||
|
when(users.selectUserByAuthCode("admin")).thenReturn(user(3, "狮子"));
|
||||||
|
when(galleries.selectGalleryByGid(500)).thenReturn(g);
|
||||||
|
when(users.selectUserById(24)).thenReturn(user(24, "bot叔叔"));
|
||||||
|
|
||||||
|
String json = service.selectDownloaderByGid(500, "admin");
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("bot叔叔"), "实际输出: " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 普通用户查下载人必须被拒,不得泄露他人身份。 */
|
||||||
|
@Test
|
||||||
|
void selectDownloaderByGidRejectsRegularUser() {
|
||||||
|
when(users.selectUserByAuthCode("code")).thenReturn(user(7, "tester"));
|
||||||
|
|
||||||
|
String json = service.selectDownloaderByGid(500, "code");
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("无权"), "实际输出: " + json);
|
||||||
|
verify(galleries, never()).selectGalleryByGid(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务不存在时回业务失败而不是 NPE。 */
|
||||||
|
@Test
|
||||||
|
void selectDownloaderByGidReportsMissingTask() {
|
||||||
|
when(users.selectUserByAuthCode("admin")).thenReturn(user(3, "狮子"));
|
||||||
|
when(galleries.selectGalleryByGid(404)).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.selectDownloaderByGid(404, "admin");
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("任务不存在"), "实际输出: " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- selectTaskByLink / ByGid ----------
|
||||||
|
|
||||||
|
/** 库里已有该任务时直接返回,不再去外部站点解析。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkReturnsStoredTaskWithoutParsing() throws Exception {
|
||||||
|
when(galleries.selectGalleryByGid(1234567)).thenReturn(gallery(1234567, "Stored", "下载中"));
|
||||||
|
|
||||||
|
try (var parser = mockStatic(GalleryUtil.class)) {
|
||||||
|
parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567);
|
||||||
|
|
||||||
|
String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/");
|
||||||
|
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertTrue(json.contains("Stored"));
|
||||||
|
parser.verify(() -> GalleryUtil.parse(anyString(), anyBoolean(), any()), never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 库里没有时回落到在线解析。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkFallsBackToOnlineParse() throws Exception {
|
||||||
|
when(galleries.selectGalleryByGid(1234567)).thenReturn(null);
|
||||||
|
|
||||||
|
try (var parser = mockStatic(GalleryUtil.class)) {
|
||||||
|
parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567);
|
||||||
|
parser.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any()))
|
||||||
|
.thenReturn(gallery(1234567, "FromWeb", "等待确认下载"));
|
||||||
|
|
||||||
|
String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/");
|
||||||
|
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertTrue(json.contains("FromWeb"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在线解析返回 null 时提示查询失败。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkReportsFailureWhenParseReturnsNull() throws Exception {
|
||||||
|
when(galleries.selectGalleryByGid(1234567)).thenReturn(null);
|
||||||
|
|
||||||
|
try (var parser = mockStatic(GalleryUtil.class)) {
|
||||||
|
parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567);
|
||||||
|
parser.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("查询失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析抛异常同样要转成业务失败。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkReportsFailureWhenParseThrows() throws Exception {
|
||||||
|
when(galleries.selectGalleryByGid(1234567)).thenReturn(null);
|
||||||
|
|
||||||
|
try (var parser = mockStatic(GalleryUtil.class)) {
|
||||||
|
parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567);
|
||||||
|
parser.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any()))
|
||||||
|
.thenThrow(new java.io.IOException("upstream down"));
|
||||||
|
|
||||||
|
String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("查询失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** gid 解析不出来时直接回「链接错误」,不查库。 */
|
||||||
|
@Test
|
||||||
|
void selectTaskByLinkRejectsUnparseableLink() throws Exception {
|
||||||
|
try (var parser = mockStatic(GalleryUtil.class)) {
|
||||||
|
parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.selectTaskByLink("https://example.com/nope");
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("链接错误"));
|
||||||
|
verify(galleries, never()).selectGalleryByGid(anyInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectTaskByGidHandlesFoundAndMissing() {
|
||||||
|
when(galleries.selectGalleryByGid(5)).thenReturn(gallery(5, "Found", "x"));
|
||||||
|
assertTrue(ok(service.selectTaskByGid(5)));
|
||||||
|
|
||||||
|
when(galleries.selectGalleryByGid(6)).thenReturn(null);
|
||||||
|
String json = service.selectTaskByGid(6);
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("未找到该图片"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- selectGalleryByName / ByDownloader ----------
|
||||||
|
|
||||||
|
/** 按名字查应把参数包成 LIKE 模式,前后都要有 %。 */
|
||||||
|
@Test
|
||||||
|
void selectGalleryByNameWrapsPattern() {
|
||||||
|
when(galleries.selectGalleryByName("%sakura%")).thenReturn(gallery(1, "Sakura", "x"));
|
||||||
|
|
||||||
|
assertTrue(ok(service.selectGalleryByName("sakura")));
|
||||||
|
|
||||||
|
verify(galleries).selectGalleryByName("%sakura%");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectGalleryByNameReportsFailureWhenMissing() {
|
||||||
|
when(galleries.selectGalleryByName(anyString())).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.selectGalleryByName("nope");
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("没有找到该名字的图片"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按下载者查:授权码要换成 userId 再查。 */
|
||||||
|
@Test
|
||||||
|
void selectGalleryByDownloaderResolvesUserId() {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(7);
|
||||||
|
when(users.selectUserByAuthCode("code")).thenReturn(u);
|
||||||
|
when(galleries.selectGalleryByDownloader(7)).thenReturn(new Gallery[]{gallery(1, "Mine", "x")});
|
||||||
|
|
||||||
|
assertTrue(ok(service.selectGalleryByDownloader("code")));
|
||||||
|
|
||||||
|
verify(galleries).selectGalleryByDownloader(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectGalleryByDownloaderReportsFailureWhenEmpty() {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(7);
|
||||||
|
when(users.selectUserByAuthCode("code")).thenReturn(u);
|
||||||
|
when(galleries.selectGalleryByDownloader(7)).thenReturn(new Gallery[0]);
|
||||||
|
|
||||||
|
String json = service.selectGalleryByDownloader("code");
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("您未下载图片"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- resetUndone ----------
|
||||||
|
|
||||||
|
/** 节点在线且存在未完成任务时,应逐个重投并报告数量。 */
|
||||||
|
@Test
|
||||||
|
void resetUndoneResendsEveryUnfinishedTask() {
|
||||||
|
when(remote.isDead()).thenReturn(false);
|
||||||
|
Gallery[] undone = {gallery(1, "A", "下载中"), gallery(2, "B", "等待压缩")};
|
||||||
|
when(galleries.selectUnDoneGalleries()).thenReturn(undone);
|
||||||
|
|
||||||
|
String json = service.resetUndone();
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("2本"), "应带上重投数量: " + json);
|
||||||
|
verify(remote).addGalleryToQueue(undone[0]);
|
||||||
|
verify(remote).addGalleryToQueue(undone[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点离线时不得重投(会丢消息),直接提示失败。 */
|
||||||
|
@Test
|
||||||
|
void resetUndoneRefusesWhenNodeOffline() {
|
||||||
|
when(remote.isDead()).thenReturn(true);
|
||||||
|
|
||||||
|
String json = service.resetUndone();
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("节点不在线"));
|
||||||
|
verify(remote, never()).addGalleryToQueue(any());
|
||||||
|
verify(galleries, never()).selectUnDoneGalleries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 没有未完成任务时不重投任何东西。 */
|
||||||
|
@Test
|
||||||
|
void resetUndoneReportsWhenNothingToDo() {
|
||||||
|
when(remote.isDead()).thenReturn(false);
|
||||||
|
when(galleries.selectUnDoneGalleries()).thenReturn(new Gallery[0]);
|
||||||
|
|
||||||
|
String json = service.resetUndone();
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("当前没有未下载完成的图片"));
|
||||||
|
verify(remote, never()).addGalleryToQueue(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetUndoneHandlesNullArray() {
|
||||||
|
when(remote.isDead()).thenReturn(false);
|
||||||
|
when(galleries.selectUnDoneGalleries()).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.resetUndone();
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
verify(remote, never()).addGalleryToQueue(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- retryGallery ----------
|
||||||
|
|
||||||
|
/** 不存在的任务直接失败,且不碰节点。 */
|
||||||
|
@Test
|
||||||
|
void retryRejectsMissingTask() {
|
||||||
|
when(galleries.selectGalleryByGid(404)).thenReturn(null);
|
||||||
|
|
||||||
|
String json = service.retryGallery(404);
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("任务不存在"), "实际输出: " + json);
|
||||||
|
verify(remote, never()).retryGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已完成的任务无需重试,直接按成功返回(幂等语义)。 */
|
||||||
|
@Test
|
||||||
|
void retryIsNoopForCompletedTask() {
|
||||||
|
when(galleries.selectGalleryByGid(1)).thenReturn(gallery(1, "Done", "下载完成"));
|
||||||
|
|
||||||
|
String json = service.retryGallery(1);
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("下载完成"));
|
||||||
|
verify(remote, never()).retryGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryRefusesWhenNodeOffline() {
|
||||||
|
when(galleries.selectGalleryByGid(1)).thenReturn(gallery(1, "G", "提交失败"));
|
||||||
|
when(remote.isDead()).thenReturn(true);
|
||||||
|
|
||||||
|
String json = service.retryGallery(1);
|
||||||
|
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("节点不在线"));
|
||||||
|
verify(remote, never()).retryGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点接受重试时透传其返回状态;被拒时透传失败原因。 */
|
||||||
|
@Test
|
||||||
|
void retryPropagatesNodeOutcome() {
|
||||||
|
Gallery g = gallery(1, "G", "提交失败");
|
||||||
|
when(galleries.selectGalleryByGid(1)).thenReturn(g);
|
||||||
|
when(remote.isDead()).thenReturn(false);
|
||||||
|
|
||||||
|
when(remote.retryGallery(g)).thenReturn(new RemoteService.RetryResult(true, "下载中"));
|
||||||
|
String okJson = service.retryGallery(1);
|
||||||
|
assertTrue(ok(okJson), "实际输出: " + okJson);
|
||||||
|
assertTrue(okJson.contains("下载中"));
|
||||||
|
|
||||||
|
when(remote.retryGallery(g)).thenReturn(new RemoteService.RetryResult(false, "节点未接受重试请求"));
|
||||||
|
String failJson = service.retryGallery(1);
|
||||||
|
assertFalse(ok(failJson));
|
||||||
|
assertTrue(failJson.contains("节点未接受重试请求"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- reconnect ----------
|
||||||
|
|
||||||
|
/** 重连结果码到用户可见文案的映射。 */
|
||||||
|
@Test
|
||||||
|
void reconnectMapsResultCodesToMessages() {
|
||||||
|
when(remote.reconnect()).thenReturn((byte) 0);
|
||||||
|
assertTrue(ok(service.reconnect()), "0 应为成功");
|
||||||
|
|
||||||
|
when(remote.reconnect()).thenReturn((byte) -1);
|
||||||
|
String fail = service.reconnect();
|
||||||
|
assertFalse(ok(fail));
|
||||||
|
assertTrue(fail.contains("重连失败"));
|
||||||
|
|
||||||
|
when(remote.reconnect()).thenReturn((byte) -2);
|
||||||
|
assertTrue(service.reconnect().contains("当前未连接"));
|
||||||
|
|
||||||
|
when(remote.reconnect()).thenReturn((byte) 42);
|
||||||
|
assertTrue(service.reconnect().contains("未知错误"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.*;
|
||||||
|
import com.lion.lionwebsite.Dao.cache.ImageCacheMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.*;
|
||||||
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class GallerySubmissionTest {
|
||||||
|
@Test
|
||||||
|
void immediateCompletionSeesPersistedTask() throws Exception {
|
||||||
|
checkSubmission((byte) 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeoutRetainsTaskForRetry() throws Exception {
|
||||||
|
checkSubmission((byte) -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkSubmission(byte ack) throws Exception {
|
||||||
|
GalleryMapper galleries = mock(GalleryMapper.class);
|
||||||
|
UserMapper users = mock(UserMapper.class);
|
||||||
|
RemoteService remote = mock(RemoteService.class);
|
||||||
|
CustomConfigurationMapper configuration = mock(CustomConfigurationMapper.class);
|
||||||
|
GalleryManageService service = new GalleryManageService(galleries, mock(CollectMapper.class), configuration,
|
||||||
|
users, mock(ImageCacheMapper.class), remote, mock(PushService.class));
|
||||||
|
User user = new User();
|
||||||
|
user.setId(7);
|
||||||
|
user.setUsername("test");
|
||||||
|
when(users.selectUserByAuthCode("test")).thenReturn(user);
|
||||||
|
AtomicReference<Gallery> saved = new AtomicReference<>();
|
||||||
|
when(galleries.selectGalleryByGid(123)).thenAnswer(call -> saved.get());
|
||||||
|
doAnswer(call -> { saved.set(call.getArgument(0)); return null; }).when(galleries).insertGallery(any());
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setGid(123);
|
||||||
|
gallery.setName("sample [123]");
|
||||||
|
gallery.setStatus("已提交");
|
||||||
|
when(remote.addGalleryToQueue(any())).thenAnswer(call -> {
|
||||||
|
assertNotNull(saved.get(), "node status must find a persisted record");
|
||||||
|
assertEquals(7, saved.get().getDownloader());
|
||||||
|
if (ack == 0) saved.get().setStatus("下载完成");
|
||||||
|
return ack;
|
||||||
|
});
|
||||||
|
try (var parser = mockStatic(GalleryUtil.class)) {
|
||||||
|
parser.when(() -> GalleryUtil.parse("https://example.org/g/123/key/", true, "original")).thenReturn(gallery);
|
||||||
|
String response = service.createTask("https://example.org/g/123/key/", "original", "test");
|
||||||
|
assertNotNull(saved.get());
|
||||||
|
assertTrue(response.contains(ack == 0 ? "success" : "任务已保存"));
|
||||||
|
assertEquals(ack == 0 ? "下载完成" : "已提交", saved.get().getStatus());
|
||||||
|
verify(galleries, never()).deleteGalleryByGid(any());
|
||||||
|
verify(galleries, times(1)).insertGallery(gallery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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 org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务与订阅更新的主逻辑。
|
||||||
|
* 这些方法由调度器在无人值守时触发,失败只会体现在日志里,因此每条分支
|
||||||
|
* 都必须有断言锁定:该重连的重连、该告警的告警、不该写库的绝不写库。
|
||||||
|
*/
|
||||||
|
class LocalServiceTest {
|
||||||
|
|
||||||
|
private CustomConfigurationMapper configurationMapper;
|
||||||
|
private GalleryMapper galleryMapper;
|
||||||
|
private PushService pushService;
|
||||||
|
private RemoteService remoteService;
|
||||||
|
private SubscriptionRefreshScheduler refreshScheduler;
|
||||||
|
private LocalService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
|
galleryMapper = mock(GalleryMapper.class);
|
||||||
|
pushService = mock(PushService.class);
|
||||||
|
remoteService = mock(RemoteService.class);
|
||||||
|
refreshScheduler = mock(SubscriptionRefreshScheduler.class);
|
||||||
|
service = new LocalService(configurationMapper, galleryMapper,
|
||||||
|
pushService, remoteService, refreshScheduler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CheckConnectionAvailability ----------
|
||||||
|
|
||||||
|
/** 连接已死:直接重连并发告警,不应再去探测可用性。 */
|
||||||
|
@Test
|
||||||
|
void deadConnectionTriggersReconnectAndAlert() {
|
||||||
|
when(remoteService.isDead()).thenReturn(true);
|
||||||
|
|
||||||
|
service.CheckConnectionAvailability();
|
||||||
|
|
||||||
|
verify(remoteService).initChannel();
|
||||||
|
verify(pushService).sendToMe(contains("自动进行重连"));
|
||||||
|
verify(remoteService, never()).checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接活着且探测有响应:什么都不做。 */
|
||||||
|
@Test
|
||||||
|
void healthyConnectionDoesNothing() {
|
||||||
|
when(remoteService.isDead()).thenReturn(false);
|
||||||
|
when(remoteService.checkAvailability()).thenReturn((byte) 0);
|
||||||
|
|
||||||
|
service.CheckConnectionAvailability();
|
||||||
|
|
||||||
|
verify(remoteService, never()).reconnect();
|
||||||
|
verifyNoInteractions(pushService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 探测无响应(-1)时按 reconnect 的返回值给出不同告警文案。 */
|
||||||
|
@Test
|
||||||
|
void probeTimeoutReconnectsAndReportsOutcome() {
|
||||||
|
when(remoteService.isDead()).thenReturn(false);
|
||||||
|
when(remoteService.checkAvailability()).thenReturn((byte) -1);
|
||||||
|
|
||||||
|
when(remoteService.reconnect()).thenReturn((byte) 0);
|
||||||
|
service.CheckConnectionAvailability();
|
||||||
|
verify(pushService).sendToMe(contains("重连成功"));
|
||||||
|
|
||||||
|
when(remoteService.reconnect()).thenReturn((byte) -1);
|
||||||
|
service.CheckConnectionAvailability();
|
||||||
|
verify(pushService).sendToMe(contains("重连失败"));
|
||||||
|
|
||||||
|
when(remoteService.reconnect()).thenReturn((byte) -2);
|
||||||
|
service.CheckConnectionAvailability();
|
||||||
|
verify(pushService).sendToMe(contains("当前未连接,不进行重连"));
|
||||||
|
|
||||||
|
when(remoteService.reconnect()).thenReturn((byte) 9);
|
||||||
|
service.CheckConnectionAvailability();
|
||||||
|
verify(pushService).sendToMe(contains("未知错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- reset ----------
|
||||||
|
|
||||||
|
/** 额度重置必须同时把用量清零并记录重置时间。 */
|
||||||
|
@Test
|
||||||
|
void resetZeroesQuotaAndStampsTime() {
|
||||||
|
service.reset();
|
||||||
|
|
||||||
|
verify(configurationMapper).updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, "0");
|
||||||
|
verify(configurationMapper).updateConfiguration(eq(CustomConfiguration.LAST_RESET_AMOUNT_TIME), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- verifyCookie ----------
|
||||||
|
|
||||||
|
/** Cookie 失效(内容为空)时应告警。 */
|
||||||
|
@Test
|
||||||
|
void verifyCookieAlertsOnEmptyContent() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn(" ");
|
||||||
|
|
||||||
|
service.verifyCookie();
|
||||||
|
|
||||||
|
verify(pushService).sendToMe("cookie过期");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cookie 有效时不应产生告警。 */
|
||||||
|
@Test
|
||||||
|
void verifyCookieSilentWhenValid() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn("<html>ok</html>");
|
||||||
|
|
||||||
|
service.verifyCookie();
|
||||||
|
|
||||||
|
verifyNoInteractions(pushService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求异常要带原因告警,不能静默失败。 */
|
||||||
|
@Test
|
||||||
|
void verifyCookieAlertsOnNetworkFailure() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenThrow(new IOException("connection reset"));
|
||||||
|
|
||||||
|
service.verifyCookie();
|
||||||
|
|
||||||
|
verify(pushService).sendToMe(contains("connection reset"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- updateSub ----------
|
||||||
|
|
||||||
|
/** 全部子账号刷新成功:同步给节点并记录更新时间。 */
|
||||||
|
@Test
|
||||||
|
void updateSubStampsTimeWhenAllAccountsSucceed() throws Exception {
|
||||||
|
when(refreshScheduler.refreshAllNow()).thenReturn(true);
|
||||||
|
|
||||||
|
assertTrue(service.updateSub());
|
||||||
|
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
verify(configurationMapper).updateConfiguration(
|
||||||
|
eq(CustomConfiguration.LAST_UPDATE_SUB_TIME), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新失败时仍要通知节点,但不更新「上次更新时间」,避免掩盖故障。 */
|
||||||
|
@Test
|
||||||
|
void updateSubDoesNotStampTimeWhenRefreshFails() throws Exception {
|
||||||
|
when(refreshScheduler.refreshAllNow()).thenReturn(false);
|
||||||
|
|
||||||
|
assertFalse(service.updateSub());
|
||||||
|
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
verify(configurationMapper, never()).updateConfiguration(anyString(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时刷新入口必须已经移除。
|
||||||
|
* 旧的 {@code updateSubScheduler} 会一次性刷新全部账号且没有 initialDelay,
|
||||||
|
* 于是每次重启都触发一遍全量刷新——这正是本次要消除的行为。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void scheduledFullRefreshEntryPointIsGone() {
|
||||||
|
assertThrows(NoSuchMethodException.class,
|
||||||
|
() -> LocalService.class.getMethod("updateSubScheduler"));
|
||||||
|
assertThrows(NoSuchMethodException.class,
|
||||||
|
() -> LocalService.class.getMethod("updateSub", boolean.class),
|
||||||
|
"不应再保留 isManual 参数:定时路径已不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 定时作业的调度声明 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各定时任务的 cron 属于运维契约(额度重置必须是周一 4 点等),
|
||||||
|
* 这里用反射锁定,避免被无意改动后无人察觉。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void scheduledCronExpressionsMatchOperationalContract() throws Exception {
|
||||||
|
assertCron("CheckConnectionAvailability", "0 0/30 * * * *");
|
||||||
|
assertCron("reset", "0 0 4 * * MON");
|
||||||
|
assertCron("verifyCookie", "0 0 0 * * *");
|
||||||
|
assertCron("clearThumbnailCache", "0 0 4 1 * *");
|
||||||
|
// 订阅刷新已改为 SubscriptionRefreshScheduler 的分散 tick,
|
||||||
|
// 其周期与初始延迟由 SubscriptionRefreshSchedulerTest 锁定。
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCron(String method, String expected) throws Exception {
|
||||||
|
var annotation = LocalService.class.getMethod(method)
|
||||||
|
.getAnnotation(org.springframework.scheduling.annotation.Scheduled.class);
|
||||||
|
assertNotNull(annotation, method + " 应带 @Scheduled");
|
||||||
|
assertEquals(expected, annotation.cron(), method + " 的 cron 与运维约定不一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** now() 只用于断言「时间被写入」,这里确认它确实是格式化的当前时间。 */
|
||||||
|
@Test
|
||||||
|
void nowIsFormattedTimestamp() {
|
||||||
|
assertTrue(CustomUtil.now().matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人管理服务在移除文件管理后的剩余契约:订阅时间、家里 IP 与留言转发。
|
||||||
|
* 文件浏览/上传/分享/打包/删除相关行为已随接口一并下线。
|
||||||
|
*/
|
||||||
|
class PersonalServiceTest {
|
||||||
|
|
||||||
|
private CustomConfigurationMapper configurationMapper;
|
||||||
|
private PushService pushService;
|
||||||
|
private PersonalService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
|
pushService = mock(PushService.class);
|
||||||
|
service = new PersonalService(configurationMapper, pushService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CustomConfiguration 只有 @Data,没有全参构造,测试里用 setter 组装。 */
|
||||||
|
private static CustomConfiguration config(String parameter, String value) {
|
||||||
|
CustomConfiguration c = new CustomConfiguration();
|
||||||
|
c.setParameter(parameter);
|
||||||
|
c.setValue(value);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- lastUpdate / getIp ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lastUpdateReturnsConfigurationValue() {
|
||||||
|
when(configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME))
|
||||||
|
.thenReturn(config(CustomConfiguration.LAST_UPDATE_SUB_TIME, "2026-09-15 10:00:00"));
|
||||||
|
|
||||||
|
String json = service.lastUpdate();
|
||||||
|
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertTrue(json.contains("2026-09-15 10:00:00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIpReturnsAddressAndUpdateTime() {
|
||||||
|
when(configurationMapper.selectConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS))
|
||||||
|
.thenReturn(config(CustomConfiguration.CURRENT_IP_ADDRESS, "203.0.113.7"));
|
||||||
|
when(configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME))
|
||||||
|
.thenReturn(config(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, "2026-09-15 10:00:00"));
|
||||||
|
|
||||||
|
String json = service.getIp();
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("203.0.113.7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- message2me ----------
|
||||||
|
|
||||||
|
/** 留言必须原样转发到 Telegram,且返回成功。 */
|
||||||
|
@Test
|
||||||
|
void message2meForwardsToPushService() {
|
||||||
|
String json = service.message2me("hello from user");
|
||||||
|
|
||||||
|
assertTrue(ok(json));
|
||||||
|
verify(pushService).sendToMe("hello from user");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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 org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开服务在移除分享取件后的剩余契约:家里 IP 记录、改授权码与身份查询。
|
||||||
|
* 分享码换文件的 GetFile 已随文件分享功能下线。
|
||||||
|
*/
|
||||||
|
class PublicServiceTest {
|
||||||
|
|
||||||
|
private CustomConfigurationMapper configurationMapper;
|
||||||
|
private UserMapper userMapper;
|
||||||
|
private TaskHandlerInterceptor interceptor;
|
||||||
|
private PublicService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
|
userMapper = mock(UserMapper.class);
|
||||||
|
interceptor = mock(TaskHandlerInterceptor.class);
|
||||||
|
service = new PublicService(configurationMapper, userMapper, interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- logIpAddress ----------
|
||||||
|
|
||||||
|
/** 记录 IP 时同时写地址与观测时间,两处都要落库。 */
|
||||||
|
@Test
|
||||||
|
void logIpAddressWritesBothAddressAndTime() {
|
||||||
|
service.logIpAddress("203.0.113.7");
|
||||||
|
|
||||||
|
verify(configurationMapper).updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, "203.0.113.7");
|
||||||
|
verify(configurationMapper)
|
||||||
|
.updateConfiguration(eq(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- alterAuthCode ----------
|
||||||
|
|
||||||
|
/** 改码成功后必须刷新拦截器里的授权码缓存,否则新码要等重启才生效。 */
|
||||||
|
@Test
|
||||||
|
void alterAuthCodeUpdatesCache() {
|
||||||
|
String json = service.alterAuthCode("old", "new");
|
||||||
|
|
||||||
|
verify(userMapper).updateAuthCode("old", "new");
|
||||||
|
verify(interceptor).updateAuthCodes();
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- getUserId ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUserIdDelegatesToMapper() {
|
||||||
|
User expected = new User(7, "code", "alice", null, true);
|
||||||
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(expected);
|
||||||
|
|
||||||
|
assertSame(expected, service.getUserId("code"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Domain.Gallery;
|
||||||
|
import com.lion.lionwebsite.Util.Response;
|
||||||
|
import com.pengrad.telegrambot.TelegramBot;
|
||||||
|
import com.pengrad.telegrambot.request.SendMessage;
|
||||||
|
import 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.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram 告警文案。任务是无人值守跑一整夜的,这些消息是运维唯一的可见性,
|
||||||
|
* 因此重点断言「什么情况下会发」以及「消息里是否带上了定位所需的字段」。
|
||||||
|
*/
|
||||||
|
class PushServiceTest {
|
||||||
|
|
||||||
|
private TelegramBot bot;
|
||||||
|
private PushService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
bot = mock(TelegramBot.class);
|
||||||
|
service = new PushService(bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sentText() {
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(SendMessage.class);
|
||||||
|
verify(bot).execute(captor.capture());
|
||||||
|
return String.valueOf(captor.getValue().getParameters().get("text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功提交任务时只报「谁提交了什么」,不带失败原因。 */
|
||||||
|
@Test
|
||||||
|
void taskCreateReportOnSuccess() {
|
||||||
|
Response response = Response.generateResponse().success();
|
||||||
|
|
||||||
|
service.taskCreateReport("alice", "My Gallery", response);
|
||||||
|
|
||||||
|
String text = sentText();
|
||||||
|
assertTrue(text.contains("alice"));
|
||||||
|
assertTrue(text.contains("My Gallery"));
|
||||||
|
assertFalse(text.contains("下载失败"), "成功时不应出现失败文案: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失败提交必须带上失败原因,方便直接定位。 */
|
||||||
|
@Test
|
||||||
|
void taskCreateReportOnFailureIncludesReason() {
|
||||||
|
Response response = Response.generateResponse().failure("链接错误");
|
||||||
|
|
||||||
|
service.taskCreateReport("alice", "My Gallery", response);
|
||||||
|
|
||||||
|
String text = sentText();
|
||||||
|
assertTrue(text.contains("下载失败"), "实际推送: " + text);
|
||||||
|
assertTrue(text.contains("链接错误"), "应带上失败原因: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载完成通知要含任务名与完成时间(时间按东八区格式化)。 */
|
||||||
|
@Test
|
||||||
|
void downloadCompleteIncludesNameAndTimestamp() {
|
||||||
|
Gallery gallery = new Gallery();
|
||||||
|
gallery.setName("Nightly Gallery");
|
||||||
|
|
||||||
|
service.downloadComplete(gallery);
|
||||||
|
|
||||||
|
String text = sentText();
|
||||||
|
assertTrue(text.contains("Nightly Gallery"));
|
||||||
|
assertTrue(text.contains("完成时间"));
|
||||||
|
assertTrue(text.matches("(?s).*\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.*"),
|
||||||
|
"应包含 yyyy-MM-dd HH:mm:ss 形式的时间: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storageNodeUpDownProduceDistinctMessages() {
|
||||||
|
service.storageNodeOnline();
|
||||||
|
assertTrue(sentText().contains("上线"));
|
||||||
|
|
||||||
|
clearInvocations(bot);
|
||||||
|
|
||||||
|
service.storageNodeOffline();
|
||||||
|
assertTrue(sentText().contains("掉线"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sendToMe 是统一出口,必须真的调用 bot.execute 而不是只打日志。 */
|
||||||
|
@Test
|
||||||
|
void sendToMeExecutesTelegramRequest() {
|
||||||
|
service.sendToMe("hello");
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(SendMessage.class);
|
||||||
|
verify(bot).execute(captor.capture());
|
||||||
|
assertEquals("hello", captor.getValue().getParameters().get("text"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Hentai 搜索结果的解析与分页链接提取。
|
||||||
|
* 外部站点不可在单测中访问,因此 requests() 一律打桩,只覆盖解析与降级逻辑。
|
||||||
|
*/
|
||||||
|
class QueryServiceTest {
|
||||||
|
|
||||||
|
private QueryService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new QueryService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行结果 + 四个分页链接的完整页面,字段顺序与线上 DOM 一致。 */
|
||||||
|
private static String resultPage() {
|
||||||
|
return "<html><body>"
|
||||||
|
+ "<div class='ido'><div class='hdr'>header</div><div class='body'>"
|
||||||
|
+ "<table><tbody>"
|
||||||
|
+ "<tr><td>skipme</td></tr>"
|
||||||
|
+ "<tr>"
|
||||||
|
+ " <td>Manga</td>"
|
||||||
|
+ " <td><img src='/t/thumb.jpg'/>"
|
||||||
|
+ " <div class='x'><div class='y'>"
|
||||||
|
+ " <span onclick='a()'>first</span>"
|
||||||
|
+ " <span onclick='b()'>2026-01-01 12:00</span>"
|
||||||
|
+ " </div></div></td>"
|
||||||
|
+ " <td><a href='https://exhentai.org/g/1234567/abcdef/'>"
|
||||||
|
+ " <div class='glink'>Title Here</div></a></td>"
|
||||||
|
+ " <td><div>x</div><span>42 pages</span></td>"
|
||||||
|
+ "</tr>"
|
||||||
|
+ "</tbody></table></div></div>"
|
||||||
|
+ "<a id='unext' href='https://exhentai.org/?next=1'>n</a>"
|
||||||
|
+ "<a id='uprev' href='https://exhentai.org/?prev=1'>p</a>"
|
||||||
|
+ "<a id='ufirst' href='https://exhentai.org/?first=1'>f</a>"
|
||||||
|
+ "<a id='ulast' href='https://exhentai.org/?last=1'>l</a>"
|
||||||
|
+ "</body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryParsesGalleryRowAndPaginationLinks() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn(resultPage());
|
||||||
|
requests.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod();
|
||||||
|
|
||||||
|
String json = service.query("sakura", null, null);
|
||||||
|
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("Title Here"), "应解析出画廊名");
|
||||||
|
assertTrue(json.contains("1234567"), "应解析出 gid");
|
||||||
|
assertTrue(json.contains("Manga"), "应解析出类型");
|
||||||
|
// data 是「数组序列化后的字符串」,字段因此带转义,这里按转义后的形态断言
|
||||||
|
assertTrue(json.contains("\\\"page\\\":42"), "应解析出页数: " + json);
|
||||||
|
assertTrue(json.contains("\\\"gid\\\":\\\"1234567\\\""), "应解析出 gid: " + json);
|
||||||
|
// 缩略图地址去掉了站点前缀,前端要拿相对路径
|
||||||
|
assertTrue(json.contains("/t/thumb.jpg"));
|
||||||
|
assertFalse(json.contains("s.exhentai.org"));
|
||||||
|
// 四个分页链接都应写入响应
|
||||||
|
assertTrue(json.contains("next"));
|
||||||
|
assertTrue(json.contains("previous"));
|
||||||
|
assertTrue(json.contains("first"));
|
||||||
|
assertTrue(json.contains("last"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缩略图用 data-src 懒加载时,必须回退取 data-src 而不是留下 data: 前缀。 */
|
||||||
|
@Test
|
||||||
|
void queryFallsBackToLazyLoadedThumbnail() throws Exception {
|
||||||
|
String page = "<html><body><div class='ido'><div></div><div>"
|
||||||
|
+ "<table><tbody><tr><td>s</td></tr>"
|
||||||
|
+ "<tr><td>T</td>"
|
||||||
|
+ "<td><img src='data:image/gif;base64,R0lGOD' data-src='https://s.exhentai.org/t/lazy.jpg'/>"
|
||||||
|
+ "<div><div><span onclick='a()'>f</span><span onclick='b()'>2026-01-01</span></div></div></td>"
|
||||||
|
+ "<td><a href='https://exhentai.org/g/99/zz/'><div>Lazy</div></a></td>"
|
||||||
|
+ "<td><div>x</div><span>7 pages</span></td>"
|
||||||
|
+ "</tr></tbody></table></div></div></body></html>";
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn(page);
|
||||||
|
requests.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod();
|
||||||
|
|
||||||
|
String json = service.query("lazy", null, null);
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
|
assertTrue(json.contains("/t/lazy.jpg"), "应回退到 data-src 并去掉站点前缀");
|
||||||
|
assertFalse(json.contains("data:image"), "不应把 data URI 当缩略图");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页参数只应有一个生效:prev 优先于 next,且都要拼进请求 URL。 */
|
||||||
|
@Test
|
||||||
|
void queryAppendsOnlyOnePaginationParameter() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
requests.when(() -> GalleryUtil.requests(captor.capture(), anyString(), any(), any()))
|
||||||
|
.thenReturn(resultPage());
|
||||||
|
|
||||||
|
service.query("a b", "P", "N");
|
||||||
|
String url = captor.getValue();
|
||||||
|
assertTrue(url.contains("f_search=a+b"), "空格应转成加号: " + url);
|
||||||
|
assertTrue(url.contains("prev=P"));
|
||||||
|
assertFalse(url.contains("next=N"), "prev 存在时不应再拼 next");
|
||||||
|
|
||||||
|
service.query("a", null, "N");
|
||||||
|
assertTrue(captor.getValue().contains("next=N"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 含保留字符的关键词必须编码后再拼进上游 URL。
|
||||||
|
* 回归:未编码时 `&` 会被上游当作参数分隔符、中文会被当成非法字节,
|
||||||
|
* 搜索结果不可预期。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void queryUrlEncodesKeywordAndPaginationParams() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
requests.when(() -> GalleryUtil.requests(captor.capture(), anyString(), any(), any()))
|
||||||
|
.thenReturn(resultPage());
|
||||||
|
|
||||||
|
service.query("a&b=c", "P&1", null);
|
||||||
|
String url = captor.getValue();
|
||||||
|
assertTrue(url.contains("f_search=a%26b%3Dc"), "关键词必须编码: " + url);
|
||||||
|
assertTrue(url.contains("prev=P%261"), "分页参数必须编码: " + url);
|
||||||
|
// 编码后不应凭空多出上游会解析的裸参数
|
||||||
|
assertFalse(url.contains("b=c"), "未编码的 & 会分裂参数: " + url);
|
||||||
|
|
||||||
|
service.query("中文", null, null);
|
||||||
|
assertTrue(captor.getValue().contains("f_search=%E4%B8%AD%E6%96%87"),
|
||||||
|
"中文必须按 UTF-8 百分号编码: " + captor.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索页无结果行时必须返回业务失败,而不是抛异常。 */
|
||||||
|
@Test
|
||||||
|
void queryReturnsFailureWhenNoResultRows() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenReturn("<html><body><div class='ido'><div></div><div>"
|
||||||
|
+ "<table><tbody></tbody></table></div></div></body></html>");
|
||||||
|
|
||||||
|
String json = service.query("nothing", null, null);
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("没有搜索到结果"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游请求异常必须转成业务失败。 */
|
||||||
|
@Test
|
||||||
|
void queryReturnsFailureOnNetworkError() throws Exception {
|
||||||
|
try (var requests = mockStatic(GalleryUtil.class)) {
|
||||||
|
requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenThrow(new IOException("boom"));
|
||||||
|
|
||||||
|
String json = service.query("x", null, null);
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("query failure"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- getEhThumbnail ----------
|
||||||
|
|
||||||
|
/** path 不含「/」时无法拆出文件名,应直接 404,不能去碰缓存目录。 */
|
||||||
|
@Test
|
||||||
|
void thumbnailRequiresPathWithSlash() throws Exception {
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
|
||||||
|
service.getEhThumbnail("nodir", request, response);
|
||||||
|
|
||||||
|
verify(response).sendError(404);
|
||||||
|
verifyNoMoreInteractions(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存命中时直接导出文件,不再访问网络。 */
|
||||||
|
@Test
|
||||||
|
void thumbnailExportsCachedFile() throws Exception {
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
var servletContext = mock(jakarta.servlet.ServletContext.class);
|
||||||
|
when(request.getServletContext()).thenReturn(servletContext);
|
||||||
|
when(request.getHeader(anyString())).thenReturn(null);
|
||||||
|
|
||||||
|
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("thumb-test");
|
||||||
|
try {
|
||||||
|
// 缓存目录布局:<CachePath>/<fileName>.avif,find() 会按后缀查找
|
||||||
|
java.nio.file.Path cached = dir.resolve("abc.avif");
|
||||||
|
java.nio.file.Files.write(cached, new byte[]{1, 2, 3, 4});
|
||||||
|
|
||||||
|
try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) {
|
||||||
|
cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache
|
||||||
|
.get(any(), anyString(), any()))
|
||||||
|
.thenReturn(cached);
|
||||||
|
|
||||||
|
service.getEhThumbnail("123/abc.jpg", request, response);
|
||||||
|
|
||||||
|
verify(response).setStatus(200);
|
||||||
|
verify(response).setHeader(eq("Content-Disposition"), anyString());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try (var walk = java.nio.file.Files.walk(dir)) {
|
||||||
|
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存获取失败时必须降级为 404,且不得在响应已提交后再改状态。 */
|
||||||
|
@Test
|
||||||
|
void thumbnailReturns404WhenCacheFetchFails() throws Exception {
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
when(response.isCommitted()).thenReturn(false);
|
||||||
|
|
||||||
|
try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) {
|
||||||
|
cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache
|
||||||
|
.get(any(), anyString(), any()))
|
||||||
|
.thenThrow(new java.io.IOException("download failed"));
|
||||||
|
|
||||||
|
service.getEhThumbnail("123/abc.jpg", request, response);
|
||||||
|
|
||||||
|
verify(response).setStatus(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被中断时应置 503 并恢复中断标志,避免线程池吞掉中断信号。 */
|
||||||
|
@Test
|
||||||
|
void thumbnailReturns503OnInterruption() throws Exception {
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
|
||||||
|
try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) {
|
||||||
|
cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache
|
||||||
|
.get(any(), anyString(), any()))
|
||||||
|
.thenThrow(new InterruptedException("stop"));
|
||||||
|
|
||||||
|
service.getEhThumbnail("123/abc.jpg", request, response);
|
||||||
|
|
||||||
|
verify(response).setStatus(503);
|
||||||
|
assertTrue(Thread.currentThread().isInterrupted(), "中断标志应被恢复");
|
||||||
|
Thread.interrupted(); // 清理,避免影响后续测试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 响应已提交时不应再试图改状态码。 */
|
||||||
|
@Test
|
||||||
|
void thumbnailDoesNotOverrideCommittedResponse() throws Exception {
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
when(response.isCommitted()).thenReturn(true);
|
||||||
|
|
||||||
|
try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) {
|
||||||
|
cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache
|
||||||
|
.get(any(), anyString(), any()))
|
||||||
|
.thenThrow(new java.io.IOException("download failed"));
|
||||||
|
|
||||||
|
service.getEhThumbnail("123/abc.jpg", request, response);
|
||||||
|
|
||||||
|
verify(response, never()).setStatus(anyInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.Gallery;
|
||||||
|
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||||
|
import com.lion.lionwebsite.Message.DownloadStatusMessage;
|
||||||
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储节点上报的下载进度如何回写库并通知前端。
|
||||||
|
* 这段逻辑跑在 Netty 的 IO 线程里,异常不会被请求链路捕获,因此每个分支
|
||||||
|
* (未知任务、状态迁移、完成通知、进度更新)都要单独锁定。
|
||||||
|
*/
|
||||||
|
class RemoteServiceStatusTest {
|
||||||
|
|
||||||
|
private GalleryMapper galleryMapper;
|
||||||
|
private PushService pushService;
|
||||||
|
private WebSocketService webSocketService;
|
||||||
|
private SubscriptionStandbySnapshotService snapshotService;
|
||||||
|
private RemoteService service;
|
||||||
|
private EmbeddedChannel channel;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
galleryMapper = mock(GalleryMapper.class);
|
||||||
|
pushService = mock(PushService.class);
|
||||||
|
webSocketService = mock(WebSocketService.class);
|
||||||
|
snapshotService = mock(SubscriptionStandbySnapshotService.class);
|
||||||
|
service = new RemoteService(galleryMapper, pushService, webSocketService, snapshotService);
|
||||||
|
channel = new EmbeddedChannel(service.new MyChannelInboundHandlerAdapter());
|
||||||
|
service.channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
service.shutdownResources();
|
||||||
|
channel.finishAndReleaseAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gallery gallery(int gid, String name, String status, int pages) {
|
||||||
|
Gallery g = new Gallery();
|
||||||
|
g.setGid(gid);
|
||||||
|
g.setName(name);
|
||||||
|
g.setStatus(status);
|
||||||
|
g.setPages(pages);
|
||||||
|
g.setProceeding(0);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalleryTask task(int gid, String name, byte status, int proceeding) {
|
||||||
|
GalleryTask t = new GalleryTask();
|
||||||
|
t.setGid(gid);
|
||||||
|
t.setName(name);
|
||||||
|
t.setStatus(status);
|
||||||
|
t.setProceeding(proceeding);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DownloadStatusMessage status(GalleryTask... tasks) {
|
||||||
|
DownloadStatusMessage m = new DownloadStatusMessage();
|
||||||
|
m.setGalleryTasks(tasks);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载中:进度写入库,状态置为「下载中」,并推送给前端。 */
|
||||||
|
@Test
|
||||||
|
void downloadingUpdatesProgressAndNotifiesFrontend() {
|
||||||
|
Gallery existing = gallery(100, "G", "等待下载", 40);
|
||||||
|
when(galleryMapper.selectGalleryByGid(100)).thenReturn(existing);
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(100, "G", GalleryTask.DOWNLOADING, 7)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
|
||||||
|
assertEquals(7, existing.getProceeding());
|
||||||
|
assertEquals("下载中", existing.getStatus());
|
||||||
|
verify(galleryMapper).updateGallery(existing);
|
||||||
|
verify(webSocketService).updateTaskProcessing(any(GalleryTask[].class));
|
||||||
|
verify(pushService, never()).downloadComplete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 压缩完成:状态置「下载完成」,且只在首次完成时发一次通知。 */
|
||||||
|
@Test
|
||||||
|
void completionNotifiesOnlyOnFirstTransition() {
|
||||||
|
Gallery existing = gallery(101, "G", "下载中", 40);
|
||||||
|
when(galleryMapper.selectGalleryByGid(101)).thenReturn(existing);
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(101, "G", GalleryTask.COMPRESS_COMPLETE, 40)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
assertEquals("下载完成", existing.getStatus());
|
||||||
|
verify(pushService).downloadComplete(existing);
|
||||||
|
|
||||||
|
// 再次上报同一完成状态:不应重复通知
|
||||||
|
channel.writeInbound(status(task(101, "G", GalleryTask.COMPRESS_COMPLETE, 40)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
verify(pushService, times(1)).downloadComplete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三种中间状态的文案映射。 */
|
||||||
|
@Test
|
||||||
|
void intermediateStatusesMapToExpectedLabels() {
|
||||||
|
Gallery existing = gallery(102, "G", "x", 10);
|
||||||
|
when(galleryMapper.selectGalleryByGid(102)).thenReturn(existing);
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(102, "G", GalleryTask.COMPRESSING, 10)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
assertEquals("压缩中", existing.getStatus());
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(102, "G", GalleryTask.DOWNLOAD_COMPLETE, 10)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
assertEquals("等待压缩", existing.getStatus());
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(102, "G", GalleryTask.DOWNLOADING, 5)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
assertEquals("下载中", existing.getStatus());
|
||||||
|
assertEquals(5, existing.getProceeding());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未知 gid(库里没有)必须忽略且不落库,避免写入幽灵任务。 */
|
||||||
|
@Test
|
||||||
|
void unknownTaskIsIgnoredWithoutPersisting() {
|
||||||
|
when(galleryMapper.selectGalleryByGid(999)).thenReturn(null);
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(999, "ghost", GalleryTask.DOWNLOADING, 3)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
|
||||||
|
verify(galleryMapper, never()).updateGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点上报了新名称时同步过来(节点侧可能重命名过)。 */
|
||||||
|
@Test
|
||||||
|
void newNameFromNodeIsApplied() {
|
||||||
|
Gallery existing = gallery(103, "old-name", "下载中", 10);
|
||||||
|
when(galleryMapper.selectGalleryByGid(103)).thenReturn(existing);
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(103, "new-name", GalleryTask.DOWNLOADING, 1)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
|
||||||
|
assertEquals("new-name", existing.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量上报:每个任务都要独立处理,全部推送一次。 */
|
||||||
|
@Test
|
||||||
|
void batchReportProcessesEveryTask() {
|
||||||
|
when(galleryMapper.selectGalleryByGid(201)).thenReturn(gallery(201, "A", "x", 10));
|
||||||
|
when(galleryMapper.selectGalleryByGid(202)).thenReturn(gallery(202, "B", "x", 10));
|
||||||
|
|
||||||
|
channel.writeInbound(status(
|
||||||
|
task(201, "A", GalleryTask.DOWNLOADING, 1),
|
||||||
|
task(202, "B", GalleryTask.COMPRESSING, 2)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
|
||||||
|
verify(galleryMapper, times(2)).updateGallery(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成上报应唤醒等待重试结果的调用方。 */
|
||||||
|
@Test
|
||||||
|
void completionCompletesRetryWaiters() throws Exception {
|
||||||
|
Gallery existing = gallery(300, "G", "重试中", 10);
|
||||||
|
when(galleryMapper.selectGalleryByGid(300)).thenReturn(existing);
|
||||||
|
|
||||||
|
// 先发起重试,制造一个等待者;再让节点回报完成状态解除等待
|
||||||
|
channel.pipeline().addFirst(new io.netty.channel.ChannelOutboundHandlerAdapter() {
|
||||||
|
@Override
|
||||||
|
public void write(io.netty.channel.ChannelHandlerContext ctx, Object msg,
|
||||||
|
io.netty.channel.ChannelPromise promise) {
|
||||||
|
io.netty.util.concurrent.Promise<com.lion.lionwebsite.Message.AbstractMessage> inner =
|
||||||
|
service.promiseHashMap.get(((com.lion.lionwebsite.Message.AbstractMessage) msg).messageId);
|
||||||
|
if (inner != null) inner.trySuccess(new com.lion.lionwebsite.Message.ResponseMessage());
|
||||||
|
promise.setSuccess();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = service.retryGallery(existing);
|
||||||
|
assertNotNull(result);
|
||||||
|
|
||||||
|
channel.writeInbound(status(task(300, "G", GalleryTask.COMPRESS_COMPLETE, 10)));
|
||||||
|
service.awaitStatusApplied();
|
||||||
|
assertEquals("下载完成", existing.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
||||||
|
import com.lion.lionwebsite.Message.*;
|
||||||
|
import io.netty.channel.*;
|
||||||
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class RemoteServiceTest {
|
||||||
|
private static ResponseMessage response(int messageId, byte result) {
|
||||||
|
ResponseMessage message = new ResponseMessage();
|
||||||
|
message.setMessageId(messageId);
|
||||||
|
message.setResult(result);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RemoteService service() {
|
||||||
|
return new RemoteService(mock(GalleryMapper.class), mock(PushService.class),
|
||||||
|
mock(WebSocketService.class), mock(SubscriptionStandbySnapshotService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void immediateResponseHasRegisteredWaiter() {
|
||||||
|
RemoteService service = service();
|
||||||
|
EmbeddedChannel channel = new EmbeddedChannel(new ChannelOutboundHandlerAdapter() {
|
||||||
|
@Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
|
||||||
|
AbstractMessage request = (AbstractMessage) msg;
|
||||||
|
ctx.fireChannelRead(response(request.messageId, (byte) 0));
|
||||||
|
promise.setSuccess();
|
||||||
|
}
|
||||||
|
}, service.new MyChannelInboundHandlerAdapter());
|
||||||
|
service.channel = channel;
|
||||||
|
try {
|
||||||
|
assertEquals(0, service.checkAvailability());
|
||||||
|
assertTrue(service.promiseHashMap.isEmpty());
|
||||||
|
} finally { service.shutdownResources(); channel.finishAndReleaseAll(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void timeoutAndWriteFailureRemoveWaiters() {
|
||||||
|
RemoteService service = service();
|
||||||
|
EmbeddedChannel channel = new EmbeddedChannel(new ChannelOutboundHandlerAdapter() {
|
||||||
|
@Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
|
||||||
|
promise.setSuccess(); // no reply
|
||||||
|
}
|
||||||
|
});
|
||||||
|
service.channel = channel;
|
||||||
|
try {
|
||||||
|
assertEquals(-1, service.sendRequest(new AvailableCheckMessage(), 1, TimeUnit.MILLISECONDS));
|
||||||
|
assertTrue(service.promiseHashMap.isEmpty());
|
||||||
|
channel.pipeline().addLast(new ChannelOutboundHandlerAdapter() {
|
||||||
|
@Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
|
||||||
|
promise.setFailure(new IOException("test failure"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertEquals(-1, service.sendRequest(new AvailableCheckMessage(), 1, TimeUnit.SECONDS));
|
||||||
|
assertTrue(service.promiseHashMap.isEmpty());
|
||||||
|
} finally { service.shutdownResources(); channel.finishAndReleaseAll(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubBind;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import com.lion.lionwebsite.Domain.User;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子账号与绑定的业务规则。这些方法决定谁能拿到订阅、绑定到哪个账号,
|
||||||
|
* 校验失败必须返回业务 failure 而不是抛异常,且失败路径不得落库。
|
||||||
|
*/
|
||||||
|
class SubServiceTest {
|
||||||
|
|
||||||
|
private SubMapper subMapper;
|
||||||
|
private UserMapper userMapper;
|
||||||
|
private SubscriptionRefreshService refreshService;
|
||||||
|
private RemoteService remoteService;
|
||||||
|
private SubService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
subMapper = mock(SubMapper.class);
|
||||||
|
userMapper = mock(UserMapper.class);
|
||||||
|
refreshService = mock(SubscriptionRefreshService.class);
|
||||||
|
remoteService = mock(RemoteService.class);
|
||||||
|
service = new SubService(subMapper, userMapper, refreshService, remoteService,
|
||||||
|
new SubscriptionStateCoordinator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(Integer id, String name, String key, boolean enabled) {
|
||||||
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(id);
|
||||||
|
a.setName(name);
|
||||||
|
a.setUpstreamKey(key);
|
||||||
|
a.setFilterHighMultiplier(false);
|
||||||
|
a.setEnabled(enabled);
|
||||||
|
a.setBoundUserCount(0);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- insertSubscriptionAccount ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankNameOrKeyIsRejectedWithoutInsert() {
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("", "key", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("name", "", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount(" ", "key", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount(null, "key", false, true)));
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("name", null, false, true)));
|
||||||
|
verify(subMapper, never()).insertSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void duplicateNameOrKeyIsRejected() {
|
||||||
|
when(subMapper.countSubscriptionAccountName("dup")).thenReturn(1);
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("dup", "fresh-key", false, true)));
|
||||||
|
|
||||||
|
when(subMapper.countSubscriptionAccountName("fresh-name")).thenReturn(0);
|
||||||
|
when(subMapper.countSubscriptionAccountKey("used-key")).thenReturn(1);
|
||||||
|
assertFalse(ok(service.insertSubscriptionAccount("fresh-name", "used-key", false, true)));
|
||||||
|
|
||||||
|
verify(subMapper, never()).insertSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称与上游 key 两端空白应被裁剪后再校验与入库。 */
|
||||||
|
@Test
|
||||||
|
void valuesAreTrimmedBeforePersisting() {
|
||||||
|
when(subMapper.countSubscriptionAccountName("trimmed")).thenReturn(0);
|
||||||
|
when(subMapper.countSubscriptionAccountKey("key123")).thenReturn(0);
|
||||||
|
doAnswer(inv -> {
|
||||||
|
inv.getArgument(0, SubscriptionAccount.class).setId(5);
|
||||||
|
return null;
|
||||||
|
}).when(subMapper).insertSubscriptionAccount(any());
|
||||||
|
|
||||||
|
assertTrue(ok(service.insertSubscriptionAccount(" trimmed ", " key123 ", true, false)));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(SubscriptionAccount.class);
|
||||||
|
verify(subMapper).insertSubscriptionAccount(captor.capture());
|
||||||
|
assertEquals("trimmed", captor.getValue().getName());
|
||||||
|
assertEquals("key123", captor.getValue().getUpstreamKey());
|
||||||
|
assertTrue(captor.getValue().isFilterHighMultiplier());
|
||||||
|
assertFalse(captor.getValue().isEnabled(), "enabled 应原样透传");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** enabled=true 时才立刻刷新并同步;enabled=false 时不应触发上游刷新。 */
|
||||||
|
@Test
|
||||||
|
void refreshAndSyncOnlyHappenForEnabledAccount() {
|
||||||
|
when(subMapper.countSubscriptionAccountName(anyString())).thenReturn(0);
|
||||||
|
when(subMapper.countSubscriptionAccountKey(anyString())).thenReturn(0);
|
||||||
|
doAnswer(inv -> {
|
||||||
|
inv.getArgument(0, SubscriptionAccount.class).setId(9);
|
||||||
|
return null;
|
||||||
|
}).when(subMapper).insertSubscriptionAccount(any());
|
||||||
|
|
||||||
|
service.insertSubscriptionAccount("enabled-one", "k1", false, true);
|
||||||
|
verify(refreshService).refresh(9);
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
|
||||||
|
clearInvocations(refreshService, remoteService);
|
||||||
|
service.insertSubscriptionAccount("disabled-one", "k2", false, false);
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- updateSubscriptionAccount ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatingMissingAccountFails() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(404)).thenReturn(null);
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(404, "n", "k", false, true)));
|
||||||
|
verify(subMapper, never()).updateSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatingWithBlankFieldsFails() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "old", "old-key", true));
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "", "key", false, true)));
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "name", " ", false, true)));
|
||||||
|
verify(subMapper, never()).updateSubscriptionAccount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改时不得与「其他」账号重名或重 key,但与自己相同应允许。 */
|
||||||
|
@Test
|
||||||
|
void updateRejectsConflictsWithOtherAccountsButAllowsSelf() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "mine", "my-key", true));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts())
|
||||||
|
.thenReturn(new ArrayList<>(java.util.List.of(
|
||||||
|
account(1, "mine", "my-key", true),
|
||||||
|
account(2, "taken", "taken-key", true))));
|
||||||
|
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "taken", "my-key", false, true)),
|
||||||
|
"与其他账号重名应拒绝");
|
||||||
|
assertFalse(ok(service.updateSubscriptionAccount(1, "mine", "taken-key", false, true)),
|
||||||
|
"与其他账号重 key 应拒绝");
|
||||||
|
|
||||||
|
assertTrue(ok(service.updateSubscriptionAccount(1, "mine", "my-key", false, true)),
|
||||||
|
"与自身相同的名称/key 应允许");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRejectsAccountThatStillHasBindings() {
|
||||||
|
SubscriptionAccount bound = account(3, "bound", "k", true);
|
||||||
|
bound.setBoundUserCount(2);
|
||||||
|
when(subMapper.selectSubscriptionAccount(3)).thenReturn(bound);
|
||||||
|
|
||||||
|
assertFalse(ok(service.deleteSubscriptionAccount(3)));
|
||||||
|
verify(subMapper, never()).deleteSubscriptionAccount(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSucceedsWhenNoBindingsRemain() {
|
||||||
|
SubscriptionAccount free = account(4, "free", "k", true);
|
||||||
|
free.setBoundUserCount(0);
|
||||||
|
when(subMapper.selectSubscriptionAccount(4)).thenReturn(free);
|
||||||
|
|
||||||
|
assertTrue(ok(service.deleteSubscriptionAccount(4)));
|
||||||
|
verify(subMapper).deleteSubscriptionAccount(4);
|
||||||
|
verify(refreshService).invalidateCache(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- insertSubBind ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindRejectsUnknownUser() {
|
||||||
|
when(userMapper.selectUserByUsername("ghost")).thenReturn(null);
|
||||||
|
assertFalse(ok(service.insertSubBind("ghost", 1)));
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindRejectsDisabledOrMissingAccount() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(7)).thenReturn(null);
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 7)));
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(8)).thenReturn(account(8, "off", "k", false));
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 8)), "已停用账号不可绑定");
|
||||||
|
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号尚无完整缓存时必须拒绝,否则用户会拿到空订阅。 */
|
||||||
|
@Test
|
||||||
|
void bindRejectsAccountWithoutCompleteCache() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(false);
|
||||||
|
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 1)));
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindRejectsUserThatAlreadyHasBinding() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(true);
|
||||||
|
when(subMapper.countSubBindByUser("alice")).thenReturn(1);
|
||||||
|
|
||||||
|
assertFalse(ok(service.insertSubBind("alice", 1)));
|
||||||
|
verify(subMapper, never()).insertSubBind(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功绑定时 key 应是 8 位随机串,且会避开已存在的 key。 */
|
||||||
|
@Test
|
||||||
|
void bindGeneratesUniqueEightCharKey() {
|
||||||
|
when(userMapper.selectUserByUsername("alice")).thenReturn(new User());
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(true);
|
||||||
|
when(subMapper.countSubBindByUser("alice")).thenReturn(0);
|
||||||
|
// 前两次「已存在」,第三次通过——验证重试而非直接失败
|
||||||
|
when(subMapper.selectSubBindExist(anyString())).thenReturn(true, true, false);
|
||||||
|
|
||||||
|
assertTrue(ok(service.insertSubBind("alice", 1)));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(SubBind.class);
|
||||||
|
verify(subMapper).insertSubBind(captor.capture());
|
||||||
|
assertEquals(8, captor.getValue().getKey().length(), "订阅 key 应为 8 位");
|
||||||
|
assertEquals("alice", captor.getValue().getUser());
|
||||||
|
assertEquals(1, captor.getValue().getSubscriptionAccountId());
|
||||||
|
verify(subMapper, times(3)).selectSubBindExist(anyString());
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetKeyRejectsUserWithoutBinding() {
|
||||||
|
when(subMapper.countSubBindByUser("nobody")).thenReturn(0);
|
||||||
|
assertFalse(ok(service.resetKey("nobody")));
|
||||||
|
verify(subMapper, never()).updateSubBindKey(anyString(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetKeyReplacesKeyAndClearsAccessRecords() {
|
||||||
|
when(subMapper.countSubBindByUser("alice")).thenReturn(1);
|
||||||
|
when(subMapper.selectSubBindExist(anyString())).thenReturn(false);
|
||||||
|
|
||||||
|
assertTrue(ok(service.resetKey("alice")));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(subMapper).updateSubBindKey(eq("alice"), captor.capture());
|
||||||
|
assertEquals(8, captor.getValue().length());
|
||||||
|
verify(subMapper).deleteSubUpdateRecord("alice");
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rebindRejectsDisabledTargetAndMissingBinding() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(2)).thenReturn(account(2, "off", "k", false));
|
||||||
|
assertFalse(ok(service.rebind("alice", 2)), "目标账号停用应拒绝");
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(3)).thenReturn(account(3, "on", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(3)).thenReturn(true);
|
||||||
|
when(subMapper.updateSubBindAccount("alice", 3)).thenReturn(0);
|
||||||
|
assertFalse(ok(service.rebind("alice", 3)), "无既有绑定应拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rebindSucceedsWhenTargetHealthyAndBindingExists() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(3)).thenReturn(account(3, "on", "k", true));
|
||||||
|
when(refreshService.hasCompleteCache(3)).thenReturn(true);
|
||||||
|
when(subMapper.updateSubBindAccount("alice", 3)).thenReturn(1);
|
||||||
|
|
||||||
|
assertTrue(ok(service.rebind("alice", 3)));
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 公开订阅分发 updateSub ----------
|
||||||
|
|
||||||
|
/** 未知 key 必须回 404,不能泄漏「账号存在但未绑定」这类差异。 */
|
||||||
|
@Test
|
||||||
|
void publicSubReturns404ForUnknownKey() throws Exception {
|
||||||
|
when(subMapper.selectSubBind("nokey")).thenReturn(null);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "nokey");
|
||||||
|
verify(response).sendError(eq(404), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicSubReturns404WhenAccountDisabled() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", false, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "key1");
|
||||||
|
verify(response).sendError(eq(404), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicSubReturns404WhenAccountIdMissing() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", null, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "key1");
|
||||||
|
verify(response).sendError(eq(404), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非法 client(既非 v2 也非 cat)应回 400,避免把未知格式当订阅返回。 */
|
||||||
|
@Test
|
||||||
|
void publicSubRejectsUnknownClient() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0");
|
||||||
|
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
|
||||||
|
service.updateSub(response, request, "clashmeta", "key1");
|
||||||
|
verify(response).sendError(eq(400), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存文件缺失时应回 503(暂时不可用),而不是 404 或空响应。 */
|
||||||
|
@Test
|
||||||
|
void publicSubReturns503WhenCacheMissing() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
when(refreshService.cachedPath(eq(1), anyString()))
|
||||||
|
.thenReturn(java.nio.file.Path.of("/nonexistent/cache/v2.txt"));
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0");
|
||||||
|
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
|
||||||
|
service.updateSub(response, request, "v2", "key1");
|
||||||
|
verify(response).sendError(eq(503), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicSubIgnoresNullKeyOrClient() throws Exception {
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
service.updateSub(response, request, null, "key1");
|
||||||
|
service.updateSub(response, request, "v2", null);
|
||||||
|
verifyNoInteractions(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取订阅时不能等待第三方归属地查询:记录先以 unknown 落库并立即返回订阅内容。
|
||||||
|
* 回归:原先同步请求 ip138,用户取一次订阅要先等最长十几秒的外呼。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void publicSubRecordsUnknownLocationWithoutWaitingForLookup() throws Exception {
|
||||||
|
SubBind bind = new SubBind("key1", "alice", 1, "name", true, false);
|
||||||
|
when(subMapper.selectSubBind("key1")).thenReturn(bind);
|
||||||
|
// 缓存文件不存在即可(本用例只关心分发不等待定位查询),与既有 503 用例保持一致。
|
||||||
|
when(refreshService.cachedPath(eq(1), anyString()))
|
||||||
|
.thenReturn(java.nio.file.Path.of("/nonexistent/cache/v2.txt"));
|
||||||
|
var request = mock(jakarta.servlet.http.HttpServletRequest.class);
|
||||||
|
when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0");
|
||||||
|
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
|
||||||
|
var response = mock(jakarta.servlet.http.HttpServletResponse.class);
|
||||||
|
|
||||||
|
// 用受控执行器换取「后台任务被投递」这一事实,避免静态 mock 到不了后台线程、
|
||||||
|
// 也避免单测真的去请求 ip138。
|
||||||
|
var recorded = new java.util.concurrent.atomic.AtomicReference<Runnable>();
|
||||||
|
service.locationExecutor = command -> recorded.set(command);
|
||||||
|
|
||||||
|
long start = System.nanoTime();
|
||||||
|
service.updateSub(response, request, "v2", "key1");
|
||||||
|
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
assertTrue(elapsedMs < 2_000, "分发不应等待归属地查询,实际耗时 " + elapsedMs + "ms");
|
||||||
|
assertNotNull(recorded.get(), "归属地查询应被投递到后台执行,而不是同步执行");
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(com.lion.lionwebsite.Domain.SubUpdateRecord.class);
|
||||||
|
verify(subMapper).insertSubUpdateRecord(captor.capture());
|
||||||
|
assertEquals("unknown", captor.getValue().getLocation(), "先落库 unknown,随后由后台补齐");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台补齐只更新最近一条记录,且定位失败不影响分发。 */
|
||||||
|
@Test
|
||||||
|
void locationLookupFallsBackToUnknownOnFailure() {
|
||||||
|
try (var util = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) {
|
||||||
|
util.when(() -> com.lion.lionwebsite.Util.GalleryUtil
|
||||||
|
.requests(anyString(), anyString(), any(), any()))
|
||||||
|
.thenThrow(new RuntimeException("network down"));
|
||||||
|
|
||||||
|
assertEquals("unknown", SubService.resolveLocation("203.0.113.5"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅抓取与「高倍率节点过滤」。
|
||||||
|
* 这段逻辑决定终端用户最终拿到哪些节点,并且会把结果落盘供分发;
|
||||||
|
* 因此既要验证过滤规则本身,也要验证失败时不会写坏缓存、不会把旧内容覆盖成空。
|
||||||
|
*/
|
||||||
|
class SubscriptionFilteringTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path cacheRoot;
|
||||||
|
|
||||||
|
private SubMapper subMapper;
|
||||||
|
private SubscriptionRefreshService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
subMapper = mock(SubMapper.class);
|
||||||
|
// download() 要打桩,因此用 spy 保留真实实现
|
||||||
|
service = spy(new SubscriptionRefreshService(subMapper, new SubscriptionStateCoordinator()));
|
||||||
|
service.cacheRoot = cacheRoot.toString();
|
||||||
|
service.v2UrlTemplate = "https://upstream.example/sub/{key}?client=v2";
|
||||||
|
service.clashUrlTemplate = "https://upstream.example/sub/{key}?client=clashmeta";
|
||||||
|
service.highMultiplierThreshold = 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(int id, String key, boolean enabled, boolean filter) {
|
||||||
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(id);
|
||||||
|
a.setName("acc-" + id);
|
||||||
|
a.setUpstreamKey(key);
|
||||||
|
a.setEnabled(enabled);
|
||||||
|
a.setFilterHighMultiplier(filter);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把节点列表编码成上游返回的 Base64 单行格式。 */
|
||||||
|
private static List<String> v2Upstream(String... nodes) {
|
||||||
|
String joined = String.join("\n", nodes);
|
||||||
|
return List.of(Base64.getEncoder().encodeToString(joined.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解回 v2 缓存内容,便于断言过滤结果。 */
|
||||||
|
private static String decodeV2(List<String> upstream) {
|
||||||
|
return new String(Base64.getMimeDecoder().decode(upstream.getFirst()), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubUpstream(List<String> v2, List<String> clash) throws Exception {
|
||||||
|
doReturn(v2).when(service).download(contains("client=v2"), any());
|
||||||
|
doReturn(clash).when(service).download(contains("client=clashmeta"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- URL 模板 ----------
|
||||||
|
|
||||||
|
/** {key} 必须被 URL 编码后替换,避免特殊字符破坏 URL。 */
|
||||||
|
@Test
|
||||||
|
void urlTemplateEncodesKey() {
|
||||||
|
// URLEncoder 采用 application/x-www-form-urlencoded:空格编码为 '+'
|
||||||
|
assertEquals("https://upstream.example/sub/a%2Fb+c?client=v2",
|
||||||
|
service.v2Url(account(1, "a/b c", true, false)));
|
||||||
|
assertEquals("https://upstream.example/sub/a%2Fb?client=v2",
|
||||||
|
service.v2Url(account(1, "a/b", true, false)), "斜杠应被编码");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板缺少 {key} 或含多个 {key} 时必须在发请求前就失败。 */
|
||||||
|
@Test
|
||||||
|
void urlTemplateMustContainExactlyOneKeyPlaceholder() {
|
||||||
|
service.v2UrlTemplate = "https://upstream.example/sub/?client=v2";
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.v2Url(account(1, "k", true, false)));
|
||||||
|
|
||||||
|
service.v2UrlTemplate = "https://x/{key}/{key}";
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.v2Url(account(1, "k", true, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 成功路径 ----------
|
||||||
|
|
||||||
|
/** 成功刷新:写入两份缓存、标记成功,返回 true。 */
|
||||||
|
@Test
|
||||||
|
void refreshWritesBothCachesAndMarksSuccess() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(1, "key1", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(acc);
|
||||||
|
stubUpstream(v2Upstream("vmess://node-a"), List.of("proxies:", " - name: \"a\""));
|
||||||
|
|
||||||
|
assertTrue(service.refresh(1));
|
||||||
|
|
||||||
|
Path v2 = cacheRoot.resolve("1/v2ray.txt");
|
||||||
|
Path clash = cacheRoot.resolve("1/clash.yaml");
|
||||||
|
assertTrue(Files.isRegularFile(v2), "应写入 v2 缓存");
|
||||||
|
assertTrue(Files.isRegularFile(clash), "应写入 clash 缓存");
|
||||||
|
// v2 缓存本身是 Base64 文本,需解码后再断言节点内容
|
||||||
|
String decodedV2 = new String(Base64.getMimeDecoder()
|
||||||
|
.decode(Files.readString(v2)), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(decodedV2.contains("vmess://node-a"), "实际内容: " + decodedV2);
|
||||||
|
verify(subMapper).markSubscriptionRefreshSuccess(eq(1), anyLong());
|
||||||
|
verify(subMapper, never()).markSubscriptionRefreshFailure(anyInt(), anyString());
|
||||||
|
// 临时文件不应残留
|
||||||
|
assertFalse(Files.exists(cacheRoot.resolve("1/clash.yaml.tmp")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用账号或不存在账号直接返回 false,且不发任何网络请求。 */
|
||||||
|
@Test
|
||||||
|
void refreshSkipsDisabledOrMissingAccount() {
|
||||||
|
when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "k", false, false));
|
||||||
|
assertFalse(service.refresh(1));
|
||||||
|
|
||||||
|
when(subMapper.selectSubscriptionAccount(2)).thenReturn(null);
|
||||||
|
assertFalse(service.refresh(2));
|
||||||
|
|
||||||
|
verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 高倍率过滤(v2) ----------
|
||||||
|
|
||||||
|
/** 过滤开启时,名称以「>2x」结尾的节点应被剔除,其余保留。 */
|
||||||
|
@Test
|
||||||
|
void v2FilterDropsHighMultiplierNodes() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(5, "key5", true, true);
|
||||||
|
when(subMapper.selectSubscriptionAccount(5)).thenReturn(acc);
|
||||||
|
stubUpstream(v2Upstream(
|
||||||
|
"vmess://aaa#keep-1",
|
||||||
|
"vmess://bbb#香港 5x",
|
||||||
|
"vmess://ccc#keep-2"), List.of("proxies:"));
|
||||||
|
|
||||||
|
assertTrue(service.refresh(5));
|
||||||
|
|
||||||
|
String content = Files.readString(cacheRoot.resolve("5/v2ray.txt"), StandardCharsets.UTF_8);
|
||||||
|
String decoded = new String(Base64.getMimeDecoder().decode(content), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(decoded.contains("keep-1"), "实际内容: " + decoded);
|
||||||
|
assertTrue(decoded.contains("keep-2"), "实际内容: " + decoded);
|
||||||
|
assertFalse(decoded.contains("5x"), "高倍率节点应被剔除: " + decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 过滤关闭时,高倍率节点必须原样保留。 */
|
||||||
|
@Test
|
||||||
|
void v2WithoutFilterKeepsHighMultiplierNodes() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(6, "key6", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(6)).thenReturn(acc);
|
||||||
|
stubUpstream(v2Upstream("vmess://bbb#香港 5x"), List.of("proxies:"));
|
||||||
|
|
||||||
|
assertTrue(service.refresh(6));
|
||||||
|
|
||||||
|
String content = Files.readString(cacheRoot.resolve("6/v2ray.txt"), StandardCharsets.UTF_8);
|
||||||
|
String decoded = new String(Base64.getMimeDecoder().decode(content), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(decoded.contains("5x"), "未开启过滤时应保留: " + decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名称里没有倍率标记的节点一律保留(无法判定即不删)。 */
|
||||||
|
@Test
|
||||||
|
void v2FilterKeepsNodesWithoutMultiplierMarker() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(7, "key7", true, true);
|
||||||
|
when(subMapper.selectSubscriptionAccount(7)).thenReturn(acc);
|
||||||
|
stubUpstream(v2Upstream("vmess://aaa#plain-node", "trojan://bbb#another"), List.of("proxies:"));
|
||||||
|
|
||||||
|
assertTrue(service.refresh(7));
|
||||||
|
|
||||||
|
String decoded = decodeV2(List.of(Files.readString(cacheRoot.resolve("7/v2ray.txt"))));
|
||||||
|
assertTrue(decoded.contains("plain-node"));
|
||||||
|
assertTrue(decoded.contains("another"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恰好等于阈值(2x)不算高倍率,只有严格大于才剔除。 */
|
||||||
|
@Test
|
||||||
|
void v2FilterTreatsThresholdAsExclusive() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(8, "key8", true, true);
|
||||||
|
when(subMapper.selectSubscriptionAccount(8)).thenReturn(acc);
|
||||||
|
stubUpstream(v2Upstream("vmess://aaa#节点 2x", "vmess://bbb#节点 2.1x"), List.of("proxies:"));
|
||||||
|
|
||||||
|
assertTrue(service.refresh(8));
|
||||||
|
|
||||||
|
String decoded = decodeV2(List.of(Files.readString(cacheRoot.resolve("8/v2ray.txt"))));
|
||||||
|
assertTrue(decoded.contains("节点 2x"), "等于阈值应保留: " + decoded);
|
||||||
|
assertFalse(decoded.contains("2.1x"), "超过阈值应剔除: " + decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 高倍率过滤(clash) ----------
|
||||||
|
|
||||||
|
/** clash 侧:被剔除节点的定义与其在 proxy-groups 里的引用都要清理。 */
|
||||||
|
@Test
|
||||||
|
void clashFilterRemovesNodeDefinitionAndGroupReference() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(9, "key9", true, true);
|
||||||
|
when(subMapper.selectSubscriptionAccount(9)).thenReturn(acc);
|
||||||
|
|
||||||
|
List<String> clashUpstream = List.of(
|
||||||
|
"proxies:",
|
||||||
|
" - name: \"keep\"",
|
||||||
|
" type: vmess",
|
||||||
|
" - name: \"drop 5x\"",
|
||||||
|
" type: vmess",
|
||||||
|
"proxy-groups:",
|
||||||
|
" - name: \"auto\"",
|
||||||
|
" proxies:",
|
||||||
|
" - keep",
|
||||||
|
" - \"drop 5x\"");
|
||||||
|
stubUpstream(v2Upstream("vmess://x"), clashUpstream);
|
||||||
|
|
||||||
|
assertTrue(service.refresh(9));
|
||||||
|
|
||||||
|
String yaml = Files.readString(cacheRoot.resolve("9/clash.yaml"), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(yaml.contains("keep"), "保留节点应在: " + yaml);
|
||||||
|
assertFalse(yaml.contains("drop 5x"), "高倍率节点不应出现(含分组引用): " + yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 过滤关闭时定与引用都应原样保留。 */
|
||||||
|
@Test
|
||||||
|
void clashWithoutFilterKeepsEverything() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(10, "key10", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(10)).thenReturn(acc);
|
||||||
|
|
||||||
|
List<String> clashUpstream = List.of(
|
||||||
|
"proxies:",
|
||||||
|
" - name: \"keep 5x\"",
|
||||||
|
"proxy-groups:",
|
||||||
|
" - name: \"auto\"",
|
||||||
|
" proxies:",
|
||||||
|
" - \"keep 5x\"");
|
||||||
|
stubUpstream(v2Upstream("vmess://x"), clashUpstream);
|
||||||
|
|
||||||
|
assertTrue(service.refresh(10));
|
||||||
|
|
||||||
|
String yaml = Files.readString(cacheRoot.resolve("10/clash.yaml"), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(yaml.contains("keep 5x"), "未过滤时应保留: " + yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描只应作用于 proxies 段,proxy-groups 段里的名字即便带倍率也不剔除。 */
|
||||||
|
@Test
|
||||||
|
void clashFilterOnlyAppliesInsideProxiesSection() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(11, "key11", true, true);
|
||||||
|
when(subMapper.selectSubscriptionAccount(11)).thenReturn(acc);
|
||||||
|
|
||||||
|
List<String> clashUpstream = List.of(
|
||||||
|
"proxies:",
|
||||||
|
" - name: \"nodeA\"",
|
||||||
|
"proxy-groups:",
|
||||||
|
" - name: \"group 9x\""); // 分组名带倍率,但不在 proxies 段
|
||||||
|
stubUpstream(v2Upstream("vmess://x"), clashUpstream);
|
||||||
|
|
||||||
|
assertTrue(service.refresh(11));
|
||||||
|
|
||||||
|
String yaml = Files.readString(cacheRoot.resolve("11/clash.yaml"), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(yaml.contains("group 9x"), "proxy-groups 段不应被过滤: " + yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 失败路径 ----------
|
||||||
|
|
||||||
|
/** 上游返回空内容必须失败并记录错误,且不写缓存。 */
|
||||||
|
@Test
|
||||||
|
void refreshFailsWhenUpstreamReturnsEmpty() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(12, "key12", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(12)).thenReturn(acc);
|
||||||
|
doReturn(new ArrayList<String>()).when(service).download(anyString(), any());
|
||||||
|
|
||||||
|
assertFalse(service.refresh(12));
|
||||||
|
|
||||||
|
verify(subMapper).markSubscriptionRefreshFailure(eq(12), contains("为空"));
|
||||||
|
verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt(), anyLong());
|
||||||
|
assertFalse(Files.exists(cacheRoot.resolve("12/v2ray.txt")), "失败时不应产出缓存");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游不是合法 Base64 时必须失败并记录原因。 */
|
||||||
|
@Test
|
||||||
|
void refreshFailsOnInvalidBase64() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(13, "key13", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(13)).thenReturn(acc);
|
||||||
|
stubUpstream(List.of("!!!not-base64!!!"), List.of("proxies:"));
|
||||||
|
|
||||||
|
assertFalse(service.refresh(13));
|
||||||
|
|
||||||
|
verify(subMapper).markSubscriptionRefreshFailure(eq(13), contains("Base64"));
|
||||||
|
assertFalse(Files.exists(cacheRoot.resolve("13/v2ray.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网络异常同样必须被兜住并记账,不能向上抛。 */
|
||||||
|
@Test
|
||||||
|
void refreshFailsOnNetworkError() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(14, "key14", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(14)).thenReturn(acc);
|
||||||
|
doThrow(new java.io.IOException("connection refused")).when(service).download(anyString(), any());
|
||||||
|
|
||||||
|
assertFalse(service.refresh(14));
|
||||||
|
|
||||||
|
verify(subMapper).markSubscriptionRefreshFailure(eq(14), contains("connection refused"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误信息超过 500 字符时截断,避免写爆数据库字段。 */
|
||||||
|
@Test
|
||||||
|
void refreshTruncatesLongErrorMessages() throws Exception {
|
||||||
|
SubscriptionAccount acc = account(15, "key15", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(15)).thenReturn(acc);
|
||||||
|
doThrow(new java.io.IOException("x".repeat(900))).when(service).download(anyString(), any());
|
||||||
|
|
||||||
|
assertFalse(service.refresh(15));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(subMapper).markSubscriptionRefreshFailure(eq(15), captor.capture());
|
||||||
|
assertEquals(500, captor.getValue().length(), "错误信息应截断到 500 字符");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 全量刷新 ----------
|
||||||
|
//
|
||||||
|
// 「遍历所有启用账号」的批量入口已从本服务移除(它正是会一次性打满上游的爆发写法),
|
||||||
|
// 现在由 SubscriptionRefreshScheduler 按账号到期逐个刷新。
|
||||||
|
// 调度行为与失败隔离见 SubscriptionRefreshSchedulerTest。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个账号刷新失败时,其余账号仍应正常写入缓存——失败隔离是分散刷新能替代
|
||||||
|
* 批量刷新的前提:一个坏账号不该拖垮其他人。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void oneAccountFailureDoesNotPreventOthersFromCaching() throws Exception {
|
||||||
|
SubscriptionAccount ok = account(20, "k20", true, false);
|
||||||
|
SubscriptionAccount bad = account(22, "k22", true, false);
|
||||||
|
when(subMapper.selectSubscriptionAccount(20)).thenReturn(ok);
|
||||||
|
when(subMapper.selectSubscriptionAccount(22)).thenReturn(bad);
|
||||||
|
doAnswer(inv -> {
|
||||||
|
String url = inv.getArgument(0);
|
||||||
|
if (url.contains("k22"))
|
||||||
|
throw new java.io.IOException("account 22 upstream down");
|
||||||
|
if (url.contains("client=v2"))
|
||||||
|
return List.of(Base64.getEncoder().encodeToString("vmess://a".getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return List.of("proxies:");
|
||||||
|
}).when(service).download(anyString(), any());
|
||||||
|
|
||||||
|
assertTrue(service.refresh(20));
|
||||||
|
assertFalse(service.refresh(22));
|
||||||
|
|
||||||
|
verify(subMapper).markSubscriptionRefreshSuccess(eq(20), anyLong());
|
||||||
|
verify(subMapper).markSubscriptionRefreshFailure(eq(22), anyString());
|
||||||
|
assertTrue(Files.exists(cacheRoot.resolve("20/v2ray.txt")), "成功账号应落盘");
|
||||||
|
assertFalse(Files.exists(cacheRoot.resolve("22/v2ray.txt")), "失败账号不应落盘");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 缓存状态 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasCompleteCacheRequiresBothFiles() throws Exception {
|
||||||
|
assertFalse(service.hasCompleteCache(40));
|
||||||
|
|
||||||
|
Files.createDirectories(cacheRoot.resolve("40"));
|
||||||
|
Files.writeString(cacheRoot.resolve("40/v2ray.txt"), "x");
|
||||||
|
assertFalse(service.hasCompleteCache(40), "只有 v2 时应为不完整");
|
||||||
|
|
||||||
|
Files.writeString(cacheRoot.resolve("40/clash.yaml"), "y");
|
||||||
|
assertTrue(service.hasCompleteCache(40));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失效缓存应删除两份文件,使同一账号的被拒请求不再拿到旧内容。 */
|
||||||
|
@Test
|
||||||
|
void invalidateCacheRemovesBothFiles() throws Exception {
|
||||||
|
Files.createDirectories(cacheRoot.resolve("41"));
|
||||||
|
Files.writeString(cacheRoot.resolve("41/v2ray.txt"), "x");
|
||||||
|
Files.writeString(cacheRoot.resolve("41/clash.yaml"), "y");
|
||||||
|
|
||||||
|
service.invalidateCache(41);
|
||||||
|
|
||||||
|
assertFalse(Files.exists(cacheRoot.resolve("41/v2ray.txt")));
|
||||||
|
assertFalse(Files.exists(cacheRoot.resolve("41/clash.yaml")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存文件本就不存在时不应抛异常。 */
|
||||||
|
@Test
|
||||||
|
void invalidateCacheIsIdempotent() {
|
||||||
|
assertDoesNotThrow(() -> service.invalidateCache(42));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅刷新排程:把「一天刷一次」变成「分散到一天里、但保证 24 小时内每个账号都刷到」。
|
||||||
|
*
|
||||||
|
* <p>这里锁定三条最关键的契约:
|
||||||
|
* <ol>
|
||||||
|
* <li>首次排程真的把账号铺开,不是挤在一起(否则等于没改);</li>
|
||||||
|
* <li>下一次刷新一定在窗口内,这是「24 小时内必刷一次」的硬保证;</li>
|
||||||
|
* <li>兜底裁剪能兜住计划缺失、长期失败等异常,不会让账号无限期不刷。</li>
|
||||||
|
* </ol>
|
||||||
|
* 全部用固定时钟与固定随机种子,结果可复现。
|
||||||
|
*/
|
||||||
|
class SubscriptionRefreshPlannerTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final Instant BASE = Instant.parse("2026-09-15T02:00:00Z"); // 北京时间 10:00
|
||||||
|
|
||||||
|
private static SubscriptionRefreshPlanner planner(long seed, Duration window, Duration minGap, Duration tick) {
|
||||||
|
return new SubscriptionRefreshPlanner(Clock.fixed(BASE, ZONE), new Random(seed), window, minGap, tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionRefreshPlanner defaultPlanner(long seed) {
|
||||||
|
return planner(seed, Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> ids(int count) {
|
||||||
|
List<Integer> list = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= count; i++)
|
||||||
|
list.add(i);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 首次排程:分散 ----------
|
||||||
|
|
||||||
|
/** 每个账号都必须拿到计划,且都落在「现在」到「现在 + 窗口」之间。 */
|
||||||
|
@Test
|
||||||
|
void initialSchedulePlacesEveryAccountInsideWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
List<Long> planned = planner.initialSchedule(ids(12));
|
||||||
|
|
||||||
|
assertEquals(12, planned.size());
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
for (long at : planned) {
|
||||||
|
assertTrue(at >= now, "计划不应早于当前时刻: " + at);
|
||||||
|
assertTrue(at <= now + Duration.ofHours(24).toMillis(), "计划不应超出窗口: " + at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 12 个账号必须被铺开到明显超过「挨个背靠背」的跨度。
|
||||||
|
* 旧实现里它们全部落在 16 秒内,这里要求至少铺开窗口的一半。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void initialScheduleSpreadsAccountsAcrossWindow() {
|
||||||
|
var planner = defaultPlanner(7);
|
||||||
|
List<Long> planned = planner.initialSchedule(ids(12));
|
||||||
|
|
||||||
|
long min = planned.stream().mapToLong(Long::longValue).min().orElseThrow();
|
||||||
|
long max = planned.stream().mapToLong(Long::longValue).max().orElseThrow();
|
||||||
|
long spread = max - min;
|
||||||
|
|
||||||
|
assertTrue(spread >= Duration.ofHours(12).toMillis(),
|
||||||
|
"12 个账号应铺开至少 12 小时,实际跨度 " + Duration.ofMillis(spread).toHours() + " 小时");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序后相邻账号之间应保持最小间隔,避免两个账号在同一分钟内打上游。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleRespectsMinimumGap() {
|
||||||
|
var planner = defaultPlanner(3);
|
||||||
|
List<Long> planned = new ArrayList<>(planner.initialSchedule(ids(12)));
|
||||||
|
planned.sort(Long::compareTo);
|
||||||
|
|
||||||
|
long minGap = Duration.ofHours(1).toMillis();
|
||||||
|
for (int i = 1; i < planned.size(); i++) {
|
||||||
|
long gap = planned.get(i) - planned.get(i - 1);
|
||||||
|
assertTrue(gap >= minGap,
|
||||||
|
"第 " + i + " 个间隔只有 " + Duration.ofMillis(gap).toMinutes() + " 分钟,应不少于 60 分钟");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不同种子应给出不同排程;若与种子无关,说明随机化没生效。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleDependsOnRandomSource() {
|
||||||
|
var a = defaultPlanner(1).initialSchedule(ids(12));
|
||||||
|
var b = defaultPlanner(99).initialSchedule(ids(12));
|
||||||
|
assertNotEquals(a, b, "不同随机种子不应得到完全相同的排程");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一种子必须可复现:这是重启后行为一致的前提。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleIsReproducibleForSameSeed() {
|
||||||
|
assertEquals(defaultPlanner(42).initialSchedule(ids(12)),
|
||||||
|
defaultPlanner(42).initialSchedule(ids(12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initialScheduleHandlesEmptyInput() {
|
||||||
|
assertTrue(defaultPlanner(1).initialSchedule(List.of()).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单账号时也应拿到一个窗口内的计划,不能抛异常。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleHandlesSingleAccount() {
|
||||||
|
List<Long> planned = defaultPlanner(5).initialSchedule(ids(1));
|
||||||
|
assertEquals(1, planned.size());
|
||||||
|
assertTrue(planned.getFirst() >= BASE.toEpochMilli());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 容量校验 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capacityIsSufficientWhenGapsFit() {
|
||||||
|
// 12 个账号、间隔 1 小时 → 需要 11 小时,24 小时窗口放得下
|
||||||
|
assertTrue(defaultPlanner(1).isCapacitySufficient(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capacityIsInsufficientWhenGapsDoNotFit() {
|
||||||
|
// 50 个账号、间隔 1 小时 → 需要 49 小时,放不下
|
||||||
|
assertFalse(defaultPlanner(1).isCapacitySufficient(50));
|
||||||
|
assertTrue(defaultPlanner(1).capacityMessage(50).contains("放不下"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 容量不足也不能让排程崩溃:仍要给出窗口内的计划。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleStillWorksWhenCapacityInsufficient() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
List<Long> planned = planner.initialSchedule(ids(200));
|
||||||
|
assertEquals(200, planned.size());
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
for (long at : planned)
|
||||||
|
assertTrue(at >= now && at <= now + Duration.ofHours(24).toMillis(),
|
||||||
|
"容量不足时仍须落在窗口内: " + at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 24 小时保证 ----------
|
||||||
|
|
||||||
|
/** 成功后下一次刷新必须严格在 24 小时以内(留出一个 tick 的余量)。 */
|
||||||
|
@Test
|
||||||
|
void nextAfterSuccessStaysWithinWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long success = BASE.toEpochMilli();
|
||||||
|
long next = planner.nextAfterSuccess(success);
|
||||||
|
|
||||||
|
assertTrue(next > success, "下一次应晚于本次成功");
|
||||||
|
assertTrue(next - success < Duration.ofHours(24).toMillis(),
|
||||||
|
"相邻两次成功应小于 24 小时,实际 " + Duration.ofMillis(next - success).toMinutes() + " 分钟");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相位自我维持:连续推进多次都不应突破窗口。 */
|
||||||
|
@Test
|
||||||
|
void successiveRefreshesNeverExceedWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long at = BASE.toEpochMilli();
|
||||||
|
for (int i = 0; i < 40; i++) {
|
||||||
|
long next = planner.nextAfterSuccess(at);
|
||||||
|
assertTrue(next - at < Duration.ofHours(24).toMillis(), "第 " + i + " 次推进超出窗口");
|
||||||
|
at = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 失败重试与兜底 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败后应延后重试,而不是立刻重打上游。
|
||||||
|
* 间隔是固定的(数据库没有「连续失败次数」字段,做不出真正的指数退避)。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nextAfterFailureDelaysRetry() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
|
||||||
|
long retry = planner.nextAfterFailure(now, now);
|
||||||
|
|
||||||
|
assertTrue(retry > now, "失败后不应立即重试");
|
||||||
|
assertTrue(retry - now <= Duration.ofHours(1).toMillis(),
|
||||||
|
"重试不应拖太久,实际 " + Duration.ofMillis(retry - now).toMinutes() + " 分钟");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重试间隔必须小于窗口,否则「窗口内重试」无从谈起。 */
|
||||||
|
@Test
|
||||||
|
void constructorRejectsRetryDelayLargerThanWindow() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new SubscriptionRefreshPlanner(Clock.fixed(BASE, ZONE), new Random(1),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), Duration.ofHours(48)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new SubscriptionRefreshPlanner(Clock.fixed(BASE, ZONE), new Random(1),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退避不得把账号推过兜底线:仍要在窗口内重试。 */
|
||||||
|
@Test
|
||||||
|
void nextAfterFailureNeverExceedsWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long lastSuccess = BASE.toEpochMilli();
|
||||||
|
long now = lastSuccess + Duration.ofHours(23).toMillis(); // 已经很接近兜底线
|
||||||
|
|
||||||
|
long next = planner.nextAfterFailure(now, lastSuccess);
|
||||||
|
|
||||||
|
assertTrue(next <= lastSuccess + Duration.ofHours(24).toMillis(),
|
||||||
|
"重试时刻不得越过「上次成功 + 窗口」");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已越过兜底线时应立刻到期,这是「必须刷新」的判定。 */
|
||||||
|
@Test
|
||||||
|
void isOverdueWhenWindowElapsed() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long lastSuccess = BASE.toEpochMilli();
|
||||||
|
|
||||||
|
assertFalse(planner.isOverdue(lastSuccess, lastSuccess + Duration.ofHours(23).toMillis()));
|
||||||
|
assertTrue(planner.isOverdue(lastSuccess, lastSuccess + Duration.ofHours(24).toMillis()));
|
||||||
|
assertTrue(planner.isOverdue(lastSuccess, lastSuccess + Duration.ofHours(30).toMillis()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从未成功过的账号不参与过期判定,避免刚建好的账号被立刻强刷。 */
|
||||||
|
@Test
|
||||||
|
void isOverdueIsFalseWithoutKnownSuccess() {
|
||||||
|
assertFalse(defaultPlanner(1).isOverdue(null, BASE.toEpochMilli()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- 到期判定与计划损坏兜底 ----------
|
||||||
|
|
||||||
|
/** 计划在未来:未到期,不应刷新(否则分散效果会被自己破坏)。 */
|
||||||
|
@Test
|
||||||
|
void isDueIsFalseBeforeScheduledTime() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertFalse(planner.isDue(now + Duration.ofHours(3).toMillis(), now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划已到或已过:到期。 */
|
||||||
|
@Test
|
||||||
|
void isDueIsTrueAtOrAfterScheduledTime() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertTrue(planner.isDue(now, now), "正好到点应可刷新");
|
||||||
|
assertTrue(planner.isDue(now - Duration.ofMinutes(1).toMillis(), now), "已过点应可刷新");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划被写到超过一个窗口之后,按「计划损坏」处理并立即到期。
|
||||||
|
* 这是 24 小时保证的兜底:即使字段被手工写坏,账号也不会长期不刷。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void isDueTreatsFarFutureScheduleAsCorrupt() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertTrue(planner.isDue(now + Duration.ofDays(10).toMillis(), now),
|
||||||
|
"超出窗口的计划不可能是本调度器产生的,应立即到期");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尚未排程视为立即到期,避免新账号永远不刷。 */
|
||||||
|
@Test
|
||||||
|
void isDueWithoutScheduleIsImmediate() {
|
||||||
|
assertTrue(defaultPlanner(1).isDue(null, BASE.toEpochMilli()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败重试排在将来时必须被尊重:否则越线的账号会在每个 tick 都重打上游。
|
||||||
|
* 重试间隔是 30 分钟到 4 小时,远小于窗口,不构成「计划损坏」。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void retryScheduleIsRespectedNotTreatedAsCorrupt() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - Duration.ofHours(30).toMillis(); // 已越线
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
long retry = planner.nextAfterFailure(now, lastSuccess);
|
||||||
|
|
||||||
|
assertTrue(retry > now, "越线后的重试仍应排在将来");
|
||||||
|
assertFalse(planner.isDue(retry, now), "重试尚未到点,不应重复刷新");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 冷启动排程 ----------
|
||||||
|
|
||||||
|
/** 从未成功过的账号应尽快刷,而不是被分散到 20 小时后。 */
|
||||||
|
@Test
|
||||||
|
void coldStartScheduleIsImmediateAndStaggered() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
List<Long> planned = planner.coldStartSchedule(ids(5), Duration.ofMinutes(5));
|
||||||
|
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertEquals(now, planned.getFirst(), "第一个应立即可刷");
|
||||||
|
for (int i = 1; i < planned.size(); i++) {
|
||||||
|
assertEquals(Duration.ofMinutes(5).toMillis(), planned.get(i) - planned.get(i - 1),
|
||||||
|
"相邻账号应按 tick 间隔错开");
|
||||||
|
}
|
||||||
|
assertTrue(planned.getLast() <= now + Duration.ofMinutes(30).toMillis(),
|
||||||
|
"全部应在半小时内排完,避免新账号长时间没有可用订阅");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coldStartScheduleHandlesEmptyInput() {
|
||||||
|
assertTrue(defaultPlanner(1).coldStartSchedule(List.of(), Duration.ofMinutes(5)).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 构造参数校验 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorRejectsNonPositiveWindow() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ZERO, Duration.ofHours(1), Duration.ofMinutes(5)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ofHours(-1), Duration.ofHours(1), Duration.ofMinutes(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorRejectsNegativeGapOrTick() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ofHours(24), Duration.ofHours(-1), Duration.ofMinutes(5)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(-5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置访问器应如实回传构造时的值,供调度器日志与告警阈值使用。 */
|
||||||
|
@Test
|
||||||
|
void exposesConfiguredWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
assertEquals(Duration.ofHours(24), planner.window());
|
||||||
|
assertEquals(Duration.ofHours(1), planner.minGap());
|
||||||
|
assertEquals(Duration.ofMinutes(5), planner.tickInterval());
|
||||||
|
assertEquals(BASE, planner.clock().instant());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分散刷新的调度行为。
|
||||||
|
*
|
||||||
|
* <p>这里锁定用户明确要求的几条:每个账号 24 小时内必刷一次、时间互相错开、
|
||||||
|
* 以及「中途重启导致当天没刷」必须能自愈。全部使用固定时钟,不依赖真实时间流逝。
|
||||||
|
*/
|
||||||
|
class SubscriptionRefreshSchedulerTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final Instant BASE = Instant.parse("2026-09-15T02:00:00Z");
|
||||||
|
|
||||||
|
private static final long HOUR = 3_600_000L;
|
||||||
|
private static final long MINUTE = 60_000L;
|
||||||
|
|
||||||
|
private SubMapper subMapper;
|
||||||
|
private SubscriptionRefreshService refreshService;
|
||||||
|
private PushService pushService;
|
||||||
|
private RemoteService remoteService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
subMapper = mock(SubMapper.class);
|
||||||
|
refreshService = mock(SubscriptionRefreshService.class);
|
||||||
|
pushService = mock(PushService.class);
|
||||||
|
remoteService = mock(RemoteService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用固定时钟构造,使「现在」可控。 */
|
||||||
|
private SubscriptionRefreshScheduler schedulerAt(Instant instant) {
|
||||||
|
SubscriptionRefreshPlanner planner = new SubscriptionRefreshPlanner(
|
||||||
|
Clock.fixed(instant, ZONE), new Random(42),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5));
|
||||||
|
SubscriptionRefreshScheduler scheduler = new SubscriptionRefreshScheduler(
|
||||||
|
subMapper, refreshService, planner, pushService, remoteService);
|
||||||
|
scheduler.maxPerTick = 2;
|
||||||
|
scheduler.staleAlertMultiplier = 2.0;
|
||||||
|
return scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionRefreshScheduler scheduler() {
|
||||||
|
return schedulerAt(BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(int id, Long nextRefreshAt, Long lastSuccessEpoch) {
|
||||||
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(id);
|
||||||
|
a.setName("acc-" + id);
|
||||||
|
a.setUpstreamKey("key-" + id);
|
||||||
|
a.setEnabled(true);
|
||||||
|
a.setBoundUserCount(0);
|
||||||
|
a.setNextRefreshAt(nextRefreshAt);
|
||||||
|
a.setLastSuccessEpoch(lastSuccessEpoch);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void existing(SubscriptionAccount... accounts) {
|
||||||
|
ArrayList<SubscriptionAccount> list = new ArrayList<>(List.of(accounts));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新成功时返回 true。 */
|
||||||
|
private void refreshSucceedsForAll() {
|
||||||
|
when(refreshService.refresh(anyInt())).thenReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 到期判定 ----------
|
||||||
|
|
||||||
|
/** 计划时刻未到:本轮不应刷新,避免把「分散」又变回「每次都刷」。 */
|
||||||
|
@Test
|
||||||
|
void accountNotDueIsSkipped() {
|
||||||
|
long future = BASE.toEpochMilli() + 5 * HOUR;
|
||||||
|
existing(account(1, future, BASE.toEpochMilli()));
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划时刻已到:必须刷新,并重排下一次。 */
|
||||||
|
@Test
|
||||||
|
void accountDueIsRefreshedAndRescheduled() {
|
||||||
|
long past = BASE.toEpochMilli() - MINUTE;
|
||||||
|
existing(account(1, past, BASE.toEpochMilli() - 2 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
int refreshed = scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
assertEquals(1, refreshed);
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用的账号即使到期也不刷新。 */
|
||||||
|
@Test
|
||||||
|
void disabledAccountsAreNeverRefreshed() {
|
||||||
|
SubscriptionAccount disabled = account(1, BASE.toEpochMilli() - HOUR, null);
|
||||||
|
disabled.setEnabled(false);
|
||||||
|
existing(disabled);
|
||||||
|
|
||||||
|
assertEquals(0, scheduler().refreshDueAccounts());
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 24 小时硬保证 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底线:即使计划缺失(例如手工清了字段),只要距上次成功已过一个窗口,
|
||||||
|
* 也必须立即刷新。这是「24 小时内必刷一次」的最后一道闸。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void overdueAccountIsRefreshedEvenWithoutSchedule() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 25 * HOUR;
|
||||||
|
existing(account(1, null, lastSuccess));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertEquals(1, scheduler().refreshDueAccounts());
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 到期但未越线的账号若计划被写得很晚,兜底裁剪要能把它拉回来。 */
|
||||||
|
@Test
|
||||||
|
void overdueAccountBeatsFarFutureSchedule() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 25 * HOUR;
|
||||||
|
long farFuture = BASE.toEpochMilli() + 30 * 24 * HOUR;
|
||||||
|
existing(account(1, farFuture, lastSuccess));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertEquals(1, scheduler().refreshDueAccounts(), "越线账号必须无视过晚的计划立即刷新");
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功后重排的下一次必须在 24 小时以内,锁定「必刷一次」的硬上限。 */
|
||||||
|
@Test
|
||||||
|
void rescheduledTimeStaysWithinWindow() {
|
||||||
|
long past = BASE.toEpochMilli() - MINUTE;
|
||||||
|
existing(account(1, past, BASE.toEpochMilli() - 2 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
long delta = next.getValue() - BASE.toEpochMilli();
|
||||||
|
assertTrue(delta > 0, "下一次应在未来");
|
||||||
|
assertTrue(delta < 24 * HOUR, "下一次必须在 24 小时内,实际 " + delta / HOUR + " 小时");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重启兜底 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启后的追赶:停机 6 小时期间错过的账号,恢复后应被补刷。
|
||||||
|
* 这是用户明确要求的「中途重启导致当天没更新」兜底。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void accountsMissedDuringDowntimeAreCaughtUpAfterRestart() {
|
||||||
|
long downtimeStart = BASE.toEpochMilli() - 6 * HOUR;
|
||||||
|
// 三个账号的计划都落在停机窗口里,即「本来该刷但进程没在跑」
|
||||||
|
existing(
|
||||||
|
account(1, downtimeStart, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(2, downtimeStart + HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(3, downtimeStart + 2 * HOUR, BASE.toEpochMilli() - 20 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
// 一个 tick 最多 2 个:先补 2 个,避免同时开火
|
||||||
|
assertEquals(2, scheduler().refreshDueAccounts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连续多个 tick 应能把停机期间积压的账号全部排空(不会永久卡住)。
|
||||||
|
*
|
||||||
|
* <p>mock 的 mapper 不会像数据库那样保存状态,因此这里显式模拟持久化:
|
||||||
|
* {@code updateNextRefreshAt} 会把新时刻写回对象,成功时同时更新 lastSuccessEpoch。
|
||||||
|
* 否则被刷过的账号在内存里仍显示为「到期」,测出的排空行为没有意义。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void catchUpDrainsBacklogAcrossTicks() {
|
||||||
|
long downtimeStart = BASE.toEpochMilli() - 6 * HOUR;
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>(List.of(
|
||||||
|
account(1, downtimeStart, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(2, downtimeStart + HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(3, downtimeStart + 2 * HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(4, downtimeStart + 3 * HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(5, downtimeStart + 4 * HOUR, BASE.toEpochMilli() - 20 * HOUR)));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
persistSchedules(accounts);
|
||||||
|
|
||||||
|
var scheduler = scheduler();
|
||||||
|
assertEquals(2, scheduler.refreshDueAccounts(), "第一轮最多 2 个");
|
||||||
|
assertEquals(2, scheduler.refreshDueAccounts(), "第二轮再 2 个");
|
||||||
|
assertEquals(1, scheduler.refreshDueAccounts(), "第三轮补齐剩余 1 个");
|
||||||
|
verify(refreshService, times(5)).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟数据库的持久化:把 updateNextRefreshAt 写回对象。
|
||||||
|
* 成功刷新还必须推进 lastSuccessEpoch,否则兜底线会一直把账号判为到期。
|
||||||
|
*/
|
||||||
|
private void persistSchedules(List<SubscriptionAccount> accounts) {
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
int id = invocation.getArgument(0);
|
||||||
|
long next = invocation.getArgument(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
for (SubscriptionAccount a : accounts) {
|
||||||
|
if (a.getId() == id) {
|
||||||
|
a.setNextRefreshAt(next);
|
||||||
|
a.setLastSuccessEpoch(now);
|
||||||
|
a.setLastError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).when(subMapper).updateNextRefreshAt(anyInt(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 单轮上限 ----------
|
||||||
|
|
||||||
|
/** 单轮刷新数量必须有上限,否则重启后一批到期账号会同时打上游。 */
|
||||||
|
@Test
|
||||||
|
void maxPerTickLimitsConcurrentRefreshes() {
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 12; i++)
|
||||||
|
accounts.add(account(i, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertEquals(2, scheduler().refreshDueAccounts());
|
||||||
|
verify(refreshService, times(2)).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 失败处理 ----------
|
||||||
|
|
||||||
|
/** 单个账号失败不影响其他账号继续刷新。 */
|
||||||
|
@Test
|
||||||
|
void oneFailureDoesNotBlockOthers() {
|
||||||
|
existing(
|
||||||
|
account(1, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR),
|
||||||
|
account(2, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(false);
|
||||||
|
when(refreshService.refresh(2)).thenReturn(true);
|
||||||
|
|
||||||
|
assertEquals(2, scheduler().refreshDueAccounts(), "失败的账号也要计入本轮尝试");
|
||||||
|
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
verify(refreshService).refresh(2);
|
||||||
|
// 失败账号也要重排,否则会每个 tick 反复重打上游
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失败后的重试时刻应晚于当前,避免失败的账号被连续重打。 */
|
||||||
|
@Test
|
||||||
|
void failureReschedulesIntoFuture() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(false);
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
assertTrue(next.getValue() > BASE.toEpochMilli(), "重试时刻应在未来");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动全量刷新不受单轮上限限制:管理页按钮的语义就是立刻全部刷新。 */
|
||||||
|
@Test
|
||||||
|
void refreshAllNowIgnoresPerTickLimit() {
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 5; i++)
|
||||||
|
accounts.add(account(i, BASE.toEpochMilli() + 10 * HOUR, BASE.toEpochMilli()));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertTrue(scheduler().refreshAllNow());
|
||||||
|
verify(refreshService, times(5)).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新后必须重排计划。否则「刚手动刷完」会让当天的计划作废,
|
||||||
|
* 该账号反而可能超过 24 小时没有下次更新。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void refreshAllNowReschedulesEveryAccount() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() + 10 * HOUR, BASE.toEpochMilli()),
|
||||||
|
account(2, BASE.toEpochMilli() + 10 * HOUR, BASE.toEpochMilli()));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshAllNow();
|
||||||
|
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), anyLong());
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(2), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动刷新中只要有一个失败,整体结果即为失败(页面据此提示)。 */
|
||||||
|
@Test
|
||||||
|
void refreshAllNowReportsFailureIfAnyAccountFails() {
|
||||||
|
existing(account(1, BASE.toEpochMilli(), BASE.toEpochMilli()),
|
||||||
|
account(2, BASE.toEpochMilli(), BASE.toEpochMilli()));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(true);
|
||||||
|
when(refreshService.refresh(2)).thenReturn(false);
|
||||||
|
|
||||||
|
assertFalse(scheduler().refreshAllNow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用账号不参与手动全量刷新。 */
|
||||||
|
@Test
|
||||||
|
void refreshAllNowSkipsDisabled() {
|
||||||
|
SubscriptionAccount disabled = account(1, BASE.toEpochMilli(), BASE.toEpochMilli());
|
||||||
|
disabled.setEnabled(false);
|
||||||
|
existing(disabled);
|
||||||
|
|
||||||
|
assertTrue(scheduler().refreshAllNow());
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 计划补齐 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从未成功过的账号(没有计划)应被自动排上并尽快刷新,而不是永远不刷。
|
||||||
|
* 会被写入两次计划:一次是冷启动排程,一次是刷新成功后的重排。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void missingScheduleIsFilledInAndRefreshedPromptly() {
|
||||||
|
existing(account(1, null, null));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper, times(2)).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
assertTrue(next.getAllValues().getFirst() <= BASE.toEpochMilli() + 5 * MINUTE,
|
||||||
|
"从未成功过的账号应尽快刷新");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补齐的计划应落在窗口内,且不会让账号立刻被刷(避免与建号时的首刷重复)。 */
|
||||||
|
@Test
|
||||||
|
void filledScheduleLandsInsideWindow() {
|
||||||
|
existing(account(1, null, null));
|
||||||
|
|
||||||
|
// 只跑到补齐逻辑:不提供成功桩,refreshDueAccounts 仍会先补齐计划
|
||||||
|
when(refreshService.refresh(1)).thenReturn(true);
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper, atLeastOnce()).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
long earliest = next.getAllValues().stream().mapToLong(Long::longValue).min().orElseThrow();
|
||||||
|
assertTrue(earliest <= BASE.toEpochMilli() + 24 * HOUR, "补齐的计划必须落在窗口内");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归测试:刚被铺开计划的账号不应在同一轮里就被刷新。
|
||||||
|
*
|
||||||
|
* <p>曾经的缺陷是「把计划写进数据库却忘了更新内存里的对象」——这些账号在本轮
|
||||||
|
* 仍带着 null 计划,被判定为立即到期,于是迁移后首批账号会在第一个 tick
|
||||||
|
* 全部集中刷新,铺开形同虚设。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void newlyScheduledAccountsAreNotRefreshedInSameTick() {
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 5; i++)
|
||||||
|
accounts.add(account(i, null, BASE.toEpochMilli() - 2 * HOUR)); // 曾成功过 → 按窗口铺开
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
int refreshed = scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
assertEquals(0, refreshed, "刚铺开计划的账号本轮不应立即刷新");
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
assertEquals(5, accounts.stream().filter(a -> a.getNextRefreshAt() != null).count(),
|
||||||
|
"铺开的计划应写回内存对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新成功后必须通知备机同步。
|
||||||
|
* 原先「全量刷新」由调用方跟一次同步;改成按账号分散刷新后,若调度器不自己触发,
|
||||||
|
* 备机就要等到下一次周期同步才拿到新内容——这个缺口在重构时很容易漏掉。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void refreshTriggersStandbySyncWhenSomethingChanged() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() - MINUTE, BASE.toEpochMilli() - 2 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本轮没有任何账号到期时不应打扰备机。 */
|
||||||
|
@Test
|
||||||
|
void noRefreshMeansNoStandbySync() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() + 5 * HOUR, BASE.toEpochMilli()));
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(remoteService, never()).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已有计划的账号不应被重新排程,否则每次 tick 都会打乱分散效果。 */
|
||||||
|
@Test
|
||||||
|
void existingScheduleIsNotOverwritten() {
|
||||||
|
long future = BASE.toEpochMilli() + 8 * HOUR;
|
||||||
|
existing(account(1, future, BASE.toEpochMilli()));
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(subMapper, never()).updateNextRefreshAt(anyInt(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 陈旧告警 ----------
|
||||||
|
|
||||||
|
/** 超过两个窗口仍未成功应告警,让运维在用户投诉前发现问题。 */
|
||||||
|
@Test
|
||||||
|
void staleAccountTriggersAlert() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 50 * HOUR;
|
||||||
|
SubscriptionAccount stale = account(1, BASE.toEpochMilli() + HOUR, lastSuccess);
|
||||||
|
stale.setLastError("上游 HTTP 状态码 403");
|
||||||
|
existing(stale);
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(pushService).sendToMe(contains("未成功刷新"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警只发一次,避免每个 tick 都刷屏。 */
|
||||||
|
@Test
|
||||||
|
void staleAlertIsSentOnlyOnce() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 50 * HOUR;
|
||||||
|
existing(account(1, BASE.toEpochMilli() + HOUR, lastSuccess));
|
||||||
|
|
||||||
|
var scheduler = scheduler();
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(pushService, times(1)).sendToMe(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未陈旧的账号不应告警。 */
|
||||||
|
@Test
|
||||||
|
void healthyAccountDoesNotAlert() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() + HOUR, BASE.toEpochMilli() - HOUR));
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(pushService, never()).sendToMe(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号恢复正常后,若再次陈旧应能重新告警。 */
|
||||||
|
@Test
|
||||||
|
void alertResetsAfterSuccessfulRefresh() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 50 * HOUR;
|
||||||
|
SubscriptionAccount stale = account(1, BASE.toEpochMilli() - MINUTE, lastSuccess);
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts())
|
||||||
|
.thenReturn(new ArrayList<>(List.of(stale)));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(true, false);
|
||||||
|
|
||||||
|
var scheduler = scheduler();
|
||||||
|
scheduler.refreshDueAccounts(); // 告警 + 成功 → 重置
|
||||||
|
scheduler.refreshDueAccounts(); // 失败,但尚未再次陈旧
|
||||||
|
|
||||||
|
verify(pushService, times(1)).sendToMe(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 调度注解契约 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tick 必须用 fixedDelay(一轮跑完再计下一轮)而不是 fixedRate,
|
||||||
|
* 并且必须有 initialDelay——这正是旧实现「每次重启立刻全量重刷」的根因。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void tickUsesFixedDelayWithInitialDelay() throws Exception {
|
||||||
|
var annotation = SubscriptionRefreshScheduler.class.getMethod("tick")
|
||||||
|
.getAnnotation(org.springframework.scheduling.annotation.Scheduled.class);
|
||||||
|
|
||||||
|
assertNotNull(annotation, "tick 应带 @Scheduled");
|
||||||
|
assertEquals("${subscription.refresh.tick-interval-ms:300000}", annotation.fixedDelayString());
|
||||||
|
assertEquals("${subscription.refresh.initial-delay-ms:120000}", annotation.initialDelayString());
|
||||||
|
assertEquals(-1, annotation.fixedRate(), "不应再使用 fixedRate(未设置时为 -1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧的「每 24 小时一次全量」入口必须已经消失。
|
||||||
|
* 它没有 initialDelay,会在每次重启时立刻重刷全部账号。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void localServiceNoLongerSchedulesFullRefresh() {
|
||||||
|
assertThrows(NoSuchMethodException.class,
|
||||||
|
() -> LocalService.class.getMethod("updateSubScheduler"),
|
||||||
|
"LocalService 不应再暴露 24 小时全量刷新入口");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class SubscriptionRefreshServiceTest {
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(String key) {
|
||||||
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(1);
|
||||||
|
a.setName("sample");
|
||||||
|
a.setUpstreamKey(key);
|
||||||
|
a.setEnabled(true);
|
||||||
|
a.setBoundUserCount(0);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void stalledDownloadDoesNotHoldStateLockAndStaleResultIsDiscarded(@TempDir Path directory) throws Exception {
|
||||||
|
SubMapper mapper = mock(SubMapper.class);
|
||||||
|
SubscriptionAccount original = account("old");
|
||||||
|
SubscriptionAccount changed = account("new");
|
||||||
|
when(mapper.selectSubscriptionAccount(1)).thenReturn(original);
|
||||||
|
var coordinator = new SubscriptionStateCoordinator();
|
||||||
|
CountDownLatch started = new CountDownLatch(1);
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
var service = new SubscriptionRefreshService(mapper, coordinator) {
|
||||||
|
@Override List<String> download(String url, com.lion.lionwebsite.Util.SubscriptionClientProfile profile) throws java.io.IOException {
|
||||||
|
started.countDown();
|
||||||
|
try {
|
||||||
|
if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("test timed out");
|
||||||
|
} catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new java.io.IOException(e); }
|
||||||
|
return url.contains("v2") ? List.of("bm9kZQ==") : List.of("proxies:");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
service.v2UrlTemplate = "https://example.invalid/v2/{key}";
|
||||||
|
service.clashUrlTemplate = "https://example.invalid/clash/{key}";
|
||||||
|
service.cacheRoot = directory.toString();
|
||||||
|
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||||
|
try {
|
||||||
|
Future<Boolean> refresh = worker.submit(() -> service.refresh(1));
|
||||||
|
assertTrue(started.await(2, TimeUnit.SECONDS));
|
||||||
|
var lock = coordinator.writeLock();
|
||||||
|
assertTrue(lock.tryLock(1, TimeUnit.SECONDS), "management must remain available during downloads");
|
||||||
|
try {
|
||||||
|
when(mapper.selectSubscriptionAccount(1)).thenReturn(changed);
|
||||||
|
service.invalidateCache(1);
|
||||||
|
} finally { lock.unlock(); }
|
||||||
|
release.countDown();
|
||||||
|
assertFalse(refresh.get(2, TimeUnit.SECONDS));
|
||||||
|
assertFalse(service.hasCompleteCache(1));
|
||||||
|
verify(mapper, never()).markSubscriptionRefreshSuccess(any(), anyLong());
|
||||||
|
} finally { release.countDown(); worker.shutdownNow(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Configuration.CustomBean;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新调度组件的 Spring 装配。
|
||||||
|
*
|
||||||
|
* <p>纯 Mockito 测试不会发现「bean 无法装配」这类问题——线上表现是整个应用起不来,
|
||||||
|
* 因此这里用真实的 Spring 容器把 {@link SubscriptionRefreshScheduler} 装一遍。
|
||||||
|
* 只注册必要的 bean,不加载 Web 与数据源,避免测试依赖数据库与端口。
|
||||||
|
*/
|
||||||
|
class SubscriptionRefreshWiringTest {
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class Stub {
|
||||||
|
@Bean
|
||||||
|
SubMapper subMapper() {
|
||||||
|
return mock(SubMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionStateCoordinator subscriptionStateCoordinator() {
|
||||||
|
return new SubscriptionStateCoordinator();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionRefreshService subscriptionRefreshService(SubMapper subMapper,
|
||||||
|
SubscriptionStateCoordinator coordinator) {
|
||||||
|
return new SubscriptionRefreshService(subMapper, coordinator);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PushService pushService() {
|
||||||
|
return mock(PushService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
RemoteService remoteService() {
|
||||||
|
return mock(RemoteService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionRefreshPlanner subscriptionRefreshPlanner() {
|
||||||
|
return new SubscriptionRefreshPlanner(java.time.Clock.systemDefaultZone(), new java.util.Random(),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionRefreshScheduler subscriptionRefreshScheduler(SubMapper subMapper,
|
||||||
|
SubscriptionRefreshService refreshService,
|
||||||
|
SubscriptionRefreshPlanner planner,
|
||||||
|
PushService pushService,
|
||||||
|
RemoteService remoteService) {
|
||||||
|
return new SubscriptionRefreshScheduler(subMapper, refreshService, planner, pushService, remoteService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调度器必须能从容器里装配出来,且依赖都已就位。 */
|
||||||
|
@Test
|
||||||
|
void schedulerBeanIsWired() {
|
||||||
|
try (var ctx = new AnnotationConfigApplicationContext(Stub.class)) {
|
||||||
|
SubscriptionRefreshScheduler scheduler = ctx.getBean(SubscriptionRefreshScheduler.class);
|
||||||
|
assertNotNull(scheduler);
|
||||||
|
assertNotNull(scheduler.planner());
|
||||||
|
assertEquals(Duration.ofHours(24), scheduler.planner().window());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生产用的 planner bean 必须真正读到 application.yaml 里的值。
|
||||||
|
* 这里直接调用 {@link CustomBean} 的工厂方法验证默认值与注入方向一致,
|
||||||
|
* 避免「配置写了但没人读」以及「读错键」两类静默故障。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void plannerFactoryReadsConfiguredWindow() {
|
||||||
|
SubscriptionRefreshPlanner planner = new CustomBean()
|
||||||
|
.subscriptionRefreshPlanner(24, 60, 300_000, 60);
|
||||||
|
|
||||||
|
assertEquals(Duration.ofHours(24), planner.window());
|
||||||
|
assertEquals(Duration.ofMinutes(60), planner.minGap());
|
||||||
|
assertEquals(Duration.ofMinutes(5), planner.tickInterval());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 12 个账号的现状下,默认配置(24 小时窗口、60 分钟间隔)应当放得下。 */
|
||||||
|
@Test
|
||||||
|
void defaultConfigurationFitsCurrentAccountCount() {
|
||||||
|
SubscriptionRefreshPlanner planner = new CustomBean()
|
||||||
|
.subscriptionRefreshPlanner(24, 60, 300_000, 60);
|
||||||
|
|
||||||
|
assertTrue(planner.isCapacitySufficient(12),
|
||||||
|
"当前 12 个子账号在默认配置下应能铺开");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备机快照的推送时机。
|
||||||
|
*
|
||||||
|
* <p>约定:快照**只在订阅内容变化时**推送(各处 {@code requestSubscriptionSync}),
|
||||||
|
* 另在节点上线时补推一次。这里刻意不做周期性重发——备机的过期语义是「主站失联」,
|
||||||
|
* 由节点按「收到主站的任何消息」判定新鲜度(见 storageNode 侧 {@code markPrimaryContact}),
|
||||||
|
* 主站每 30 分钟的可用性检查就能持续刷新它。
|
||||||
|
*
|
||||||
|
* <p>历史教训:曾用「每 60 秒重发整份快照」来续期,12 账号时约 346 KiB/次、
|
||||||
|
* 约 486 MiB/天,而节点每次完整校验后都判 APPLY_OLD 丢弃;也曾改成「只在变化时推」
|
||||||
|
* 却不给节点任何存活信号,导致内容长期不变时备机在第 7 天永久判过期。
|
||||||
|
* 本类锁住「两者都不再发生」。
|
||||||
|
*/
|
||||||
|
class SubscriptionSnapshotPushTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemoteService 不得再有周期性重发快照的定时入口。
|
||||||
|
*
|
||||||
|
* <p>续期职责已下沉到节点侧(任何主站消息都刷新新鲜度),主站侧的周期重发是纯粹的浪费。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void hasNoPeriodicSnapshotResend() {
|
||||||
|
for (Method method : RemoteService.class.getDeclaredMethods()) {
|
||||||
|
Scheduled scheduled = method.getAnnotation(Scheduled.class);
|
||||||
|
if (scheduled == null)
|
||||||
|
continue;
|
||||||
|
fail("RemoteService 不应再有定时任务,但发现: " + method.getName()
|
||||||
|
+ "(订阅快照续期应由节点的存活信号判定承担)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅状态变更的入口必须仍然主动触发推送,否则变化无法在数秒内到达备机。
|
||||||
|
*
|
||||||
|
* <p>用真实调用验证:刷新调度器完成一轮刷新后,必须调用过一次 requestSubscriptionSync。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void subscriptionRefreshTriggersPush() {
|
||||||
|
com.lion.lionwebsite.Dao.normal.SubMapper subMapper =
|
||||||
|
org.mockito.Mockito.mock(com.lion.lionwebsite.Dao.normal.SubMapper.class);
|
||||||
|
RemoteService remoteService = org.mockito.Mockito.mock(RemoteService.class);
|
||||||
|
SubscriptionRefreshService refreshService =
|
||||||
|
org.mockito.Mockito.mock(SubscriptionRefreshService.class);
|
||||||
|
SubscriptionRefreshPlanner planner = new SubscriptionRefreshPlanner(
|
||||||
|
java.time.Clock.systemDefaultZone(), new java.util.Random(),
|
||||||
|
java.time.Duration.ofHours(24), java.time.Duration.ofHours(1), java.time.Duration.ofMinutes(5));
|
||||||
|
SubscriptionRefreshScheduler scheduler = new SubscriptionRefreshScheduler(
|
||||||
|
subMapper, refreshService, planner,
|
||||||
|
org.mockito.Mockito.mock(PushService.class), remoteService);
|
||||||
|
scheduler.maxPerTick = 2; // 手工构造的实例不受 @Value 注入,需显式设置单轮上限
|
||||||
|
|
||||||
|
com.lion.lionwebsite.Domain.SubscriptionAccount account =
|
||||||
|
new com.lion.lionwebsite.Domain.SubscriptionAccount();
|
||||||
|
account.setId(1);
|
||||||
|
account.setName("acc");
|
||||||
|
account.setUpstreamKey("key");
|
||||||
|
account.setEnabled(true);
|
||||||
|
account.setNextRefreshAt(System.currentTimeMillis() - 1); // 已到期
|
||||||
|
account.setLastSuccessEpoch(System.currentTimeMillis());
|
||||||
|
org.mockito.Mockito.when(subMapper.selectAllSubscriptionAccounts())
|
||||||
|
.thenReturn(new java.util.ArrayList<>(java.util.List.of(account)));
|
||||||
|
org.mockito.Mockito.when(refreshService.refresh(1)).thenReturn(true);
|
||||||
|
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
|
||||||
|
org.mockito.Mockito.verify(remoteService).requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubBind;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import com.lion.lionwebsite.Message.SubscriptionSnapshotMessage;
|
||||||
|
import com.lion.lionwebsite.Message.SubscriptionSnapshotPayload;
|
||||||
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.zip.GZIPInputStream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SubscriptionStandbySnapshotServiceTest {
|
||||||
|
@Test
|
||||||
|
void buildsDeterministicSnapshotWithoutPlainPublicKeys(@TempDir Path directory) throws Exception {
|
||||||
|
SubscriptionAccount account = new SubscriptionAccount();
|
||||||
|
account.setId(1);
|
||||||
|
account.setName("account");
|
||||||
|
account.setUpstreamKey("upstream-secret");
|
||||||
|
account.setFilterHighMultiplier(true);
|
||||||
|
account.setEnabled(true);
|
||||||
|
account.setBoundUserCount(2);
|
||||||
|
ArrayList<SubscriptionAccount> accounts = new ArrayList<>(java.util.List.of(account));
|
||||||
|
ArrayList<SubBind> bindings = new ArrayList<>(java.util.List.of(
|
||||||
|
new SubBind("public-key-a", "user-a", 1, "account", true, true),
|
||||||
|
new SubBind("public-key-b", "user-b", 1, "account", true, true)));
|
||||||
|
SubMapper mapper = (SubMapper) Proxy.newProxyInstance(SubMapper.class.getClassLoader(),
|
||||||
|
new Class<?>[]{SubMapper.class}, (proxy, method, args) -> switch (method.getName()) {
|
||||||
|
case "selectAllSubscriptionAccounts" -> accounts;
|
||||||
|
case "selectAllSubBind" -> bindings;
|
||||||
|
default -> throw new UnsupportedOperationException(method.getName());
|
||||||
|
});
|
||||||
|
SubscriptionStateCoordinator coordinator = new SubscriptionStateCoordinator();
|
||||||
|
Path accountDirectory = directory.resolve("1");
|
||||||
|
Files.createDirectories(accountDirectory);
|
||||||
|
Path v2 = accountDirectory.resolve("v2ray.txt");
|
||||||
|
Path clash = accountDirectory.resolve("clash.yaml");
|
||||||
|
Files.writeString(v2, "v2-content", StandardCharsets.UTF_8);
|
||||||
|
Files.writeString(clash, "clash-content", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
SubscriptionStandbySnapshotService service = new SubscriptionStandbySnapshotService(mapper, coordinator);
|
||||||
|
service.syncSecret = "test-sync-secret";
|
||||||
|
service.cacheRoot = directory.toString();
|
||||||
|
SubscriptionSnapshotMessage first = service.build();
|
||||||
|
SubscriptionSnapshotMessage second = service.build();
|
||||||
|
|
||||||
|
assertEquals(first.getRevision(), second.getRevision());
|
||||||
|
byte[] compressed = Base64.getDecoder().decode(first.getPayloadBase64());
|
||||||
|
byte[] json;
|
||||||
|
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
|
||||||
|
json = gzip.readAllBytes();
|
||||||
|
}
|
||||||
|
String rawPayload = new String(json, StandardCharsets.UTF_8);
|
||||||
|
assertFalse(rawPayload.contains("public-key-a"));
|
||||||
|
assertFalse(rawPayload.contains("public-key-b"));
|
||||||
|
assertFalse(rawPayload.contains("upstream-secret"));
|
||||||
|
SubscriptionSnapshotPayload payload = CustomUtil.objectMapper.readValue(json, SubscriptionSnapshotPayload.class);
|
||||||
|
assertEquals(1, payload.getAccounts().size());
|
||||||
|
assertEquals(2, payload.getBindings().size());
|
||||||
|
assertTrue(payload.getAccounts().getFirst().isFilterHighMultiplier());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.CollectMapper;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.User;
|
||||||
|
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户与授权码管理的业务规则。
|
||||||
|
* 这些方法决定谁能登录、被删用户的任务与收藏如何善后,因此重点落在
|
||||||
|
* 「副作用是否发生」与「失败时是否误报成功」两点上。
|
||||||
|
*/
|
||||||
|
class UserServiceTest {
|
||||||
|
|
||||||
|
private UserMapper userMapper;
|
||||||
|
private GalleryMapper galleryMapper;
|
||||||
|
private CollectMapper collectMapper;
|
||||||
|
private TaskHandlerInterceptor interceptor;
|
||||||
|
private UserService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userMapper = mock(UserMapper.class);
|
||||||
|
galleryMapper = mock(GalleryMapper.class);
|
||||||
|
collectMapper = mock(CollectMapper.class);
|
||||||
|
interceptor = mock(TaskHandlerInterceptor.class);
|
||||||
|
service = new UserService(userMapper, galleryMapper, collectMapper, interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User user(int id, String authCode, String username) {
|
||||||
|
return new User(id, authCode, username, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ok(String json) {
|
||||||
|
return json.contains("\"result\":\"success\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- addAuthCode ----------
|
||||||
|
|
||||||
|
/** 新增授权码必须刷新拦截器缓存,否则新用户要等到重启才能用。 */
|
||||||
|
@Test
|
||||||
|
void addAuthCodePersistsAndRefreshesInterceptorCache() {
|
||||||
|
assertTrue(ok(service.addAuthCode("code-1", "alice")));
|
||||||
|
|
||||||
|
var captor = org.mockito.ArgumentCaptor.forClass(User.class);
|
||||||
|
verify(userMapper).insertUser(captor.capture());
|
||||||
|
assertEquals("code-1", captor.getValue().getAuthCode());
|
||||||
|
assertEquals("alice", captor.getValue().getUsername());
|
||||||
|
assertTrue(captor.getValue().isEnable(), "新用户默认应启用");
|
||||||
|
verify(interceptor).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落库异常必须转成业务失败,且不得刷新缓存。 */
|
||||||
|
@Test
|
||||||
|
void addAuthCodeReportsFailureOnMapperError() {
|
||||||
|
doThrow(new RuntimeException("db down")).when(userMapper).insertUser(any());
|
||||||
|
|
||||||
|
String json = service.addAuthCode("code-1", "alice");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("插入失败"));
|
||||||
|
verify(interceptor, never()).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- alterAuthCode / alterUsername ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alterAuthCodeSucceedsOnlyWhenCodeExists() {
|
||||||
|
when(userMapper.isExist("old")).thenReturn(1);
|
||||||
|
assertTrue(ok(service.alterAuthCode("old", "new")));
|
||||||
|
verify(userMapper).updateAuthCode("old", "new");
|
||||||
|
verify(interceptor).updateAuthCodes();
|
||||||
|
|
||||||
|
when(userMapper.isExist("missing")).thenReturn(0);
|
||||||
|
String json = service.alterAuthCode("missing", "new");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("授权码不存在"));
|
||||||
|
verify(userMapper, never()).updateAuthCode(eq("missing"), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alterAuthCodeReportsFailureOnMapperError() {
|
||||||
|
when(userMapper.isExist("old")).thenReturn(1);
|
||||||
|
doThrow(new RuntimeException("db down")).when(userMapper).updateAuthCode(anyString(), anyString());
|
||||||
|
|
||||||
|
String json = service.alterAuthCode("old", "new");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("修改失败"));
|
||||||
|
verify(interceptor, never()).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改用户名不影响登录凭据,因此不应刷新授权码缓存。 */
|
||||||
|
@Test
|
||||||
|
void alterUsernameDoesNotTouchAuthCodeCache() {
|
||||||
|
when(userMapper.isExist("code")).thenReturn(1);
|
||||||
|
assertTrue(ok(service.alterUsername("code", "bob")));
|
||||||
|
verify(userMapper).updateUsername("code", "bob");
|
||||||
|
verify(interceptor, never()).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alterUsernameRejectsUnknownCodeAndReportsMapperError() {
|
||||||
|
when(userMapper.isExist("missing")).thenReturn(0);
|
||||||
|
String json = service.alterUsername("missing", "bob");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("授权码不存在"));
|
||||||
|
|
||||||
|
when(userMapper.isExist("code")).thenReturn(1);
|
||||||
|
doThrow(new RuntimeException("db down")).when(userMapper).updateUsername(anyString(), anyString());
|
||||||
|
String failed = service.alterUsername("code", "bob");
|
||||||
|
assertFalse(ok(failed));
|
||||||
|
assertTrue(failed.contains("修改失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- deleteAuthCode ----------
|
||||||
|
|
||||||
|
/** 删除用户要把其收藏全部取消,并把其名下任务转交给下载人 3,避免留下悬空记录。 */
|
||||||
|
@Test
|
||||||
|
void deleteAuthCodeClearsCollectionsAndReassignsGalleries() {
|
||||||
|
when(userMapper.isExist("code")).thenReturn(1);
|
||||||
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(user(7, "code", "alice"));
|
||||||
|
when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>(List.of(11, 22)));
|
||||||
|
|
||||||
|
assertTrue(ok(service.deleteAuthCode("code")));
|
||||||
|
|
||||||
|
verify(collectMapper).disCollect(11, 7);
|
||||||
|
verify(collectMapper).disCollect(22, 7);
|
||||||
|
verify(galleryMapper).updateGalleryDownloader(3, 7);
|
||||||
|
verify(userMapper).deleteUserByAuthCode("code");
|
||||||
|
verify(interceptor).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无收藏的用户也应能删除:此时不应调用任何取消收藏操作。 */
|
||||||
|
@Test
|
||||||
|
void deleteAuthCodeWorksForUserWithoutCollections() {
|
||||||
|
when(userMapper.isExist("code")).thenReturn(1);
|
||||||
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(user(8, "code", "bob"));
|
||||||
|
when(collectMapper.selectGidByCollector(8)).thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
assertTrue(ok(service.deleteAuthCode("code")));
|
||||||
|
verify(collectMapper, never()).disCollect(anyInt(), anyInt());
|
||||||
|
verify(userMapper).deleteUserByAuthCode("code");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不存在的授权码必须直接失败,不能落任何删除动作。 */
|
||||||
|
@Test
|
||||||
|
void deleteAuthCodeRejectsUnknownCode() {
|
||||||
|
when(userMapper.isExist("missing")).thenReturn(0);
|
||||||
|
|
||||||
|
String json = service.deleteAuthCode("missing");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("授权码不存在"));
|
||||||
|
verify(userMapper, never()).deleteUserByAuthCode(anyString());
|
||||||
|
verify(interceptor, never()).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中途异常必须报失败,不能给出「删除成功」的假象。 */
|
||||||
|
@Test
|
||||||
|
void deleteAuthCodeReportsFailureWhenCleanupThrows() {
|
||||||
|
when(userMapper.isExist("code")).thenReturn(1);
|
||||||
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(user(7, "code", "alice"));
|
||||||
|
when(collectMapper.selectGidByCollector(7)).thenThrow(new RuntimeException("db down"));
|
||||||
|
|
||||||
|
String json = service.deleteAuthCode("code");
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("删除失败"));
|
||||||
|
verify(userMapper, never()).deleteUserByAuthCode(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- alterStatus ----------
|
||||||
|
|
||||||
|
/** 停用用户时同样要交还其任务,否则停用后任务仍挂在不可用账号上。 */
|
||||||
|
@Test
|
||||||
|
void alterStatusUpdatesFlagAndReassignsGalleries() {
|
||||||
|
when(userMapper.isExist("code")).thenReturn(1);
|
||||||
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(user(5, "code", "alice"));
|
||||||
|
|
||||||
|
assertTrue(ok(service.alterStatus("code", false)));
|
||||||
|
|
||||||
|
verify(userMapper).updateIsEnableById(5, false);
|
||||||
|
verify(galleryMapper).updateGalleryDownloader(3, 5);
|
||||||
|
verify(interceptor).updateAuthCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alterStatusRejectsUnknownUser() {
|
||||||
|
when(userMapper.isExist("ghost")).thenReturn(0);
|
||||||
|
|
||||||
|
String json = service.alterStatus("ghost", false);
|
||||||
|
assertFalse(ok(json));
|
||||||
|
assertTrue(json.contains("该用户不存在"));
|
||||||
|
verify(userMapper, never()).updateIsEnableById(anyInt(), anyBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- getAllUser / getUserId ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllUserSerialisesEveryUser() {
|
||||||
|
when(userMapper.selectAllUser()).thenReturn(new User[]{
|
||||||
|
user(1, "a", "alice"), user(2, "b", "bob")});
|
||||||
|
|
||||||
|
String json = service.getAllUser();
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertTrue(json.contains("alice"));
|
||||||
|
assertTrue(json.contains("bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空表也要返回成功,且 data 是「数组序列化后的字符串」(历史契约,前端按字符串解析)。 */
|
||||||
|
@Test
|
||||||
|
void getAllUserHandlesEmptyTable() {
|
||||||
|
when(userMapper.selectAllUser()).thenReturn(new User[0]);
|
||||||
|
|
||||||
|
String json = service.getAllUser();
|
||||||
|
assertTrue(ok(json));
|
||||||
|
assertTrue(json.contains("\"data\":\"[]\""), "实际输出: " + json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUserIdResolvesCodeToId() {
|
||||||
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(user(42, "code", "alice"));
|
||||||
|
assertEquals(42, service.getUserId("code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 现状记录:授权码不存在时 getUserId 直接对 null 取 id,会抛 NPE。
|
||||||
|
* 生产上靠调用方(拦截器)先校验授权码规避;若此处改为返回 -1 或抛业务异常,说明已修复。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getUserIdThrowsForUnknownCode() {
|
||||||
|
when(userMapper.selectUserByAuthCode("ghost")).thenReturn(null);
|
||||||
|
assertThrows(NullPointerException.class, () -> service.getUserId("ghost"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user