Compare commits
76
Commits
2c08552986
..
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 | ||
|
|
29f1532dea | ||
|
|
b500691008 | ||
|
|
bcb7a7a6dd | ||
|
|
8b99608193 | ||
|
|
3bdaddaead | ||
|
|
d7ba01be2d | ||
|
|
c1ff2938bf | ||
|
|
2df627eab4 | ||
|
|
00187100f2 | ||
|
|
e8cc4b1097 | ||
|
|
0838434d64 | ||
|
|
325b9b321f | ||
|
|
25cd6d73b6 | ||
|
|
0cc583b910 | ||
|
|
cca2980efa | ||
|
|
27ceed8f4a | ||
|
|
78747c60eb | ||
|
|
c31b3ff5f0 | ||
|
|
7057b28ff0 | ||
|
|
fd016c7809 | ||
|
|
6f0049442f | ||
|
|
1516226192 | ||
|
|
54b1dd47a6 | ||
|
|
07b5b1c376 | ||
|
|
2c0ed4be58 | ||
|
|
793ae12348 | ||
|
|
97c1e91f44 | ||
|
|
bb6a247381 | ||
|
|
5cb2b7005b | ||
|
|
1db8cdf264 | ||
|
|
f451aaaf9c | ||
|
|
9df2b7994b | ||
|
|
e1afc3a6c5 |
+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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.1.5</version>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.lion</groupId>
|
||||
@@ -19,18 +19,18 @@
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mybatis.spring.boot</groupId>
|
||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||
<version>3.0.3</version>
|
||||
<version>4.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
<version>1.18.46</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -41,20 +41,20 @@
|
||||
<dependency>
|
||||
<groupId>org.mybatis.spring.boot</groupId>
|
||||
<artifactId>mybatis-spring-boot-starter-test</artifactId>
|
||||
<version>3.0.3</version>
|
||||
<version>4.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.15.3</version>
|
||||
<version>1.23.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.20</version>
|
||||
<version>5.8.47</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -63,22 +63,8 @@
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpmime</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.im4java</groupId>
|
||||
<artifactId>im4java</artifactId>
|
||||
<version>1.4.0</version>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -89,46 +75,97 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.21</version>
|
||||
<version>1.28.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.11.0</version>
|
||||
<version>2.22.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
<version>4.1.101.Final</version>
|
||||
<!--
|
||||
只保留实际用到的模块。原先的 netty-all 是聚合 pom,会拖进 50 个模块
|
||||
(含 5 个平台的 native-quic、aarch64/riscv64/osx 的 epoll/kqueue/io_uring、
|
||||
codec-http3/mqtt/redis/smtp/stomp/xml/protobuf 等),
|
||||
而本项目只用 Bootstrap/NioEventLoopGroup/NioSocketChannel/ByteBuf/
|
||||
ByteToMessageCodec/LengthFieldBasedFrameDecoder/LoggingHandler/Promise。
|
||||
transport 提供 Bootstrap/EventLoop/Channel,codec-base 提供
|
||||
ByteToMessageCodec 与 LengthFieldBasedFrameDecoder,handler 提供
|
||||
LoggingHandler(RemoteService 的协议调试日志用)。
|
||||
buffer/common/resolver 由它们传递引入。
|
||||
-->
|
||||
<artifactId>netty-transport</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-codec-base</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-handler</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.pengrad</groupId>
|
||||
<artifactId>java-telegram-bot-api</artifactId>
|
||||
<version>6.9.1</version>
|
||||
<version>10.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.lionsoul</groupId>
|
||||
<artifactId>ip2region</artifactId>
|
||||
<version>2.7.0</version>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.46</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.13</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
<version>1.1.8</version>
|
||||
<configuration>
|
||||
<imageName>lionwebsite</imageName>
|
||||
<buildArgs>
|
||||
<arg>--gc=G1</arg>
|
||||
<arg>-Ob</arg>
|
||||
<arg>-H:+ReportExceptionStackTraces</arg>
|
||||
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService</arg>
|
||||
<arg>--initialize-at-build-time=org.apache.commons.logging.LogFactory,org.apache.commons.logging.LogFactoryService,org.apache.commons.logging.impl.WeakHashtable,org.sqlite.util.ProcessRunner</arg>
|
||||
</buildArgs>
|
||||
<metadataRepository>
|
||||
<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,29 +2,66 @@ package com.lion.lionwebsite.Configuration;
|
||||
|
||||
import com.lion.lionwebsite.Domain.*;
|
||||
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.model.Chat;
|
||||
import com.pengrad.telegrambot.model.Message;
|
||||
import com.pengrad.telegrambot.model.MessageEntity;
|
||||
import com.pengrad.telegrambot.model.*;
|
||||
import com.pengrad.telegrambot.model.User;
|
||||
import com.pengrad.telegrambot.response.SendResponse;
|
||||
import com.sun.tools.javac.Main;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import com.pengrad.telegrambot.model.User;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Random;
|
||||
|
||||
@Configuration
|
||||
@RegisterReflectionForBinding(classes = {CRC32Cache.class, CustomConfiguration.class,
|
||||
GalleryForQuery.class, Gallery.class, GalleryTask.class, MaskDomain.class,
|
||||
PageNameCache.class, ShareFile.class, Tag.class, TagMark.class, User.class,
|
||||
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
|
||||
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
|
||||
PageNameCache.class, User.class,
|
||||
SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class,
|
||||
Chat.class, MessageEntity.class,
|
||||
AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class,
|
||||
GalleryPageQueryMessage.class, GalleryRequestMessage.class, IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, UpdateGalleryMessage.class})
|
||||
IdentityMessage.class, MaintainMessage.class, ResponseMessage.class, AvailableCheckMessage.class,
|
||||
SubscriptionSnapshotMessage.class, SubscriptionSnapshotPayload.class, SubscriptionAccountSnapshot.class,
|
||||
SubscriptionBindingSnapshot.class, LinkPreviewOptions.class})
|
||||
public class CustomBean {
|
||||
|
||||
@Value("${bot.token:5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA}")
|
||||
private String botToken;
|
||||
|
||||
@Value("${gallery.cookie:ipb_session_id=af2b2b1a795b39550711134d7bdcbf7f; ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1}")
|
||||
private String ehentaiCookie;
|
||||
|
||||
@PostConstruct
|
||||
void initGalleryCookie() {
|
||||
GalleryUtil.setEhentaiCookie(ehentaiCookie);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TelegramBot getTelegramBot(){
|
||||
return new TelegramBot("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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.lion.lionwebsite.Configuration;
|
||||
|
||||
import com.lion.lionwebsite.Domain.MaskDomain;
|
||||
import com.lion.lionwebsite.Interceptor.HumanInterceptor;
|
||||
import com.lion.lionwebsite.Interceptor.PersonalInterceptor;
|
||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
@@ -13,17 +11,19 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class InterceptorConfiguration implements WebMvcConfigurer {
|
||||
TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
public InterceptorConfiguration(TaskHandlerInterceptor taskHandlerInterceptor) {
|
||||
this.taskHandlerInterceptor = taskHandlerInterceptor;
|
||||
}
|
||||
final TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
@Override
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -36,14 +36,4 @@ public class InterceptorConfiguration implements WebMvcConfigurer {
|
||||
public HandlerInterceptor getHumanInterceptor(){
|
||||
return new HumanInterceptor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CustomUtil getParameterUtil(){
|
||||
CustomUtil customUtil = new CustomUtil();
|
||||
MaskDomain[] maskDomains = new MaskDomain[2];
|
||||
maskDomains[0] = new MaskDomain("exhentai.org", "element-plus.org");
|
||||
maskDomains[1] = new MaskDomain("e-hentai.org", "element.org");
|
||||
customUtil.setMaskDomains(maskDomains);
|
||||
return customUtil;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.beans.factory.config.ConstructorArgumentValues;
|
||||
import org.springframework.beans.factory.support.MergedBeanDefinitionPostProcessor;
|
||||
import org.springframework.beans.factory.support.RegisteredBean;
|
||||
import org.springframework.beans.factory.support.RootBeanDefinition;
|
||||
@@ -201,8 +202,7 @@ public class MyBatisNativeConfiguration {
|
||||
} else {
|
||||
result = (Class<?>) src;
|
||||
}
|
||||
} else if (src instanceof ParameterizedType) {
|
||||
ParameterizedType parameterizedType = (ParameterizedType) src;
|
||||
} else if (src instanceof ParameterizedType parameterizedType) {
|
||||
int index = (parameterizedType.getRawType() instanceof Class
|
||||
&& Map.class.isAssignableFrom((Class<?>) parameterizedType.getRawType())
|
||||
&& parameterizedType.getActualTypeArguments().length > 1) ? 1 : 0;
|
||||
@@ -246,8 +246,10 @@ public class MyBatisNativeConfiguration {
|
||||
Class<?> mapperInterface = getMapperInterface(beanDefinition);
|
||||
if (mapperInterface != null) {
|
||||
// Exposes a generic type information to context for prevent early initializing
|
||||
beanDefinition
|
||||
.setTargetType(ResolvableType.forClassWithGenerics(beanDefinition.getBeanClass(), mapperInterface));
|
||||
ConstructorArgumentValues constructorArgumentValues = new ConstructorArgumentValues();
|
||||
constructorArgumentValues.addGenericArgumentValue(mapperInterface);
|
||||
beanDefinition.setConstructorArgumentValues(constructorArgumentValues);
|
||||
beanDefinition.setTargetType(ResolvableType.forClassWithGenerics(beanDefinition.getBeanClass(), mapperInterface));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lion.lionwebsite.Configuration;
|
||||
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.mybatis.spring.SqlSessionFactoryBean;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
@Configuration
|
||||
public class SqlConfiguration {
|
||||
@Bean(name = "datasource-main")
|
||||
@ConfigurationProperties(prefix = "spring.datasource-main")
|
||||
public DataSource dataSourceMain() {
|
||||
return DataSourceBuilder.create().build();
|
||||
}
|
||||
|
||||
@Bean(name = "datasource-cache")
|
||||
@ConfigurationProperties(prefix = "spring.datasource-cache")
|
||||
public DataSource dataSourceCache() {
|
||||
return DataSourceBuilder.create().build();
|
||||
}
|
||||
|
||||
@Bean(name = "sqlSessionFactory-main")
|
||||
public SqlSessionFactory sqlSessionFactoryMain(@Qualifier("datasource-main") DataSource dataSource) throws Exception {
|
||||
SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
|
||||
sessionFactoryBean.setDataSource(dataSource);
|
||||
return sessionFactoryBean.getObject();
|
||||
}
|
||||
|
||||
@Bean(name = "sqlSessionFactory-cache")
|
||||
public SqlSessionFactory sqlSessionFactoryCache(@Qualifier("datasource-cache") DataSource dataSource) throws Exception {
|
||||
SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
|
||||
sessionFactoryBean.setDataSource(dataSource);
|
||||
return sessionFactoryBean.getObject();
|
||||
}
|
||||
|
||||
@Bean(name = "sqlSessionTemplate-main")
|
||||
public SqlSessionTemplate sqlSessionTemplateMain(@Qualifier("sqlSessionFactory-main") SqlSessionFactory sqlSessionFactory) {
|
||||
return new SqlSessionTemplate(sqlSessionFactory);
|
||||
}
|
||||
|
||||
@Bean(name = "sqlSessionTemplate-cache")
|
||||
public SqlSessionTemplate sqlSessionTemplateCache(@Qualifier("sqlSessionFactory-cache") SqlSessionFactory sqlSessionFactory) {
|
||||
return new SqlSessionTemplate(sqlSessionFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lion.lionwebsite.Configuration;
|
||||
|
||||
import com.lion.lionwebsite.Service.WebSocketService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
@RequiredArgsConstructor
|
||||
public class WebsocketConfiguration implements WebSocketConfigurer {
|
||||
|
||||
final WebSocketService webSocketService;
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(webSocketService, "/ws/").setAllowedOriginPatterns("*");
|
||||
}
|
||||
}
|
||||
@@ -2,52 +2,46 @@ package com.lion.lionwebsite.Controller;
|
||||
|
||||
import com.lion.lionwebsite.Service.CollectService;
|
||||
import com.lion.lionwebsite.Service.GalleryManageService;
|
||||
import com.lion.lionwebsite.Service.UserServiceImpl;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Service.RemoteService;
|
||||
import com.lion.lionwebsite.Service.UserService;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/GalleryManage")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GalleryManageController {
|
||||
GalleryManageService galleryManageService;
|
||||
final GalleryManageService galleryManageService;
|
||||
|
||||
CollectService collectService;
|
||||
final CollectService collectService;
|
||||
|
||||
CustomUtil customUtil;
|
||||
final UserService userService;
|
||||
|
||||
UserServiceImpl userService;
|
||||
|
||||
public GalleryManageController(GalleryManageService galleryManageService, CollectService collectService, CustomUtil customUtil, UserServiceImpl userService) {
|
||||
this.galleryManageService = galleryManageService;
|
||||
this.collectService = collectService;
|
||||
this.customUtil = customUtil;
|
||||
this.userService = userService;
|
||||
}
|
||||
final RemoteService remoteService;
|
||||
|
||||
@PostMapping("")
|
||||
public String create_task(String link, String targetResolution, String AuthCode,
|
||||
@RequestParam(value = "tags", required = false) List<Integer> tags,
|
||||
@RequestParam(value = "mode", defaultValue = "3", required = false)String mode){
|
||||
public String create_task(String link, String targetResolution, String AuthCode){
|
||||
if(link == null || targetResolution == null)
|
||||
return Response._failure("参数不全");
|
||||
link = customUtil.restoreUrl(link);
|
||||
if(link == null)
|
||||
return Response._failure("链接错误");
|
||||
return galleryManageService.createTask(link, targetResolution, AuthCode, tags, Byte.parseByte(mode));
|
||||
return galleryManageService.createTask(link, targetResolution, AuthCode);
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
public String updateGallery(String link){
|
||||
return galleryManageService.updateGallery(link);
|
||||
@PostMapping("/reconnect")
|
||||
public String reconnect(){
|
||||
return galleryManageService.reconnect();
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
public String test(){
|
||||
remoteService.checkAvailability();
|
||||
return "";
|
||||
}
|
||||
|
||||
@GetMapping("")
|
||||
@@ -56,40 +50,29 @@ public class GalleryManageController {
|
||||
|
||||
if(type == null)
|
||||
return Response._failure("参数不全");
|
||||
switch (type) {
|
||||
case "link" -> {
|
||||
param = customUtil.restoreUrl(param);
|
||||
if (param == null)
|
||||
return Response._failure("链接错误");
|
||||
return galleryManageService.selectTaskByLink(param);
|
||||
}
|
||||
case "gid" -> {
|
||||
return galleryManageService.selectTaskByGid(Integer.parseInt(param));
|
||||
}
|
||||
case "all" -> {
|
||||
return galleryManageService.selectAllGallery(userId);
|
||||
}
|
||||
case "name" -> {
|
||||
return galleryManageService.selectGalleryByName(param);
|
||||
}
|
||||
case "undone" -> {
|
||||
return galleryManageService.selectUnDoneGallery();
|
||||
}
|
||||
case "downloader" -> {
|
||||
return galleryManageService.selectGalleryByDownloader(AuthCode);
|
||||
}
|
||||
default -> {
|
||||
return Response._default();
|
||||
}
|
||||
}
|
||||
return switch (type) {
|
||||
case "link" -> galleryManageService.selectTaskByLink(param);
|
||||
case "gid" -> galleryManageService.selectTaskByGid(Integer.parseInt(param));
|
||||
case "all" -> galleryManageService.selectAllGallery(userId);
|
||||
case "name" -> galleryManageService.selectGalleryByName(param);
|
||||
case "downloader" -> galleryManageService.selectGalleryByDownloader(AuthCode);
|
||||
default -> Response._failure("参数错误");
|
||||
};
|
||||
}
|
||||
|
||||
@DeleteMapping("")
|
||||
public String deleteTask(Integer gid, String AuthCode, @RequestParam(value = "mode", defaultValue = "3", required = false)String mode){
|
||||
public String deleteTask(Integer gid, String AuthCode){
|
||||
if(gid == null)
|
||||
return Response._failure("参数不全");
|
||||
return galleryManageService.deleteGalleryByGid(gid, AuthCode);
|
||||
}
|
||||
|
||||
return galleryManageService.deleteGalleryByGid(gid, AuthCode, Byte.parseByte(mode));
|
||||
/** 按 gid 查实际下载人昵称,仅管理员可用。 */
|
||||
@GetMapping("/downloader")
|
||||
public String selectDownloader(Integer gid, String AuthCode){
|
||||
if(gid == null)
|
||||
return Response._failure("参数不全");
|
||||
return galleryManageService.selectDownloaderByGid(gid, AuthCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,27 +91,25 @@ public class GalleryManageController {
|
||||
return galleryManageService.getWeekUsedAmount();
|
||||
}
|
||||
|
||||
@GetMapping("/thumbnail/{name}")
|
||||
public void getThumbnail(HttpServletRequest request, HttpServletResponse response, @PathVariable("name") String name){
|
||||
galleryManageService.getThumbnail(request, response, name.replace(".webp", ""));
|
||||
@PostMapping("/cache")
|
||||
public String cacheImageKeys(String url){
|
||||
return galleryManageService.cacheImagesKey(url);
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/onlineImage/{page}")
|
||||
public void getOnlineImage(Integer gid, @PathVariable("page") Short page, HttpServletRequest request, HttpServletResponse response){
|
||||
galleryManageService.getOnlineImage(gid, page, request, response);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/share")
|
||||
public String shareGallery(Integer gid, Integer userId, Integer expireHour){
|
||||
if(userId.equals(3))
|
||||
return galleryManageService.shareGallery(gid, expireHour);
|
||||
return Response._failure("非法访问");
|
||||
public Callable<?> getCacheImage(String gid, @PathVariable("page") int page, HttpServletRequest request, HttpServletResponse response){
|
||||
return galleryManageService.getCachedImage(gid, page, request, response);
|
||||
}
|
||||
|
||||
@PostMapping("/reset")
|
||||
public String resetUndone(){
|
||||
return galleryManageService.resetUndone();
|
||||
}
|
||||
|
||||
@PostMapping("/retry")
|
||||
public String retryGallery(Integer gid){
|
||||
if(gid == null)
|
||||
return Response._failure("参数不全");
|
||||
return galleryManageService.retryGallery(gid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,123 @@
|
||||
package com.lion.lionwebsite.Controller;
|
||||
|
||||
import com.lion.lionwebsite.Service.LocalServiceImpl;
|
||||
import com.lion.lionwebsite.Service.PersonalServiceImpl;
|
||||
import com.lion.lionwebsite.Util.FileDownload;
|
||||
import com.lion.lionwebsite.Service.LocalService;
|
||||
import com.lion.lionwebsite.Service.PersonalService;
|
||||
import com.lion.lionwebsite.Util.PanelLoginTicket;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/personal")
|
||||
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) {
|
||||
this.personalService = personalService;
|
||||
this.localService = localService;
|
||||
final LocalService localService;
|
||||
|
||||
final PanelLoginTicket panelLoginTicket;
|
||||
|
||||
@GetMapping("/")
|
||||
public void index(HttpServletResponse resp) throws IOException {
|
||||
resp.sendRedirect("/index");
|
||||
}
|
||||
|
||||
@GetMapping("/sub/self")
|
||||
public void sub(HttpServletResponse response, HttpServletRequest request){
|
||||
FileDownload.export(request, response, "sub/sub.txt");
|
||||
/**
|
||||
* 一键登录:校验机器人签发的 HMAC 票据,通过后建立会话并跳到个人面板。
|
||||
*
|
||||
* <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");
|
||||
}
|
||||
|
||||
@GetMapping("/files")
|
||||
public String file(String path) throws IOException {
|
||||
return personalService.getFiles(path);
|
||||
@GetMapping("/login/logout")
|
||||
public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session != null)
|
||||
session.invalidate();
|
||||
response.sendRedirect("/denied");
|
||||
}
|
||||
|
||||
@PostMapping("/uploadFile")
|
||||
public String uploadFile(String path, String fileName, MultipartFile file){
|
||||
return personalService.uploadFile(path, fileName, file);
|
||||
/** 未登录或票据失效时的提示页;纯静态文案,不含任何可推断的信息。 */
|
||||
@GetMapping(value = "/denied", produces = MediaType.TEXT_HTML_VALUE)
|
||||
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>
|
||||
""";
|
||||
}
|
||||
|
||||
@GetMapping("/private/**")
|
||||
public void getFile(HttpServletRequest request, HttpServletResponse response, String path){
|
||||
personalService.download(request, response, path);
|
||||
}
|
||||
|
||||
@PostMapping("/share")
|
||||
public String shareFile(String path, Integer expireHour) {
|
||||
return personalService.shareFile(path, expireHour);
|
||||
}
|
||||
|
||||
@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);
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank())
|
||||
return forwarded.split(",")[0].trim();
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
@PostMapping("/updateSub")
|
||||
public String updateSub() throws IOException {
|
||||
Response response = Response.generateResponse();
|
||||
if(localService.updateSub(true))
|
||||
if(localService.updateSub())
|
||||
response.success();
|
||||
else
|
||||
response.failure();
|
||||
@@ -93,7 +130,12 @@ public class PersonalController {
|
||||
}
|
||||
|
||||
@GetMapping("/ip")
|
||||
public String ip() throws JsonProcessingException {
|
||||
public String ip() {
|
||||
return personalService.getIp();
|
||||
}
|
||||
|
||||
@PostMapping("/message2me")
|
||||
public String message2me(String message) {
|
||||
return personalService.message2me(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,36 +2,39 @@ package com.lion.lionwebsite.Controller;
|
||||
|
||||
|
||||
import com.lion.lionwebsite.Domain.User;
|
||||
import com.lion.lionwebsite.Service.PublicServiceImpl;
|
||||
import com.lion.lionwebsite.Service.PublicService;
|
||||
import com.lion.lionwebsite.Service.QueryService;
|
||||
import com.lion.lionwebsite.Service.RemoteService;
|
||||
import com.lion.lionwebsite.Service.SubService;
|
||||
import com.lion.lionwebsite.Service.UserService;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PublicController {
|
||||
|
||||
final List<String> black_share_codes = new LinkedList<>();
|
||||
final PublicService publicService;
|
||||
|
||||
PublicServiceImpl publicService;
|
||||
final RemoteService remoteService;
|
||||
|
||||
RemoteService remoteService;
|
||||
final SubService subService;
|
||||
|
||||
SubService subService;
|
||||
final QueryService queryService;
|
||||
|
||||
public PublicController(PublicServiceImpl publicService, RemoteService remoteService, SubService subService) {
|
||||
this.publicService = publicService;
|
||||
this.remoteService = remoteService;
|
||||
this.subService = subService;
|
||||
@GetMapping("/")
|
||||
public void index(HttpServletResponse resp) throws IOException {
|
||||
resp.sendRedirect("/index");
|
||||
}
|
||||
|
||||
@GetMapping("/ip")
|
||||
@@ -55,32 +58,20 @@ public class PublicController {
|
||||
subService.updateSub(response, request, client, key);
|
||||
}
|
||||
|
||||
@GetMapping("/GetFile/{path}")
|
||||
public void getFile(HttpServletRequest request, HttpServletResponse response, String ShareCode, @PathVariable("path") String path) throws IOException {
|
||||
synchronized (black_share_codes) {
|
||||
if (black_share_codes.contains(ShareCode))
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("ShareCode:{}", ShareCode);
|
||||
log.info("Path:{}", path);
|
||||
boolean result = publicService.GetFile(request, response, ShareCode);
|
||||
|
||||
if(!result)
|
||||
black_share_codes.add(ShareCode);
|
||||
|
||||
if(black_share_codes.size() > 100)
|
||||
black_share_codes.remove(0);
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
public String validate(String AuthCode){
|
||||
Response response = Response.generateResponse();
|
||||
User user = publicService.getUserId(AuthCode);
|
||||
String isAvailable = remoteService.isDead() ? "false": "true";
|
||||
response.success(String.format("{\"userId\": %d, " +
|
||||
"\"username\": \"%s\", " +
|
||||
"\"isAvailable\": %s}", user.getId(), user.getUsername(), isAvailable));
|
||||
// 用 ObjectMapper 组装内层 JSON:手工 String.format 拼用户名时,
|
||||
// 名字里带引号或反斜杠会直接产出非法 JSON,前端 JSON.parse 随即失败。
|
||||
// 这里仍以「JSON 文本」形式放进 data(历史契约,前端按字符串再解析一次)。
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -90,8 +81,7 @@ public class PublicController {
|
||||
}
|
||||
|
||||
@GetMapping("/GalleryManage/ehThumbnail")
|
||||
public void getEhThumbnail(String path, HttpServletResponse response){
|
||||
publicService.getEhThumbnail(path, response);
|
||||
public void getEhThumbnail(String path, HttpServletRequest request, HttpServletResponse response){
|
||||
queryService.getEhThumbnail(path, request, response);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.lion.lionwebsite.Controller;
|
||||
|
||||
import com.lion.lionwebsite.Service.QueryService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -10,22 +9,13 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/query")
|
||||
@RequiredArgsConstructor
|
||||
public class QueryController {
|
||||
|
||||
QueryService queryService;
|
||||
|
||||
public QueryController(QueryService queryService) {
|
||||
this.queryService = queryService;
|
||||
}
|
||||
final QueryService queryService;
|
||||
|
||||
@GetMapping("")
|
||||
public String query(String keyword, String prev, String next){
|
||||
return queryService.query(keyword, prev, next);
|
||||
}
|
||||
|
||||
@GetMapping("/image")
|
||||
public void image(HttpServletResponse response, String path){
|
||||
queryService.image(response, path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
package com.lion.lionwebsite.Controller;
|
||||
|
||||
import com.lion.lionwebsite.Service.SubService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/personal/subBind/")
|
||||
@RequiredArgsConstructor
|
||||
public class SubController {
|
||||
SubService subService;
|
||||
|
||||
public SubController(SubService subService) {
|
||||
this.subService = subService;
|
||||
}
|
||||
final SubService subService;
|
||||
|
||||
@PostMapping("")
|
||||
public String addSubBind(String user){
|
||||
return subService.insertSubBind(user);
|
||||
public String addSubBind(String user, Integer accountId){
|
||||
return subService.insertSubBind(user, accountId);
|
||||
}
|
||||
|
||||
@PutMapping("")
|
||||
@@ -36,4 +34,38 @@ public class SubController {
|
||||
public String deleteSubBind(String user){
|
||||
return subService.deleteSubBind(user);
|
||||
}
|
||||
|
||||
@GetMapping("accounts")
|
||||
public String getAccounts(){
|
||||
return subService.listSubscriptionAccounts();
|
||||
}
|
||||
|
||||
@PostMapping("accounts")
|
||||
public String addAccount(String name, String upstreamKey,
|
||||
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
|
||||
@RequestParam(defaultValue = "true") boolean enabled){
|
||||
return subService.insertSubscriptionAccount(name, upstreamKey, filterHighMultiplier, enabled);
|
||||
}
|
||||
|
||||
@PutMapping("accounts/{id}")
|
||||
public String updateAccount(@PathVariable Integer id, String name, String upstreamKey,
|
||||
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
|
||||
@RequestParam(defaultValue = "true") boolean enabled){
|
||||
return subService.updateSubscriptionAccount(id, name, upstreamKey, filterHighMultiplier, enabled);
|
||||
}
|
||||
|
||||
@PostMapping("accounts/{id}/refresh")
|
||||
public String refreshAccount(@PathVariable Integer id){
|
||||
return subService.refreshSubscriptionAccount(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("accounts/{id}")
|
||||
public String deleteAccount(@PathVariable Integer id){
|
||||
return subService.deleteSubscriptionAccount(id);
|
||||
}
|
||||
|
||||
@PutMapping("{user}/account")
|
||||
public String rebind(@PathVariable String user, Integer accountId){
|
||||
return subService.rebind(user, accountId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.lion.lionwebsite.Controller;
|
||||
|
||||
import com.lion.lionwebsite.Service.TagService;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/GalleryManage")
|
||||
public class TagController {
|
||||
|
||||
TagService tagService;
|
||||
|
||||
public TagController(TagService tagService) {
|
||||
this.tagService = tagService;
|
||||
}
|
||||
|
||||
@PostMapping("/tag")
|
||||
public String createTag(String tag){
|
||||
return tagService.createTag(tag);
|
||||
}
|
||||
|
||||
@PostMapping("/tagAndMark")
|
||||
public String createTagAndMark(Integer gid, String tag){
|
||||
return tagService.createTagAndMark(gid, tag);
|
||||
}
|
||||
|
||||
@DeleteMapping("/tag")
|
||||
public String deleteTag(Integer tid){
|
||||
return tid == null ? Response._failure("参数错误") : tagService.deleteTag(tid);
|
||||
}
|
||||
|
||||
@PostMapping("/mark")
|
||||
public String markTag(Integer gid, Integer tid){
|
||||
return tagService.markTag(gid, tid);
|
||||
}
|
||||
|
||||
@PostMapping("/disMark")
|
||||
public String disMarkTag(Integer gid, Integer tid){
|
||||
return tagService.disMarkTag(gid, tid);
|
||||
}
|
||||
|
||||
@GetMapping("/allTag")
|
||||
public String allTag(){
|
||||
return tagService.selectAllTag();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
package com.lion.lionwebsite.Controller;
|
||||
|
||||
import com.lion.lionwebsite.Service.UserServiceImpl;
|
||||
import jakarta.annotation.Resource;
|
||||
import com.lion.lionwebsite.Service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/personal/user")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
UserServiceImpl userService;
|
||||
|
||||
public UserController(UserServiceImpl userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
final UserService userService;
|
||||
|
||||
@GetMapping("")
|
||||
public String getAllUser(){
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
|
||||
import com.lion.lionwebsite.Domain.CRC32Cache;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface CRC32CacheMapper {
|
||||
@Insert("insert into CRC32Cache (path, lastModifyTime, crc32) values (#{path}, #{lastModifyTime}, #{crc32})")
|
||||
void insertCache(CRC32Cache crc32Cache);
|
||||
|
||||
@Select("select * from CRC32Cache where path=#{path}")
|
||||
CRC32Cache selectCache(String path);
|
||||
|
||||
@Select("select * from CRC32Cache where path like '%' || #{name} || '%'")
|
||||
CRC32Cache selectCacheByName(String name);
|
||||
|
||||
// @Update("update CRC32Cache set lastModifyTime=#{lastModifyTime}, crc32=#{crc32} where path=#{path}")
|
||||
// void updateCache(CRC32Cache crc32Cache);
|
||||
|
||||
@Delete("delete from CRC32Cache where path=#{path}")
|
||||
void deleteCacheByPath(String path);
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
|
||||
import com.lion.lionwebsite.Domain.PageNameCache;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface PageNameCacheMapper {
|
||||
|
||||
@Insert("insert into pageName (gid, page, pageName) values (#{gid}, #{page}, #{pageName})")
|
||||
void insertPageNameCache(PageNameCache pageNameCache);
|
||||
|
||||
|
||||
@Select("select pageName from pageName where gid=#{gid} and page=#{page}")
|
||||
String selectPageName(@Param("gid") int gid, @Param("page") short page);
|
||||
|
||||
|
||||
@Delete("delete from pageName where gid=#{gid}")
|
||||
void deletePageNameByGid(int gid);
|
||||
|
||||
@Delete("delete from pageName where gid=#{gid} and page=#{page}")
|
||||
void deletePageName(PageNameCache pageNameCache);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
|
||||
import com.lion.lionwebsite.Domain.SubBind;
|
||||
import com.lion.lionwebsite.Domain.SubUpdateRecord;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Mapper
|
||||
public interface SubMapper {
|
||||
@Insert("insert into sub_bind values (#{key}, #{user})")
|
||||
void insertSubBind(SubBind subBind);
|
||||
|
||||
@Select("select * from sub_bind")
|
||||
ArrayList<SubBind> selectAllSubBind();
|
||||
|
||||
@Select("select * from sub_bind where key=#{key}")
|
||||
SubBind selectSubBind(String key);
|
||||
|
||||
@Select("select count(key) from sub_bind where key=#{key}")
|
||||
boolean selectSubBindExist(String key);
|
||||
|
||||
@Select("select count(user) from sub_update_record where user=#{user}")
|
||||
Integer selectUpdateRecordCount(String user);
|
||||
|
||||
@Select("select min(id) from sub_update_record where user=#{user}")
|
||||
Integer selectMinUpdateRecordId(String user);
|
||||
|
||||
@Delete("delete from sub_bind where user=#{user}")
|
||||
void deleteSubBind(String user);
|
||||
|
||||
@Insert("insert into sub_update_record (user, ip, UA, time, location) values (#{user}, #{ip}, #{UA}, #{time}, #{location})")
|
||||
void insertSubUpdateRecord(SubUpdateRecord subUpdateRecord);
|
||||
|
||||
@Select("select * from sub_update_record")
|
||||
ArrayList<SubUpdateRecord> selectAllSubUpdateRecord();
|
||||
|
||||
@Delete("delete from sub_update_record where user=#{user}")
|
||||
void deleteSubUpdateRecord(String user);
|
||||
|
||||
@Delete("delete from sub_update_record where id=#{id}")
|
||||
void deleteSubUpdateRecordById(int id);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
|
||||
import com.lion.lionwebsite.Domain.Tag;
|
||||
import com.lion.lionwebsite.Domain.TagMark;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Mapper
|
||||
public interface TagMapper {
|
||||
@Insert("insert into tag (tag) values (#{tag})")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
void insertTag(Tag tag);
|
||||
|
||||
// @Select("select id, tag, usage from tag where id=#{id}")
|
||||
// Tag selectTagById(int id);
|
||||
//
|
||||
// @Select("select id from tag where tag=#{tag}")
|
||||
// int selectTidByTag(String tag);
|
||||
|
||||
@Select("select count(id) from tag where tag=#{tag}")
|
||||
int selectTagExistByTag(String tag);
|
||||
|
||||
@Select("select count(id) from tag where id=#{id}")
|
||||
int selectTagExistById(int id);
|
||||
|
||||
|
||||
@Select("select id, tag, usage from tag")
|
||||
ArrayList<Tag> selectAllTag();
|
||||
|
||||
@Delete("delete from tag where id=#{id}")
|
||||
void deleteTagById(int id);
|
||||
|
||||
@Select("select count(id) from galleryTag where tid=#{tid}")
|
||||
int selectTagUsage(int tid);
|
||||
|
||||
@Select("select count(id) from galleryTag where gid=#{gid} and tid=#{tid}")
|
||||
int selectIsMark(@Param("gid") int gid, @Param("tid") int tid);
|
||||
|
||||
@Select("select tid from galleryTag where gid=#{gid}")
|
||||
ArrayList<Integer> selectTagByGid(int gid);
|
||||
|
||||
@Insert("insert into galleryTag (gid, tid) values (#{gid}, #{tid})")
|
||||
int markTag(@Param("gid") int gid, @Param("tid") int tid);
|
||||
|
||||
@Delete("delete from galleryTag where gid=#{gid} and tid=#{tid}")
|
||||
int disMarkTag(@Param("gid") int gid, @Param("tid") int tid);
|
||||
|
||||
@Select("select gid, tid from galleryTag")
|
||||
ArrayList<TagMark> selectAllMark();
|
||||
|
||||
@Update("update tag set usage=usage+1 where id=#{tid}")
|
||||
void incrTagUsage(int tid);
|
||||
|
||||
@Update("update tag set usage=usage-1 where id=#{tid}")
|
||||
void decrTagUsage(int tid);
|
||||
|
||||
@Delete("delete from galleryTag where gid=#{gid}")
|
||||
void disMarkTagByGid(int gid);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.lion.lionwebsite.Dao.cache;
|
||||
|
||||
import com.lion.lionwebsite.Domain.GidToKey;
|
||||
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ImageCacheMapper {
|
||||
@Insert("insert into ImageKeyCache values (#{gid}, #{imgkey}, #{page})")
|
||||
void insertImageKeyCache(ImageKeyCache imageKeyCache);
|
||||
|
||||
@Insert("insert into gidToKey values (#{gid}, #{key}, #{pages})")
|
||||
void insertGidToKey(GidToKey gidToKey);
|
||||
|
||||
@Select("select * from ImageKeyCache where gid=#{gid} and page=#{page}")
|
||||
ImageKeyCache selectImageKeyCacheByGidAndPage(@Param("gid") String gid, @Param("page") int page);
|
||||
|
||||
@Select("select * from gidToKey where gid=#{gid}")
|
||||
GidToKey selectKeyByGid(String gid);
|
||||
|
||||
/** 已缓存的页 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);
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
package com.lion.lionwebsite.Dao.normal;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
package com.lion.lionwebsite.Dao.normal;
|
||||
|
||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@@ -16,6 +16,9 @@ public interface CustomConfigurationMapper {
|
||||
// @Delete("delete from customConfiguration where parameter=#{parameter}")
|
||||
// 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}")
|
||||
CustomConfiguration selectConfiguration(String parameter);
|
||||
}
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
package com.lion.lionwebsite.Dao.normal;
|
||||
|
||||
import com.lion.lionwebsite.Domain.Gallery;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
@@ -9,8 +9,8 @@ public interface GalleryMapper {
|
||||
|
||||
|
||||
@Insert("insert into gallery" +
|
||||
" (gid, name, link, language, pages, status, fileSize, createTime, proceeding, resolution, displayFileSize, downloader, mode)\n" +
|
||||
" values (#{gid}, #{name}, #{link}, #{language}, #{pages}, #{status}, #{fileSize}, #{createTime}, #{proceeding}, #{resolution}, #{displayFileSize}, #{downloader}, #{mode})")
|
||||
" (gid, name, link, language, pages, status, fileSize, createTime, proceeding, resolution, displayFileSize, downloader, thumb_link, is_download)\n" +
|
||||
" values (#{gid}, #{name}, #{link}, #{language}, #{pages}, #{status}, #{fileSize}, #{createTime}, #{proceeding}, #{resolution}, #{displayFileSize}, #{downloader}, #{thumb_link}, #{is_download})")
|
||||
void insertGallery(Gallery gallery);
|
||||
|
||||
@Select("select * from gallery where link=#{link}")
|
||||
@@ -25,7 +25,7 @@ public interface GalleryMapper {
|
||||
@Select("select * from gallery where downloader=#{downloader}")
|
||||
Gallery[] selectGalleryByDownloader(int downloader);
|
||||
|
||||
@Select("select * from gallery where status in ('已提交', '下载中')")
|
||||
@Select("select * from gallery where status in ('已提交', '下载中', '等待压缩', '压缩中')")
|
||||
Gallery[] selectUnDoneGalleries();
|
||||
|
||||
@Select("select * from gallery order by createTime")
|
||||
@@ -38,7 +38,7 @@ public interface GalleryMapper {
|
||||
@Update("""
|
||||
update gallery set name=#{name}, link=#{link}, language=#{language},
|
||||
pages=#{pages}, status=#{status}, fileSize=#{fileSize}, createTime=#{createTime},
|
||||
proceeding=#{proceeding}, resolution=#{resolution}, displayFileSize=#{displayFileSize}, mode=#{mode}
|
||||
proceeding=#{proceeding}, resolution=#{resolution}, displayFileSize=#{displayFileSize}, is_download=#{is_download}
|
||||
where gid=#{gid}""")
|
||||
void updateGallery(Gallery gallery);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.lion.lionwebsite.Dao.normal;
|
||||
|
||||
import com.lion.lionwebsite.Domain.SubBind;
|
||||
import com.lion.lionwebsite.Domain.SubUpdateRecord;
|
||||
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Mapper
|
||||
public interface SubMapper {
|
||||
@Insert("insert into subscription_account (name, upstream_key, filter_high_multiplier, enabled, created_at, updated_at) values (#{name}, #{upstreamKey}, #{filterHighMultiplier}, #{enabled}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
void insertSubscriptionAccount(SubscriptionAccount account);
|
||||
|
||||
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, 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);
|
||||
|
||||
@Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id order by sb.user")
|
||||
ArrayList<SubBind> selectAllSubBind();
|
||||
|
||||
@Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id where sb.key=#{key}")
|
||||
SubBind selectSubBind(String key);
|
||||
|
||||
@Select("select count(key) from sub_bind where key=#{key}")
|
||||
boolean selectSubBindExist(String key);
|
||||
|
||||
@Select("select count(*) from sub_bind where user=#{user}")
|
||||
int countSubBindByUser(String user);
|
||||
|
||||
@Update("update sub_bind set subscription_account_id=#{accountId} where user=#{user}")
|
||||
int updateSubBindAccount(@Param("user") String user, @Param("accountId") Integer accountId);
|
||||
|
||||
@Update("update sub_bind set key=#{key} where user=#{user}")
|
||||
int updateSubBindKey(@Param("user") String user, @Param("key") String key);
|
||||
|
||||
@Delete("delete from sub_bind where user=#{user}")
|
||||
void deleteSubBind(String user);
|
||||
|
||||
@Insert("insert into sub_update_record (user, ip, UA, time, location) values (#{user}, #{ip}, #{UA}, #{time}, #{location})")
|
||||
void insertSubUpdateRecord(SubUpdateRecord subUpdateRecord);
|
||||
|
||||
@Select("select * from sub_update_record order by time desc")
|
||||
ArrayList<SubUpdateRecord> selectAllSubUpdateRecord();
|
||||
|
||||
@Delete("delete from sub_update_record where user=#{user}")
|
||||
void deleteSubUpdateRecord(String user);
|
||||
|
||||
@Select("select count(user) from sub_update_record where user=#{user}")
|
||||
Integer selectUpdateRecordCount(String user);
|
||||
|
||||
@Select("select min(id) from sub_update_record where user=#{user}")
|
||||
Integer selectMinUpdateRecordId(String user);
|
||||
|
||||
/** 后台补齐归属地:只更新该用户最近一条记录,避免覆盖更早的历史记录。 */
|
||||
@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}")
|
||||
void deleteSubUpdateRecordById(int id);
|
||||
}
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
package com.lion.lionwebsite.Dao;
|
||||
package com.lion.lionwebsite.Dao.normal;
|
||||
|
||||
import com.lion.lionwebsite.Domain.User;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
@@ -11,10 +11,23 @@ public interface UserMapper {
|
||||
@Select("select * from User where AuthCode=#{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")
|
||||
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();
|
||||
|
||||
@Select("select * from User")
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.lion.lionwebsite.Domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class CRC32Cache {
|
||||
String path;
|
||||
long lastModifyTime;
|
||||
String crc32;
|
||||
}
|
||||
@@ -13,22 +13,22 @@ import java.util.Map;
|
||||
@Data
|
||||
public class Gallery {
|
||||
@JsonProperty("name")
|
||||
private String name; //本子名字
|
||||
private String name; //图片名字
|
||||
|
||||
@JsonProperty("gid")
|
||||
private int gid; //gid
|
||||
|
||||
@JsonProperty("link")
|
||||
private String link; //本子链接
|
||||
private String link; //图片链接
|
||||
|
||||
@JsonProperty("language")
|
||||
private String language; //本子语言
|
||||
private String language; //图片语言
|
||||
|
||||
@JsonProperty("pages")
|
||||
private int pages; //本子页数
|
||||
private int pages; //图片页数
|
||||
|
||||
@JsonProperty("status")
|
||||
private String status; //本子当前状态
|
||||
private String status; //图片当前状态
|
||||
|
||||
@JsonIgnore
|
||||
private long fileSize; //文件大小
|
||||
@@ -48,6 +48,11 @@ public class Gallery {
|
||||
@JsonProperty("downloader")
|
||||
private int downloader; //下载人
|
||||
|
||||
/** 下载人昵称。仅管理员查询时填充,普通用户拿不到,避免暴露他人信息。 */
|
||||
@JsonProperty("downloaderName")
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
private String downloaderName;
|
||||
|
||||
@JsonProperty("collector")
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
private String collector; //收藏人
|
||||
@@ -64,7 +69,11 @@ public class Gallery {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private Map<String, String> availableResolution; //可选分辨率
|
||||
|
||||
@JsonProperty("mode")
|
||||
@JsonProperty("isDownload")
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
private byte mode; //rd 可读可下载 r可读 d可下载
|
||||
private boolean is_download; //0 仅查看 1 仅下载源文件 2 仅存储预览图 3 全部
|
||||
|
||||
@JsonProperty("thumb_link")
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
private String thumb_link;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import lombok.Data;
|
||||
@Data
|
||||
public class GalleryForQuery {
|
||||
|
||||
@JsonProperty("gid")
|
||||
String gid;
|
||||
|
||||
@JsonProperty("name")
|
||||
String name;
|
||||
|
||||
|
||||
@@ -6,35 +6,20 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GalleryTask {
|
||||
|
||||
public static byte DOWNLOADING = 1;
|
||||
|
||||
public static byte DOWNLOAD_COMPLETE = 2;
|
||||
|
||||
public static byte DOWNLOAD_QUEUED = 3;
|
||||
|
||||
public static byte COMPRESSING = 3;
|
||||
public static byte COMPRESS_COMPLETE = 4;
|
||||
|
||||
public static byte COMPRESSING = 5;
|
||||
|
||||
|
||||
public static byte DOWNLOAD_ALL = 3;
|
||||
public static byte DOWNLOAD_PREVIEW = 2;
|
||||
public static byte DOWNLOAD_SOURCE = 1;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private String name;
|
||||
|
||||
private int gid;
|
||||
|
||||
private int pages;
|
||||
|
||||
private byte status;
|
||||
|
||||
private int proceeding;
|
||||
|
||||
private byte type;
|
||||
|
||||
@JsonIgnore
|
||||
private String path;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.lion.lionwebsite.Domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GidToKey {
|
||||
String gid;
|
||||
String key;
|
||||
int pages;
|
||||
|
||||
public String toUrl(){
|
||||
return "https://exhentai.org/g/" + gid + "/" + key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lion.lionwebsite.Domain;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageKeyCache {
|
||||
String gid;
|
||||
int page;
|
||||
String imgkey;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.lion.lionwebsite.Domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class MaskDomain {
|
||||
String raw;
|
||||
String mask;
|
||||
}
|
||||
@@ -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 {
|
||||
String key;
|
||||
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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.lion.lionwebsite.Domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Tag {
|
||||
int id;
|
||||
String tag;
|
||||
int usage;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.lion.lionwebsite.Domain;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class TagMark {
|
||||
int gid;
|
||||
int tid;
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
package com.lion.lionwebsite.Filter;
|
||||
|
||||
import com.lion.lionwebsite.Dao.UserMapper;
|
||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import jakarta.servlet.*;
|
||||
import jakarta.servlet.annotation.WebFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@WebFilter(filterName = "AccessFilter", urlPatterns = {"/validate"})
|
||||
@RequiredArgsConstructor
|
||||
public class AccessFilter implements Filter {
|
||||
|
||||
UserMapper userMapper;
|
||||
|
||||
public AccessFilter(UserMapper userMapper) {
|
||||
this.userMapper = userMapper;
|
||||
}
|
||||
final UserMapper userMapper;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
|
||||
|
||||
@@ -4,41 +4,17 @@ import jakarta.servlet.*;
|
||||
import jakarta.servlet.annotation.WebFilter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.util.Calendar;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@WebFilter(filterName = "AdaptorFilter", urlPatterns = {"/", "/personal/"})
|
||||
@Slf4j
|
||||
public class AdaptorFilter implements Filter {
|
||||
|
||||
FileWriter writer;
|
||||
|
||||
AdaptorFilter(){
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
try {
|
||||
writer = new FileWriter(String.format("log/AccessLog_%s-%s-%s.log",
|
||||
calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH)));
|
||||
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 0 * * *")
|
||||
void changeDate(){
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
try {
|
||||
if(writer != null)
|
||||
writer.close();
|
||||
|
||||
writer = new FileWriter(String.format("log/AccessLog_%s-%s-%s.log",
|
||||
calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH)));
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
|
||||
@@ -50,17 +26,15 @@ public class AdaptorFilter implements Filter {
|
||||
if(UserAgent == null)
|
||||
return;
|
||||
|
||||
String AuthCode = request.getParameter("AuthCode") == null ? "null" : request.getParameter("AuthCode");
|
||||
// 这里是全站请求日志,AuthCode 是真正的凭据;只记录是否携带,绝不落明文。
|
||||
boolean hasAuthCode = request.getParameter("AuthCode") != null;
|
||||
String ServletPath = request.getServletPath();
|
||||
String ip = request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr(): request.getHeader("X-Forwarded-For");
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
String now = String.format("%s:%s:%s", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND));
|
||||
|
||||
String now = LocalTime.now().format(TIME_FORMATTER);
|
||||
|
||||
//日志
|
||||
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);
|
||||
writer.write(String.format("%s ip:%s \tpath:%s \tAuthCode:%s ua:%s\n", now, ip, ServletPath, AuthCode, UserAgent));
|
||||
writer.flush();
|
||||
log.info("{} ip:{} \tpath:{} \tAuthCode:{} ua:{}", now, ip, ServletPath,
|
||||
hasAuthCode ? "present" : "absent", UserAgent.length() > 61 ? UserAgent.substring(0, 60): UserAgent);
|
||||
|
||||
//如果是验证,则直接跳转
|
||||
if(ServletPath.equals("/validate"))
|
||||
@@ -68,7 +42,7 @@ public class AdaptorFilter implements Filter {
|
||||
|
||||
//如果不是,则根据UA判断是否跳转
|
||||
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");
|
||||
else
|
||||
response.sendRedirect("/mobile");
|
||||
|
||||
@@ -2,13 +2,40 @@ package com.lion.lionwebsite.Interceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
|
||||
/**
|
||||
* 个人管理区的访问闸门:有效会话或合法 AuthCode。
|
||||
*
|
||||
* <p>会话来自机器人签发的登录链接(见 {@code PanelLoginTicket} 与
|
||||
* {@code /personal/login});固定的 {@code alone} 授权码保留给下载器与存储节点推送,
|
||||
* 因此这里两条路径都放行,等那些调用方切换完成后再退役字面量。
|
||||
*/
|
||||
public class PersonalInterceptor implements HandlerInterceptor {
|
||||
|
||||
/** 会话标记:{@code /personal/login} 校验票据后写入。 */
|
||||
public static final String SESSION_ATTRIBUTE = "personalAuthenticated";
|
||||
|
||||
@Override
|
||||
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,28 +1,32 @@
|
||||
package com.lion.lionwebsite.Interceptor;
|
||||
|
||||
import com.lion.lionwebsite.Dao.UserMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TaskHandlerInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Resource
|
||||
UserMapper userMapper;
|
||||
final UserMapper userMapper;
|
||||
|
||||
String[] AuthCodes = null;
|
||||
// 由 PostConstruct 加载后仅被读(发布式更新)与被测试线程读取;
|
||||
// volatile 保证 refresh 后其它线程立即看到新数组,避免停用的授权码短暂仍可用。
|
||||
volatile String[] AuthCodes;
|
||||
|
||||
public TaskHandlerInterceptor(UserMapper userMapper) {
|
||||
this.userMapper = userMapper;
|
||||
@PostConstruct
|
||||
void init() {
|
||||
AuthCodes = userMapper.selectAllAuthCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){
|
||||
public boolean preHandle(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler){
|
||||
String auth = request.getParameter("AuthCode");
|
||||
|
||||
if(auth != null)
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.lion.lionwebsite;
|
||||
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.mybatis.spring.annotation.MapperScans;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.boot.web.server.servlet.context.ServletComponentScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ServletComponentScan(value = "com.lion.lionwebsite.Filter")
|
||||
@MapperScans({
|
||||
@MapperScan(basePackages = "com.lion.lionwebsite.Dao.normal", sqlSessionTemplateRef = "sqlSessionTemplate-main", sqlSessionFactoryRef = "sqlSessionFactory-main"),
|
||||
@MapperScan(basePackages = "com.lion.lionwebsite.Dao.cache", sqlSessionTemplateRef = "sqlSessionTemplate-cache", sqlSessionFactoryRef = "sqlSessionFactory-cache")
|
||||
})
|
||||
public class LionWebsiteApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -13,15 +13,14 @@ public class AbstractMessage {
|
||||
|
||||
public static final byte RESPONSE_MESSAGE = 0;
|
||||
|
||||
public static final byte UPDATE_GALLERY_MESSAGE = 4;
|
||||
|
||||
public static final byte GALLERY_PAGE_QUERY_MESSAGE = 5;
|
||||
|
||||
public static final byte IDENTITY_MESSAGE = 6;
|
||||
|
||||
public static final byte MAINTAIN_MESSAGE = 7;
|
||||
|
||||
public static final byte GALLERY_REQUEST_MESSAGE = 101;
|
||||
public static final byte AVAILABLE_CHECK_MESSAGE = 8;
|
||||
|
||||
public static final byte SUBSCRIPTION_SNAPSHOT_MESSAGE = 9;
|
||||
|
||||
public byte messageType;
|
||||
|
||||
public int messageId;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AvailableCheckMessage extends AbstractMessage{
|
||||
{
|
||||
messageType = AVAILABLE_CHECK_MESSAGE;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,5 @@ public class DeleteGalleryMessage extends AbstractMessage{
|
||||
{
|
||||
messageType = DELETE_GALLERY_MESSAGE;
|
||||
}
|
||||
|
||||
public static final byte DELETE_ALL = 3;
|
||||
public static final byte DELETE_PREVIEW = 2;
|
||||
public static final byte DELETE_SOURCE = 1;
|
||||
|
||||
|
||||
|
||||
byte deleteType;
|
||||
|
||||
String galleryName;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GalleryPageQueryMessage extends AbstractMessage{
|
||||
{
|
||||
messageType = GALLERY_PAGE_QUERY_MESSAGE;
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
String name;
|
||||
|
||||
int page;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
String pageName;
|
||||
|
||||
byte result;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
//请求预览/压缩包
|
||||
public class GalleryRequestMessage extends AbstractMessage{
|
||||
|
||||
public static final byte SOURCE = 1;
|
||||
public static final byte PREVIEW = 2;
|
||||
|
||||
public static final byte COMPRESS_SOURCE = 3;
|
||||
|
||||
{
|
||||
messageType = GALLERY_REQUEST_MESSAGE;
|
||||
}
|
||||
|
||||
String galleryName;
|
||||
|
||||
byte type;
|
||||
|
||||
short page;
|
||||
|
||||
short port;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageCodec;
|
||||
@@ -39,13 +39,12 @@ public class MessageCodec extends ByteToMessageCodec<AbstractMessage> {
|
||||
AbstractMessage abstractMessage = switch (messageType){
|
||||
case AbstractMessage.DOWNLOAD_POST_MESSAGE -> objectMapper.readValue(metadata, DownloadPostMessage.class);
|
||||
case AbstractMessage.DOWNLOAD_STATUS_MESSAGE -> objectMapper.readValue(metadata, DownloadStatusMessage.class);
|
||||
case AbstractMessage.GALLERY_REQUEST_MESSAGE -> objectMapper.readValue(metadata, GalleryRequestMessage.class);
|
||||
case AbstractMessage.RESPONSE_MESSAGE -> objectMapper.readValue(metadata, ResponseMessage.class);
|
||||
case AbstractMessage.UPDATE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, UpdateGalleryMessage.class);
|
||||
case AbstractMessage.DELETE_GALLERY_MESSAGE -> objectMapper.readValue(metadata, DeleteGalleryMessage.class);
|
||||
case AbstractMessage.GALLERY_PAGE_QUERY_MESSAGE -> objectMapper.readValue(metadata, GalleryPageQueryMessage.class);
|
||||
case AbstractMessage.IDENTITY_MESSAGE -> objectMapper.readValue(metadata, IdentityMessage.class);
|
||||
case AbstractMessage.MAINTAIN_MESSAGE -> objectMapper.readValue(metadata, MaintainMessage.class);
|
||||
case AbstractMessage.AVAILABLE_CHECK_MESSAGE -> objectMapper.readValue(metadata, AvailableCheckMessage.class);
|
||||
case AbstractMessage.SUBSCRIPTION_SNAPSHOT_MESSAGE -> objectMapper.readValue(metadata, SubscriptionSnapshotMessage.class);
|
||||
default -> null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class SubscriptionAccountSnapshot {
|
||||
private Integer accountId;
|
||||
private boolean enabled;
|
||||
private boolean filterHighMultiplier;
|
||||
private String v2ContentBase64;
|
||||
private String v2Sha256;
|
||||
private String clashContentBase64;
|
||||
private String clashSha256;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class SubscriptionBindingSnapshot {
|
||||
private String publicKeySha256;
|
||||
private Integer accountId;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@ToString(exclude = {"payloadBase64", "signature"})
|
||||
public class SubscriptionSnapshotMessage extends AbstractMessage {
|
||||
{
|
||||
messageType = SUBSCRIPTION_SNAPSHOT_MESSAGE;
|
||||
}
|
||||
|
||||
private int schemaVersion;
|
||||
private String revision;
|
||||
private long generatedAt;
|
||||
private String payloadBase64;
|
||||
private String payloadSha256;
|
||||
private String signature;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class SubscriptionSnapshotPayload {
|
||||
private int schemaVersion;
|
||||
private List<SubscriptionAccountSnapshot> accounts = new ArrayList<>();
|
||||
private List<SubscriptionBindingSnapshot> bindings = new ArrayList<>();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.lion.lionwebsite.Message;
|
||||
|
||||
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UpdateGalleryMessage extends AbstractMessage{
|
||||
{
|
||||
messageType = UPDATE_GALLERY_MESSAGE;
|
||||
}
|
||||
|
||||
GalleryTask galleryTask;
|
||||
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.CollectMapper;
|
||||
import com.lion.lionwebsite.Dao.normal.CollectMapper;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import lombok.Data;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -8,11 +8,7 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
@Data
|
||||
public class CollectService {
|
||||
CollectMapper collectMapper;
|
||||
|
||||
public CollectService(CollectMapper collectMapper){
|
||||
this.collectMapper = collectMapper;
|
||||
}
|
||||
final CollectMapper collectMapper;
|
||||
|
||||
public String collectGallery(int gid, int collector){
|
||||
Response response = Response.generateResponse();
|
||||
@@ -28,7 +24,7 @@ public class CollectService {
|
||||
public String disCollectGallery(int gid, int collector){
|
||||
Response response = Response.generateResponse();
|
||||
if(collectMapper.isCollect(gid, collector) == 0) { //没有收藏
|
||||
response.failure("没有收藏该本子");
|
||||
response.failure("没有收藏该图片");
|
||||
}else{
|
||||
collectMapper.disCollect(gid, collector);
|
||||
response.success("取消收藏成功");
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.lion.lionwebsite.Dao.*;
|
||||
import com.lion.lionwebsite.Dao.cache.ImageCacheMapper;
|
||||
import com.lion.lionwebsite.Dao.normal.*;
|
||||
import com.lion.lionwebsite.Domain.*;
|
||||
import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
||||
import com.lion.lionwebsite.Error.ErrorCode;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
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.Response;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.Data;
|
||||
@@ -18,156 +19,160 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.dateTimeFormatter;
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
|
||||
import static com.lion.lionwebsite.Util.GalleryUtil.*;
|
||||
|
||||
@Service
|
||||
@Data
|
||||
@Slf4j
|
||||
public class GalleryManageService {
|
||||
String TargetPath = "/root/gallery/";
|
||||
String cachePath = "/storage/galleryCache/onlineImages/";
|
||||
|
||||
int cacheSize = 100;
|
||||
final GalleryMapper galleryMapper;
|
||||
|
||||
Map<Integer, String> gid2name_cache = new HashMap<>();
|
||||
final CollectMapper collectMapper;
|
||||
|
||||
Map<Integer, Integer> gid2page_cache = new HashMap<>();
|
||||
final CustomConfigurationMapper configurationMapper;
|
||||
|
||||
Map<String, String> page2pageNameCache = new HashMap<>();
|
||||
final UserMapper userMapper;
|
||||
|
||||
GalleryMapper galleryMapper;
|
||||
final ImageCacheMapper imageCacheMapper;
|
||||
|
||||
CollectMapper collectMapper;
|
||||
final RemoteService remoteService;
|
||||
|
||||
CustomConfigurationMapper configurationMapper;
|
||||
|
||||
UserMapper userMapper;
|
||||
|
||||
ShareFileMapper shareFileMapper;
|
||||
|
||||
TagMapper tagMapper;
|
||||
|
||||
PageNameCacheMapper pageNameCacheMapper;
|
||||
|
||||
RemoteService remoteService;
|
||||
|
||||
PushService pushService;
|
||||
|
||||
public GalleryManageService(GalleryMapper galleryMapper, CollectMapper collectMapper, CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper,
|
||||
TagMapper tagMapper, PageNameCacheMapper pageNameCacheMapper, RemoteService remoteService, PushService pushService){
|
||||
this.galleryMapper = galleryMapper;
|
||||
this.collectMapper = collectMapper;
|
||||
this.configurationMapper = configurationMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.shareFileMapper = shareFileMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.pageNameCacheMapper = pageNameCacheMapper;
|
||||
this.remoteService = remoteService;
|
||||
this.pushService = pushService;
|
||||
}
|
||||
final PushService pushService;
|
||||
|
||||
/**
|
||||
* 创建任务
|
||||
* @param link 任务链接
|
||||
* @param targetResolution 目标分辨率
|
||||
* @param AuthCode 授权码(用于记录是谁下载的)
|
||||
* @return 提交结果
|
||||
* @param link 任务链接
|
||||
* @param targetResolution 目标分辨率
|
||||
* @param AuthCode 授权码(用于记录是谁下载的)
|
||||
* @return 提交结果
|
||||
*/
|
||||
public String createTask(String link, String targetResolution, String AuthCode, List<Integer> tidS, byte mode){
|
||||
public String createTask(String link, String targetResolution, String AuthCode) {
|
||||
Response response = Response.generateResponse();
|
||||
User user = userMapper.selectUserByAuthCode(AuthCode);
|
||||
// return Response._failure("调试中,请勿提交任务");
|
||||
|
||||
if(remoteService.isDead()) {
|
||||
response.failure("节点挂了,找狮子处理");
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
if (user == null) {
|
||||
response.failure("授权码无效");
|
||||
return response.toJSONString();
|
||||
}
|
||||
int gid;
|
||||
try {
|
||||
gid = Integer.parseInt(link.split("/")[4]);
|
||||
}catch (NumberFormatException e){
|
||||
|
||||
// 段数不足会先抛 ArrayIndexOutOfBoundsException,非数字段抛 NumberFormatException;
|
||||
// 只捕后者会让畸形链接穿透为 500(本项目无 @ControllerAdvice)。
|
||||
Integer parsedGid = parseGidFromLink(link);
|
||||
if (parsedGid == null) {
|
||||
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();
|
||||
}
|
||||
Gallery gallery;
|
||||
|
||||
//判断数据库中是否有这个任务,有则返回状态
|
||||
if((gallery = galleryMapper.selectGalleryByGid(gid)) != null){
|
||||
if ((gallery = galleryMapper.selectGalleryByGid(gid)) != null) {
|
||||
response.failure("任务队列已有此任务,任务状态: " + gallery.getStatus() + " 请点击查找任务");
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
pushService.taskCreateReport(user.getUsername(), gallery.getName(), response);
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
//尝试下载本子,返回结果
|
||||
//尝试下载图片,返回结果
|
||||
try {
|
||||
gallery = GalleryUtil.parse(link, true, targetResolution);
|
||||
if(gallery == null){
|
||||
log.error("创建任务: {},解析失败", link);
|
||||
response.failure("任务解析失败,未知原因,请检查链接是否正常");
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
if (gallery == null || !gallery.getStatus().equals("已提交")) {
|
||||
log.error("创建任务失败: {},", link);
|
||||
response.failure("提交任务失败,未知原因,请检查链接是否正常");
|
||||
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||
return response.toJSONString();
|
||||
}else{
|
||||
} else {
|
||||
taskName = gallery.getName();
|
||||
log.info("创建任务: {} 目标分辨率:{}", link, targetResolution);
|
||||
if(remoteService.addGalleryToQueue(gallery, mode) != 0){
|
||||
// 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) {
|
||||
log.error("传送任务{}失败, 未知原因", gallery.getName());
|
||||
response.failure("任务传送失败,未知原因");
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
response.failure("任务已保存,但节点未确认接收;请刷新任务列表后重试");
|
||||
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||
return response.toJSONString();
|
||||
}
|
||||
}
|
||||
}catch (ResolutionNotMatchException e){
|
||||
e.printStackTrace();
|
||||
} catch (ResolutionNotMatchException e) {
|
||||
response.failure("提交失败,分辨率不存在");
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||
return response.toJSONString();
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
} catch (IOException e) {
|
||||
log.error(e.getMessage());
|
||||
response.failure("IO错误,可能是网络波动");
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
//处理下载结果,将任务插入数据库并且更新每周用量
|
||||
if(gallery.getStatus().equals("已提交")) {
|
||||
response.success(gallery.toString());
|
||||
gallery.setDownloader(user.getId());
|
||||
gallery.setMode(mode);
|
||||
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));
|
||||
if(tidS != null)
|
||||
for (Integer tid : tidS)
|
||||
if(tagMapper.selectTagExistById(tid) > 0)
|
||||
tagMapper.markTag(gallery.getGid(), tid);
|
||||
}
|
||||
else{
|
||||
response.failure("提交失败,未知原因");
|
||||
galleryMapper.deleteGalleryByGid(gallery.getGid());
|
||||
}
|
||||
pushService.taskCreateReport(user.getUsername(), link, response);
|
||||
// Do not overwrite an immediate node status with the original submitted state.
|
||||
Gallery current = galleryMapper.selectGalleryByGid(gallery.getGid());
|
||||
response.success((current == null ? gallery : current).toString());
|
||||
pushService.taskCreateReport(user.getUsername(), taskName, response);
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接查询本子
|
||||
* @param link 链接
|
||||
* 从任务链接中安全提取 gid。任何畸形输入(null、段数不足、非数字)都返回 null,
|
||||
* 由调用方转成业务失败,避免异常穿透为 500。
|
||||
*/
|
||||
static Integer parseGidFromLink(String link) {
|
||||
if (link == null)
|
||||
return null;
|
||||
String[] segments = link.split("/");
|
||||
if (segments.length <= 4)
|
||||
return null;
|
||||
try {
|
||||
return Integer.parseInt(segments[4]);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重新连接
|
||||
* @return 重连结果
|
||||
*/
|
||||
public String reconnect(){
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
return switch (remoteService.reconnect()) {
|
||||
case 0 -> response.success("重连成功").toJSONString();
|
||||
case -1 -> response.failure("重连失败").toJSONString();
|
||||
case -2 -> response.failure("当前未连接").toJSONString();
|
||||
default -> response.failure("未知错误").toJSONString();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接查询图片
|
||||
* @param link 链接
|
||||
* @return 查询结果
|
||||
*/
|
||||
public String selectTaskByLink(String link) {
|
||||
Response response = Response.generateResponse();
|
||||
Integer gid = parseGid(link);
|
||||
Gallery gallery;
|
||||
if(gid != null)
|
||||
gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
if (gid != null)
|
||||
gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
else {
|
||||
response.failure("链接错误");
|
||||
return response.toJSONString();
|
||||
@@ -175,35 +180,36 @@ public class GalleryManageService {
|
||||
log.info("查询{}", link);
|
||||
|
||||
//判断数据库中是否有这个任务,如果有则返回,如果没有则直接查询
|
||||
if(gallery == null)
|
||||
try{
|
||||
if (gallery == null)
|
||||
try {
|
||||
gallery = GalleryUtil.parse(link, false, null);
|
||||
if(gallery != null)
|
||||
response.success(new ObjectMapper().valueToTree(gallery).toString());
|
||||
if (gallery != null)
|
||||
response.success(objectMapper.valueToTree(gallery).toString());
|
||||
else
|
||||
response.failure("查询失败");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.info(e.getMessage());
|
||||
response.failure("查询失败");
|
||||
}
|
||||
|
||||
else
|
||||
response.success(new ObjectMapper().valueToTree(gallery).toString());
|
||||
response.success(objectMapper.valueToTree(gallery).toString());
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过gid查询本子
|
||||
* 通过gid查询图片
|
||||
*
|
||||
* @param gid gid
|
||||
* @return 查询结果
|
||||
*/
|
||||
public String selectTaskByGid(int gid){
|
||||
public String selectTaskByGid(int gid) {
|
||||
Response response = Response.generateResponse();
|
||||
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
|
||||
if(gallery == null)
|
||||
response.failure("未找到该本子,请使用链接");
|
||||
if (gallery == null)
|
||||
response.failure("未找到该图片,请使用链接");
|
||||
|
||||
else
|
||||
response.success(gallery.toString());
|
||||
@@ -212,77 +218,82 @@ public class GalleryManageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有本子
|
||||
* 查询所有图片
|
||||
*
|
||||
* @return 查询结果
|
||||
*/
|
||||
public String selectAllGallery(int userId) {
|
||||
Response response = Response.generateResponse();
|
||||
Gallery[] galleries = galleryMapper.selectAllGallery();
|
||||
|
||||
if(galleries == null){
|
||||
response.failure("没有找到本子");
|
||||
if (galleries == null) {
|
||||
response.failure("没有找到图片");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
ArrayList<Integer> galleryIds = collectMapper.selectGidByCollector(userId);
|
||||
Iterator<Integer> idIterator;
|
||||
|
||||
if(!galleryIds.isEmpty()) //如果该用户收藏了本子
|
||||
galleryLoop: for (Gallery gallery : galleries) { //遍历本子
|
||||
idIterator = galleryIds.iterator();
|
||||
while (idIterator.hasNext()){ //遍历收藏的gid
|
||||
Integer id = idIterator.next();
|
||||
if(id.equals(gallery.getGid())){ //如果找到对应的gid,修改对应本子的属性,删除当前gid,判断是否需要跳出或者结束循环
|
||||
gallery.setCollect(true);
|
||||
idIterator.remove();
|
||||
|
||||
if(galleryIds.isEmpty())
|
||||
break galleryLoop;
|
||||
else
|
||||
continue galleryLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
HashMap<Integer, ArrayList<Integer>> tagsMap = new HashMap<>();
|
||||
ArrayList<TagMark> tags = tagMapper.selectAllMark();
|
||||
|
||||
for (TagMark tagMark : tags) { //从数据库取出所有标记,然后筛选出 gid -> tids ,后面加个缓存
|
||||
tagsMap.computeIfAbsent(tagMark.getGid(), k -> new ArrayList<>());
|
||||
tagsMap.get(tagMark.getGid()).add(tagMark.getTid());
|
||||
// 原先对每个画廊线性扫描收藏列表(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);
|
||||
}
|
||||
|
||||
ArrayList<Integer> temp;
|
||||
for (Gallery gallery : galleries) {
|
||||
if((temp = tagsMap.get(gallery.getGid())) != null) {
|
||||
gallery.setTags(new ArrayList<>());
|
||||
gallery.getTags().addAll(temp);
|
||||
}
|
||||
}
|
||||
// 下载人昵称只对管理员下发;一次建表避免逐条查询。
|
||||
if (userId == UserService.ADMIN_USER_ID)
|
||||
fillDownloaderNames(galleries);
|
||||
|
||||
response.success(new ObjectMapper().valueToTree(galleries).toString());
|
||||
response.success(objectMapper.valueToTree(galleries).toString());
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/** 给任务列表补上下载人昵称,找不到用户时保留空值而不是报错。 */
|
||||
private void fillDownloaderNames(Gallery[] galleries) {
|
||||
User[] users = userMapper.selectAllUser();
|
||||
Map<Integer, String> names = new HashMap<>();
|
||||
if (users != null)
|
||||
for (User user : users)
|
||||
names.put(user.getId(), user.getUsername());
|
||||
|
||||
for (Gallery gallery : galleries)
|
||||
gallery.setDownloaderName(names.get(gallery.getDownloader()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询未完成的本子
|
||||
* @return 查询结果
|
||||
* 查询某个任务的实际下载人昵称。
|
||||
* 只有管理员可调用,普通用户一律拒绝,避免暴露他人身份。
|
||||
*
|
||||
* @param gid 任务 gid
|
||||
* @param AuthCode 调用方授权码
|
||||
*/
|
||||
public String selectUnDoneGallery() {
|
||||
public String selectDownloaderByGid(int gid, String AuthCode) {
|
||||
Response response = Response.generateResponse();
|
||||
Gallery[] galleries = galleryMapper.selectUnDoneGalleries();
|
||||
User requester = userMapper.selectUserByAuthCode(AuthCode);
|
||||
if (requester == null || requester.getId() != UserService.ADMIN_USER_ID) {
|
||||
response.failure("无权查看下载人");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
if(galleries.length > 0)
|
||||
response.success(new ObjectMapper().valueToTree(galleries).toString());
|
||||
else
|
||||
response.failure();
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过本子名查询本子
|
||||
* 通过图片名查询图片
|
||||
*
|
||||
* @param name 名字
|
||||
* @return 查询结果
|
||||
*/
|
||||
@@ -290,193 +301,76 @@ public class GalleryManageService {
|
||||
Response response = Response.generateResponse();
|
||||
Gallery gallery = galleryMapper.selectGalleryByName("%" + name + "%");
|
||||
|
||||
if(gallery != null)
|
||||
response.success(new ObjectMapper().valueToTree(gallery).toString());
|
||||
if (gallery != null)
|
||||
response.success(objectMapper.valueToTree(gallery).toString());
|
||||
else
|
||||
response.failure("没有找到该名字的本子");
|
||||
response.failure("没有找到该名字的图片");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户下载的本子
|
||||
* @param AuthCode 授权码(用于查询用户名)
|
||||
* @return 查询结果
|
||||
* 查询用户下载的图片
|
||||
*
|
||||
* @param AuthCode 授权码(用于查询用户名)
|
||||
* @return 查询结果
|
||||
*/
|
||||
public String selectGalleryByDownloader(String AuthCode){
|
||||
public String selectGalleryByDownloader(String AuthCode) {
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
Gallery[] galleries = galleryMapper.selectGalleryByDownloader(userMapper.selectUserByAuthCode(AuthCode).getId());
|
||||
if(galleries.length > 0)
|
||||
response.success(new ObjectMapper().valueToTree(galleries).toString());
|
||||
if (galleries.length > 0)
|
||||
response.success(objectMapper.valueToTree(galleries).toString());
|
||||
else
|
||||
response.failure("您未下载本子");
|
||||
response.failure("您未下载图片");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 获取本子图片的名字
|
||||
// * @param gid gid
|
||||
// * @return 名字数组
|
||||
// */
|
||||
// public String selectOnlineFilename(Integer gid){
|
||||
// Response response = Response.generateResponse();
|
||||
// String path;
|
||||
//
|
||||
// if((path = gid2name_cache.get(gid)) == null) {
|
||||
// Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
//
|
||||
// if (gallery == null) {
|
||||
// response.failure("本子不存在");
|
||||
// return response.toJSONString();
|
||||
// }
|
||||
//
|
||||
// path = TargetPath + gallery.getName();
|
||||
// gid2name_cache.put(gid, path);
|
||||
// if(gid2name_cache.size() > cacheSize)
|
||||
// gid2name_cache.remove(gid2name_cache.keySet().iterator().next());
|
||||
// }
|
||||
//
|
||||
// File file = new File(path);
|
||||
// if(!file.isDirectory()){
|
||||
// response.failure("本子文件已被删除");
|
||||
// return response.toJSONString();
|
||||
// }
|
||||
//
|
||||
// File[] files = file.listFiles(pathname -> pathname.getName().endsWith(".webp") && !pathname.getName().startsWith("thumbnail"));
|
||||
// if(files == null || files.length == 0){
|
||||
// response.failure("本子文件丢失");
|
||||
// return response.toJSONString();
|
||||
// }
|
||||
//
|
||||
// ArrayList<String> images = new ArrayList<>();
|
||||
// for(File image: files){
|
||||
// images.add(image.getName().replace(".webp", ""));
|
||||
// }
|
||||
// //字符串长度相等时比较字典顺序,不相等时比较长度
|
||||
// images.sort((s1, s2) ->
|
||||
// s1.length() == s2.length() ? s1.compareTo(s2): s1.length() - s2.length()
|
||||
// );
|
||||
//
|
||||
// response.success(new ObjectMapper().valueToTree(images).toString());
|
||||
// return response.toJSONString();
|
||||
// }
|
||||
|
||||
/**
|
||||
* 删除本子以及对应的文件(如果存在的话)
|
||||
* 删除图片以及对应的文件(如果存在的话)
|
||||
*
|
||||
* @param gid gid
|
||||
* @return 删除结果
|
||||
*/
|
||||
public String deleteGalleryByGid(Integer gid, String AuthCode, byte mode) {
|
||||
public String deleteGalleryByGid(Integer gid, String AuthCode) {
|
||||
Response response = Response.generateResponse();
|
||||
String deleteType;
|
||||
|
||||
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
User user = userMapper.selectUserByAuthCode(AuthCode);
|
||||
if(gallery == null){
|
||||
response.failure("删除失败,该本子不存在");
|
||||
if (gallery == null) {
|
||||
response.failure("删除失败,该图片不存在");
|
||||
return response.toJSONString();
|
||||
}
|
||||
if (user == null) {
|
||||
response.failure("删除失败,授权码无效");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
|
||||
if(!(collector.isEmpty() || collector.size() == 1 && collector.get(0).equals(user.getId()) //判断收藏
|
||||
&& gallery.getDownloader() == user.getId())) //判断下载
|
||||
response.failure("删除失败,该本子已被别人收藏或你不是下载人");
|
||||
|
||||
else{
|
||||
if((gallery.getMode() & mode) == 0) { //不能删除对应的,因为对应的没有
|
||||
if (mode == 1)
|
||||
response.failure("无法删除该本子的源文件,不存在");
|
||||
else if (mode == 2)
|
||||
response.failure("无法删除该本子的预览图,不存在");
|
||||
} else {
|
||||
log.info("删除本子{}, mode:{}, delete_mode:{}", gallery.getName(), gallery.getMode(), mode);
|
||||
gallery.setMode((byte) (gallery.getMode() ^ mode));
|
||||
if(gallery.getMode() == 0) { //删整个本子
|
||||
galleryMapper.deleteGalleryByGid(gallery.getGid()); //删除本子记录
|
||||
ArrayList<Integer> tidS = tagMapper.selectTagByGid(gallery.getGid());
|
||||
for (Integer tid : tidS)
|
||||
tagMapper.decrTagUsage(tid);
|
||||
|
||||
tagMapper.disMarkTagByGid(gallery.getGid()); //删除本子标记
|
||||
gid2name_cache.remove(gallery.getGid()); //删除本子缓存
|
||||
page2pageNameCache.remove(gallery.getName()); //移除页数缓存
|
||||
pageNameCacheMapper.deletePageNameByGid(gid);
|
||||
|
||||
if(new File(TargetPath + "/" + gallery.getName()).isDirectory()) //删除本地缓存
|
||||
FileUtil.del(TargetPath + "/" + gallery.getName());
|
||||
deleteType = "全部";
|
||||
}else{
|
||||
if(mode == 2 && new File(TargetPath + "/" + gallery.getName()).isDirectory()) { //删除预览缓存
|
||||
FileUtil.del(TargetPath + "/" + gallery.getName());
|
||||
gid2name_cache.remove(gallery.getGid()); //删除本子缓存
|
||||
page2pageNameCache.remove(gallery.getName()); //移除页数缓存
|
||||
}
|
||||
|
||||
galleryMapper.updateGallery(gallery);
|
||||
deleteType = (mode == 1? "源文件": "预览图"); //用于提示
|
||||
}
|
||||
switch (remoteService.deleteGallery(gallery, mode)){
|
||||
case ErrorCode.IO_ERROR -> response.failure("本子:" + gallery.getName() + deleteType + "删除失败,IO错误");
|
||||
case ErrorCode.FILE_NOT_FOUND -> response.failure("本子:" + gallery.getName() + deleteType + "删除失败,文件不存在");
|
||||
case 0 -> response.success();
|
||||
}
|
||||
if(response.get("result").equals("failure"))
|
||||
log.info(response.getResult());
|
||||
}
|
||||
}
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新本子
|
||||
* @param link 目标链接
|
||||
* @return 更新结果
|
||||
*/
|
||||
public String updateGallery(String link){
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
Integer gid = parseGid(link);
|
||||
if(gid == null) {
|
||||
response.failure("链接格式出错");
|
||||
// 拒绝条件与提示文案一致:被别人收藏,或者请求者不是下载人。
|
||||
// 注意不能写成 collector.isEmpty() || ...:那样在「无任何收藏」时会短路放行,
|
||||
// 从而完全跳过下载者校验,导致任何有效授权码都能删除他人任务。
|
||||
boolean collectedByOthers = collector.stream().anyMatch(id -> id != user.getId());
|
||||
boolean isDownloader = gallery.getDownloader() == user.getId();
|
||||
if (collectedByOthers || !isDownloader) {
|
||||
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
|
||||
log.info("拒绝删除 gid={}:collectedByOthers={} isDownloader={}", gid, collectedByOthers, isDownloader);
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
Gallery oldGallery = galleryMapper.selectGalleryByGid(gid);
|
||||
if(oldGallery == null) {
|
||||
response.failure("数据库不存在该本子,请提交下载");
|
||||
return response.toJSONString();
|
||||
// 通过授权后才落库并通知节点,避免被拒请求仍删除节点文件。
|
||||
log.info("删除图片{}", gallery.getName());
|
||||
galleryMapper.deleteGalleryByGid(gallery.getGid());
|
||||
switch (remoteService.deleteGallery(gallery)) {
|
||||
case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
|
||||
case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
|
||||
case 0 -> response.success();
|
||||
// 节点无响应/超时会返回 -1 等非枚举值;必须显式判失败,
|
||||
// 否则 result 保持未设置,末尾的 response.get("result") 会抛 NPE。
|
||||
default -> response.failure("图片:" + gallery.getName() + "删除失败,节点无响应");
|
||||
}
|
||||
|
||||
try {
|
||||
String newLink = GalleryUtil.queryUpdateLink(oldGallery.getLink());
|
||||
if(newLink == null)
|
||||
response.failure("本子没有更新");
|
||||
|
||||
else{
|
||||
Gallery newGallery = GalleryUtil.parse(newLink, true, oldGallery.getResolution());
|
||||
if(newGallery != null) {
|
||||
galleryMapper.deleteGalleryByGid(oldGallery.getGid());
|
||||
remoteService.updateGallery(newGallery, oldGallery.getMode());
|
||||
newGallery.setDownloader(oldGallery.getDownloader());
|
||||
newGallery.setCollector(oldGallery.getCollector());
|
||||
|
||||
long usedAmount = Long.parseLong(configurationMapper.selectConfiguration(CustomConfiguration.WEEK_USED_AMOUNT).getValue());
|
||||
usedAmount += newGallery.getFileSize();
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, String.valueOf(usedAmount));
|
||||
|
||||
galleryMapper.insertGallery(newGallery);
|
||||
response.success("提交更新成功,更新页数:" + newGallery.getPages());
|
||||
}
|
||||
else {
|
||||
response.failure("存在更新,但更新失败");
|
||||
}
|
||||
}
|
||||
}catch (Exception e){
|
||||
response.failure("获取更新失败");
|
||||
}
|
||||
|
||||
if (!response.isSuccess())
|
||||
log.info(response.getData());
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
@@ -490,126 +384,127 @@ public class GalleryManageService {
|
||||
CustomConfiguration lastResetAmountTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME);
|
||||
Map<String, String> data = new HashMap<>();
|
||||
|
||||
data.put("weekUsedAmount", CustomUtil.fileSizeToString(Long.parseLong(weekUsedAmount.getValue())));
|
||||
data.put("lastResetAmountTime", lastResetAmountTime.getValue());
|
||||
// 配置行缺失时给出默认值,避免 NPE 让用量接口整体不可用。
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取缩略图 (直接按照名字路径获取,后期可能会加入缓存)
|
||||
* @param request 请求对象
|
||||
* @param response 响应对象
|
||||
* @param name 本子名字
|
||||
*/
|
||||
public void getThumbnail(HttpServletRequest request, HttpServletResponse response, String name){
|
||||
File thumbnail = new File(TargetPath + name, "thumbnail.webp");
|
||||
|
||||
if(!thumbnail.exists())
|
||||
if(remoteService.cachePreview(name, (short) 0, thumbnail) != 0){
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
|
||||
FileDownload.export(request, response, thumbnail.getAbsolutePath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线图片
|
||||
* @param gid gid
|
||||
* @param page 文件名
|
||||
* @param request 请求对象
|
||||
* @param response 响应对象
|
||||
*/
|
||||
public void getOnlineImage(Integer gid, Short page, HttpServletRequest request, HttpServletResponse response){
|
||||
String name;
|
||||
|
||||
//本子名缓存
|
||||
if((name = gid2name_cache.get(gid)) == null) //内存
|
||||
if((name = galleryMapper.selectGalleryNameByGid(gid)) == null) { //数据库
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
else
|
||||
gid2name_cache.put(gid, name);
|
||||
|
||||
//页数缓存
|
||||
Integer real_page;
|
||||
if((real_page = gid2page_cache.get(gid)) == null)
|
||||
real_page = galleryMapper.selectGalleryByGid(gid).getPages();
|
||||
|
||||
//判断页数是否超出
|
||||
if(real_page < page || page < 0){
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
//页名缓存
|
||||
String pageName;
|
||||
if((pageName = page2pageNameCache.get(gid + String.valueOf(page))) == null) //内存
|
||||
if((pageName = pageNameCacheMapper.selectPageName(gid, page)) == null) //数据库
|
||||
if ((pageName = remoteService.queryPageName(name, page)) == null) { //远程查询
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
//插入数据库
|
||||
else
|
||||
pageNameCacheMapper.insertPageNameCache(new PageNameCache(gid, page, pageName));
|
||||
//插入缓存
|
||||
else
|
||||
page2pageNameCache.put(gid + String.valueOf(page), pageName);
|
||||
|
||||
|
||||
//图片缓存
|
||||
File file = new File(TargetPath, name + "/" + pageName);
|
||||
if(!file.exists()) //硬盘
|
||||
if(remoteService.cachePreview(name, page, file) != 0) { //远程
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
|
||||
FileDownload.export(request, response, file.getAbsolutePath());
|
||||
}
|
||||
|
||||
|
||||
|
||||
public String shareGallery(Integer gid, Integer expireHour){
|
||||
public String cacheImagesKey(String url) {
|
||||
Response response = Response.generateResponse();
|
||||
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
Map<String, String> jsonObject = new HashMap<>();
|
||||
if(gallery == null){
|
||||
response.failure("本子不存在");
|
||||
|
||||
// 畸形链接在这里就拦掉:段数不足会让 url.split("/")[5] 抛 ArrayIndexOutOfBoundsException,
|
||||
// 而 parseGid 也会对无 /g/ 的链接返回 null,两者都不该变成 500。
|
||||
String[] segments = url == null ? null : url.split("/");
|
||||
Integer parsedGid = parseGidFromLink(url);
|
||||
if (parsedGid == null || segments == null || segments.length <= 5) {
|
||||
response.failure("链接错误");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
File file = new File(TargetPath + gallery.getName() + ".zip");
|
||||
|
||||
if(!file.isFile()){
|
||||
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);
|
||||
}
|
||||
|
||||
ShareFile shareFile = shareFileMapper.selectShareFileByFilePath(file.getAbsolutePath());
|
||||
if(shareFile != null){
|
||||
jsonObject.put("shareCode", shareFile.getShareCode());
|
||||
jsonObject.put("expireTime", dateTimeFormatter.format(LocalDateTime.ofInstant(shareFile.getExpireTime().toInstant(), ZoneId.systemDefault())));
|
||||
response.success(new ObjectMapper().valueToTree(jsonObject).toString());
|
||||
return response.toJSONString();
|
||||
boolean inserted = false;
|
||||
try {
|
||||
gidToKey = new GidToKey();
|
||||
gidToKey.setGid(gid);
|
||||
gidToKey.setKey(segments[5].strip());
|
||||
ArrayList<ImageKeyCache> imageKeyCaches = GalleryUtil.parseImageKeys(url);
|
||||
if(imageKeyCaches == null)
|
||||
return response.failure("该图片已下架或已被删除").toJSONString();
|
||||
gidToKey.setPages(imageKeyCaches.size());
|
||||
imageCacheMapper.insertGidToKey(gidToKey);
|
||||
inserted = true;
|
||||
for (ImageKeyCache imageKeyCache : imageKeyCaches)
|
||||
imageCacheMapper.insertImageKeyCache(imageKeyCache);
|
||||
response.success(objectMapper.valueToTree(gidToKey));
|
||||
}catch (Exception e){
|
||||
// 索引必须整体生效:先写 gidToKey 再逐页写 key,中途失败会留下「gidToKey 命中、
|
||||
// 页 key 缺失」的半截缓存,后续请求直接返回已缓存而永远取不到图。
|
||||
// 因此任何异常都回滚已写入的部分再回业务失败。Jackson 3 的解析异常继承
|
||||
// RuntimeException 而非 IOException,只用 IOException 会漏掉,这里统一兜住。
|
||||
if (inserted)
|
||||
rollbackImageIndex(gid);
|
||||
log.warn("缓存图片索引失败 gid={} errorType={}", gid, e.getClass().getSimpleName());
|
||||
response.failure("网络波动或其他异常");
|
||||
}
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
String ShareCode = RandomUtil.randomString(8);
|
||||
Calendar expireTime = Calendar.getInstance();
|
||||
/** 页 key 行数与 gidToKey 记录不一致时视为半截缓存,需要重建。 */
|
||||
private boolean isIndexComplete(GidToKey gidToKey) {
|
||||
Integer pages = gidToKey.getPages();
|
||||
if (pages == null || pages <= 0)
|
||||
return false;
|
||||
return imageCacheMapper.countImageKeyCacheByGid(gidToKey.getGid()) == pages;
|
||||
}
|
||||
|
||||
expireTime.add(Calendar.HOUR, expireHour);
|
||||
shareFileMapper.insertShareFile(ShareCode, file.getAbsolutePath(), 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());
|
||||
/** 删除某个 gid 已写入的索引(页 key 与 gidToKey),用于回滚或重建。 */
|
||||
private void rollbackImageIndex(String gid) {
|
||||
try {
|
||||
imageCacheMapper.deleteImageKeyCacheByGid(gid);
|
||||
imageCacheMapper.deleteGidToKey(gid);
|
||||
} catch (Exception e) {
|
||||
log.warn("回滚图片索引缓存失败 gid={}", gid, e);
|
||||
}
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
public Callable<?> getCachedImage(String gid, Integer page, HttpServletRequest request, HttpServletResponse response) {
|
||||
Path directory = Path.of(cachePath, gid);
|
||||
String name = String.valueOf(page);
|
||||
Path cached = ImageFileCache.find(directory, name);
|
||||
if (cached != null) {
|
||||
FileDownload.export(request, response, cached.toString());
|
||||
return null;
|
||||
}
|
||||
return () -> {
|
||||
if (response.isCommitted()) return null;
|
||||
try {
|
||||
Path image = ImageFileCache.get(directory, name, () -> {
|
||||
GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid);
|
||||
ImageKeyCache imageKey = imageCacheMapper.selectImageKeyCacheByGidAndPage(gid, page);
|
||||
if (gidToKey == null || imageKey == null)
|
||||
throw new IOException("图片索引不存在");
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
String url = GalleryUtil.getImageUrl(getMpvKey(gidToKey.toUrl()), imageKey);
|
||||
if (url != null) return url;
|
||||
GalleryUtil.refreshMpvKey(gidToKey.toUrl());
|
||||
}
|
||||
throw new IOException("无法获取图片地址");
|
||||
});
|
||||
FileDownload.export(request, response, image.toString());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (!response.isCommitted()) response.sendError(503);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取在线图片失败: gid={} page={} errorType={}", gid, page, e.getClass().getSimpleName());
|
||||
if (!response.isCommitted()) response.sendError(404);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
public String resetUndone(){
|
||||
@@ -622,22 +517,29 @@ public class GalleryManageService {
|
||||
|
||||
Gallery[] galleries = galleryMapper.selectUnDoneGalleries();
|
||||
if(galleries != null && galleries.length != 0) {
|
||||
log.info("发送未下载完成本子至节点,{}本", galleries.length);
|
||||
log.info("发送未下载完成图片至节点,{}本", galleries.length);
|
||||
for (Gallery gallery : galleries)
|
||||
remoteService.addGalleryToQueue(gallery, gallery.getMode());
|
||||
response.success(String.format("发送未下载完成本子至节点,%s本", galleries.length));
|
||||
remoteService.addGalleryToQueue(gallery);
|
||||
response.success(String.format("发送未下载完成图片至节点,%s本", galleries.length));
|
||||
}else{
|
||||
response.failure("当前没有未下载完成的本子");
|
||||
response.failure("当前没有未下载完成的图片");
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public static Integer parseGid(String link){
|
||||
try {
|
||||
return Integer.parseInt(link.split("/g/")[1].split("/")[0]);
|
||||
}catch (IndexOutOfBoundsException e){
|
||||
return null;
|
||||
}
|
||||
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,299 +0,0 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.CustomConfigurationMapper;
|
||||
import com.lion.lionwebsite.Dao.GalleryMapper;
|
||||
import com.lion.lionwebsite.Dao.ShareFileMapper;
|
||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||
import com.lion.lionwebsite.Domain.ShareFile;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import lombok.Data;
|
||||
import org.apache.http.HttpEntity;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
import static java.nio.file.FileVisitResult.CONTINUE;
|
||||
|
||||
@Service
|
||||
@Data
|
||||
public class LocalServiceImpl{
|
||||
String fires = "https://api.dler.io/sub?target=clash&new_name=true&url=https%3A%2F%2Ffast.losadhwselfff2332dasd.xyz%2Flink%2Fz0pfwyTvC5naXkbb%3Fclash%3D1&insert=false&config=https%3A%2F%2Fraw.githubusercontent.com%2FACL4SSR%2FACL4SSR%2Fmaster%2FClash%2Fconfig%2FACL4SSR_Online.ini";
|
||||
|
||||
String DouNaiClash = "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta";
|
||||
|
||||
String DouNaiV2ray = "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2";
|
||||
|
||||
CustomConfigurationMapper configurationMapper;
|
||||
|
||||
ShareFileMapper shareFIleMapper;
|
||||
|
||||
GalleryMapper galleryMapper;
|
||||
|
||||
public LocalServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, GalleryMapper galleryMapper){
|
||||
this.shareFIleMapper = shareFileMapper;
|
||||
this.configurationMapper = configurationMapper;
|
||||
this.galleryMapper = galleryMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每周周一四点重置额度
|
||||
*/
|
||||
@Scheduled(cron = "0 0 4 * * MON")
|
||||
public void reset() {
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, "0");
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME, CustomUtil.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时更新订阅
|
||||
* @throws IOException 下载以及保存异常
|
||||
*/
|
||||
@Scheduled(fixedRate = 86400000)
|
||||
public void updateSubScheduler() throws IOException {
|
||||
updateSub(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新订阅链接的实际方法
|
||||
*/
|
||||
public boolean updateSub(boolean isManual) throws IOException {
|
||||
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
|
||||
CustomConfiguration customConfiguration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
|
||||
|
||||
//如果不是手动,则判断更新间隔是否满足,不满足则取消更新
|
||||
if (!isManual) {
|
||||
LocalDateTime lastUpdate = LocalDateTime.parse(customConfiguration.getValue(), CustomUtil.dateTimeFormatter());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
now = now.plusHours(-3);
|
||||
if(now.isBefore(lastUpdate))
|
||||
return false;
|
||||
}
|
||||
|
||||
File mixin = new File("sub/sub.txt");
|
||||
File firesFile = new File("sub/fires.txt");
|
||||
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> fires_profile = null;
|
||||
List<String> DouNaiClash_profile = null;
|
||||
OutputStream outputStream;
|
||||
|
||||
//下载薯条订阅
|
||||
try(FileWriter writer = new FileWriter(firesFile)) {
|
||||
fires_profile = Get(fires);
|
||||
for (String i : fires_profile)
|
||||
writer.write(i + "\n");
|
||||
|
||||
System.out.println("load fires complete");
|
||||
}catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
System.out.println("load fires failure");
|
||||
}
|
||||
|
||||
//下载豆奶v2ray订阅
|
||||
try(FileWriter writer = new FileWriter(DouNaiV2rayFile)) {
|
||||
String DouNaiV2rayRaw = Get(DouNaiV2ray).get(0);
|
||||
String[] v2rayPlain = new String(Base64.getDecoder().decode(DouNaiV2rayRaw)).split("\n");
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
//过滤高倍率节点
|
||||
for(String node: v2rayPlain){
|
||||
String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8);
|
||||
if(name.startsWith("①") && name.contains("流量")){
|
||||
float ratio = Float.parseFloat(name.substring(name.indexOf("(") + 1, name.indexOf(")")).replace("倍流量", ""));
|
||||
if(ratio <= 1)
|
||||
stringBuilder.append(node).append("\n");
|
||||
}
|
||||
else{
|
||||
stringBuilder.append(node).append("\n");
|
||||
}
|
||||
}
|
||||
writer.write(new String(Base64.getEncoder().encode(stringBuilder.toString().getBytes(StandardCharsets.UTF_8))));
|
||||
|
||||
System.out.println("load DouNai v2ray complete");
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
System.out.println("load DouNai v2ray failure");
|
||||
}
|
||||
|
||||
//下载豆奶clash订阅
|
||||
try(FileWriter writer = new FileWriter(DouNaiClashFile)) {
|
||||
DouNaiClash_profile = Get(DouNaiClash);
|
||||
//过滤高倍率节点
|
||||
ArrayList<String> clashProcessed = new ArrayList<>();
|
||||
boolean isProxies = false;
|
||||
boolean skip = false;
|
||||
for(String line: DouNaiClash_profile){
|
||||
if(line.equals("proxies:"))
|
||||
isProxies = true;
|
||||
else if(line.equals("proxy-groups:") && isProxies)
|
||||
isProxies = false;
|
||||
|
||||
if(isProxies) {
|
||||
if (line.contains("name"))
|
||||
skip = line.contains("流量");
|
||||
if (!skip)
|
||||
clashProcessed.add(line);
|
||||
}
|
||||
else
|
||||
if (!line.contains("流量"))
|
||||
clashProcessed.add(line);
|
||||
}
|
||||
|
||||
for(String line: clashProcessed)
|
||||
writer.write(line + "\n");
|
||||
System.out.println("load DouNai clash complete");
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
System.out.println("load DouNai clash failure");
|
||||
}
|
||||
|
||||
//处理合并
|
||||
assert DouNaiClash_profile != null;
|
||||
int side_start = DouNaiClash_profile.indexOf("proxies:");
|
||||
int side_end = DouNaiClash_profile.indexOf("proxy-groups:");
|
||||
|
||||
List<String> side_proxy = new ArrayList<>();
|
||||
List<String> side_name = new ArrayList<>();
|
||||
for (int i = side_start + 1; i < side_end; i++) {
|
||||
side_proxy.add(DouNaiClash_profile.get(i));
|
||||
if (DouNaiClash_profile.get(i).contains("name"))
|
||||
side_name.add(DouNaiClash_profile.get(i).replace("name:", ""));
|
||||
}
|
||||
|
||||
List<String> final_profile = new ArrayList<>();
|
||||
assert fires_profile != null;
|
||||
for (String i : fires_profile) {
|
||||
if (i.equals("proxy-groups:"))
|
||||
final_profile.addAll(side_proxy);
|
||||
|
||||
final_profile.add(i);
|
||||
if (i.equals("proxy-groups:")) {
|
||||
final_profile.add(" -");
|
||||
final_profile.add(" name: e站流量");
|
||||
final_profile.add(" type: select");
|
||||
final_profile.add(" proxies:");
|
||||
for (String x : side_name)
|
||||
final_profile.add(" " + x);
|
||||
|
||||
}
|
||||
if (i.equals("rules:"))
|
||||
final_profile.add(" - DOMAIN-KEYWORD,hentai,e站流量");
|
||||
}
|
||||
|
||||
outputStream = Files.newOutputStream(mixin.toPath());
|
||||
for(String i: final_profile)
|
||||
outputStream.write((i + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.close();
|
||||
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME, dateTimeFormatter.format(LocalDateTime.now()));
|
||||
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,368 +0,0 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.lion.lionwebsite.Dao.CustomConfigurationMapper;
|
||||
import com.lion.lionwebsite.Dao.ShareFileMapper;
|
||||
import com.lion.lionwebsite.Dao.UserMapper;
|
||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||
import com.lion.lionwebsite.Domain.ShareFile;
|
||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Util.FileDownload;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.ArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
|
||||
import org.apache.tomcat.util.http.fileupload.IOUtils;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
|
||||
@Service
|
||||
@Data
|
||||
@Slf4j
|
||||
public class PersonalServiceImpl{
|
||||
@Resource
|
||||
CustomConfigurationMapper configurationMapper;
|
||||
|
||||
@Resource
|
||||
UserMapper userMapper;
|
||||
|
||||
@Resource
|
||||
ShareFileMapper shareFileMapper;
|
||||
|
||||
@Resource
|
||||
TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
String StoragePath = "/storage/";
|
||||
|
||||
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
|
||||
|
||||
ExecutorService compressThreadPool;
|
||||
|
||||
public PersonalServiceImpl(CustomConfigurationMapper configurationMapper, UserMapper userMapper, ShareFileMapper shareFileMapper, TaskHandlerInterceptor taskHandlerInterceptor){
|
||||
this.configurationMapper = configurationMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.shareFileMapper = shareFileMapper;
|
||||
this.taskHandlerInterceptor = taskHandlerInterceptor;
|
||||
compressThreadPool = Executors.newFixedThreadPool(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件列表,同时带上分享码以及过期时间
|
||||
* @param path 路径
|
||||
* @return 文件列表
|
||||
*/
|
||||
public String getFiles(String path) {
|
||||
Response response = Response.generateResponse();
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
File root_file = new File(StoragePath + path);
|
||||
Calendar now = Calendar.getInstance();
|
||||
Calendar expireTime = Calendar.getInstance();
|
||||
|
||||
//如果目标路径是文件夹
|
||||
if(root_file.isDirectory()) {
|
||||
ArrayList<Map<String, String>> fileMaps = new ArrayList<>();
|
||||
File[] originalFiles = root_file.listFiles();
|
||||
|
||||
//如果文件夹里的文件不为空
|
||||
if (originalFiles != null) {
|
||||
originalFiles = Arrays.stream(originalFiles).sorted(Comparator.comparing(File::getName)).toArray(File[]::new);
|
||||
ArrayList<ShareFile> shareFiles = shareFileMapper.selectShareFilesByFilePath(root_file.getAbsolutePath());
|
||||
|
||||
//遍历文件,放入文件信息以及查询对应的分享码
|
||||
for (File file : originalFiles) {
|
||||
Map<String, String> fileMap = new LinkedHashMap<>();
|
||||
fileMap.put("name", file.getName());
|
||||
fileMap.put("path", file.getAbsolutePath());
|
||||
|
||||
if (file.isDirectory()) {
|
||||
fileMap.put("type", "FOLDER");
|
||||
} else if (file.isFile()) {
|
||||
fileMap.put("type", "FILE"); //处理文件大小单位
|
||||
String fileSize = CustomUtil.fileSizeToString(file.length());
|
||||
fileMap.put("size", fileSize);
|
||||
|
||||
Iterator<ShareFile> iterator = shareFiles.iterator();
|
||||
while(iterator.hasNext()){
|
||||
ShareFile shareFile = iterator.next();
|
||||
if(shareFile.getFilePath().equals(file.getAbsolutePath())){
|
||||
expireTime.setTime(shareFile.getExpireTime());
|
||||
if(now.after(expireTime)){
|
||||
shareFileMapper.deleteShareFile(shareFile.getShareCode());
|
||||
}
|
||||
else {
|
||||
fileMap.put("shareCode", shareFile.getShareCode());
|
||||
fileMap.put("expireTime", dateTimeFormatter.format(shareFile.getExpireTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()));
|
||||
}
|
||||
iterator.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
fileMaps.add(fileMap);
|
||||
}
|
||||
response.success(new ObjectMapper().valueToTree(fileMaps).toString());
|
||||
}
|
||||
else
|
||||
response.failure("文件夹为空");
|
||||
}
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param request 请求对象
|
||||
* @param response 响应对象
|
||||
* @param path 目标路径
|
||||
*/
|
||||
public void download(HttpServletRequest request, HttpServletResponse response, String path){
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
File file = new File(path);
|
||||
if(file.exists())
|
||||
FileDownload.export(request, response, path);
|
||||
|
||||
else
|
||||
try{
|
||||
response.getWriter().print("404 NOT FOUND");
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param path 目标路径
|
||||
* @param fileName 文件名称
|
||||
* @param file 文件对象
|
||||
* @return 上传结果
|
||||
*/
|
||||
public String uploadFile(String path, String fileName, MultipartFile file) {
|
||||
Response response = Response.generateResponse();
|
||||
log.info("上传文件:{}, 目标路径:{}", fileName, path);
|
||||
|
||||
if(path == null || fileName == null || file == null){
|
||||
response.failure("参数不完整");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
File directory = new File(StoragePath + path);
|
||||
if(directory.isDirectory()){
|
||||
File targetFile = new File(StoragePath + path, fileName);
|
||||
|
||||
if(targetFile.exists())
|
||||
response.failure("目标文件已存在");
|
||||
else
|
||||
try {
|
||||
file.transferTo(Path.of(StoragePath + path, fileName));
|
||||
response.success("上传成功");
|
||||
} catch (IOException e) {
|
||||
response.failure("上传失败");
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
else
|
||||
response.failure("该路径不存在或者不是文件夹");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分享码
|
||||
* @param path 目标路径
|
||||
* @param expireHour 过期时间
|
||||
* @return 如果成功则是分享码以及过期时间,失败则是失败原因
|
||||
*/
|
||||
public String shareFile(String path, Integer expireHour) {
|
||||
Response response = Response.generateResponse();
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
Map<String, String> jsonObject = new HashMap<>();
|
||||
File file = new File(path);
|
||||
if(file.isFile()){ //如果是文件,则生成分享码,调用此接口时不需要考虑分享已分享文件以及分享已过期文件
|
||||
String ShareCode;
|
||||
ShareCode = RandomUtil.randomString(8);
|
||||
Calendar expireTime = Calendar.getInstance();
|
||||
|
||||
expireTime.add(Calendar.HOUR, expireHour);
|
||||
shareFileMapper.insertShareFile(ShareCode, path, expireTime.getTime());
|
||||
jsonObject.put("shareCode", ShareCode);
|
||||
jsonObject.put("expireTime", dateTimeFormatter.format(expireTime.getTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()));
|
||||
response.success(new ObjectMapper().valueToTree(jsonObject).toString());
|
||||
}
|
||||
else
|
||||
response.failure("此路径为文件夹或不存在");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 延长分享时间
|
||||
* @param path 目标文件路径
|
||||
* @param extendHour 延长小时数
|
||||
* @return 延长结果
|
||||
*/
|
||||
public String extendShareTime(String path, Integer extendHour) {
|
||||
Response response = Response.generateResponse();
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
Map<String, String> data = new LinkedHashMap<>();
|
||||
ShareFile shareFile = shareFileMapper.selectShareFileByFilePath(path);
|
||||
|
||||
if(shareFile != null){
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(shareFile.getExpireTime());
|
||||
|
||||
calendar.add(Calendar.HOUR, extendHour);
|
||||
shareFile.setExpireTime(calendar.getTime());
|
||||
shareFileMapper.updateShareFile(shareFile);
|
||||
data.put("expireTime", dateTimeFormatter.format(shareFile.getExpireTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()));
|
||||
data.put("path", shareFile.getFilePath());
|
||||
|
||||
response.success(new ObjectMapper().valueToTree(data).toString());
|
||||
}
|
||||
else{
|
||||
response.failure("该文件未被分享");
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消分享
|
||||
* @param path 目标路径
|
||||
* @return 取消结果
|
||||
*/
|
||||
public String cancelShare(String path) {
|
||||
Response response = Response.generateResponse();
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
ShareFile shareFile = shareFileMapper.selectShareFileByFilePath(path);
|
||||
|
||||
if(shareFile != null){
|
||||
shareFileMapper.deleteShareFile(shareFile.getShareCode());
|
||||
response.success("取消分享成功");
|
||||
}
|
||||
else{
|
||||
response.failure("该文件未被分享");
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订阅文件上次更新时间
|
||||
* @return 订阅文件上次更新时间
|
||||
*/
|
||||
public String lastUpdate() {
|
||||
Response response = Response.generateResponse();
|
||||
CustomConfiguration configuration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
|
||||
response.success(configuration.getValue());
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取家里的ip
|
||||
* @return 家里的ip
|
||||
*/
|
||||
public String getIp(){
|
||||
Response response = Response.generateResponse();
|
||||
Map<String, String> jsonObject = new HashMap<>();
|
||||
String ip = configurationMapper.selectConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS).getValue();
|
||||
String updateTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME).getValue();
|
||||
|
||||
jsonObject.put("ip", ip);
|
||||
jsonObject.put("lastUpdateTime", updateTime);
|
||||
response.success(new ObjectMapper().valueToTree(jsonObject).toString().replace("\"", " ").replace("\\", " "));
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包文件
|
||||
* @param path 目标路径
|
||||
* @return 响应提交成功,因为该方法为异步执行,未完成时后辍为undone
|
||||
*/
|
||||
public String compress(String path) {
|
||||
Response response = Response.generateResponse();
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
File file = new File(path);
|
||||
String finalPath = path;
|
||||
|
||||
if(!file.isDirectory() || !file.exists()){
|
||||
response.failure("选中的路径不是文件夹");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
compressThreadPool.submit(() -> {
|
||||
try(OutputStream bos = new BufferedOutputStream(Files.newOutputStream(Paths.get(finalPath + ".tar***undone")));
|
||||
TarArchiveOutputStream aos = new TarArchiveOutputStream(bos)) {
|
||||
aos.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU); //解除文件名长度限制
|
||||
Path dirPath = Paths.get(finalPath);
|
||||
Files.walkFileTree(dirPath, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
|
||||
ArchiveEntry entry = new TarArchiveEntry(dir.toFile(), dirPath.relativize(dir).toString());
|
||||
aos.putArchiveEntry(entry);
|
||||
aos.closeArchiveEntry();
|
||||
return super.preVisitDirectory(dir, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||
ArchiveEntry entry = new TarArchiveEntry(file.toFile(), dirPath.relativize(file).toString());
|
||||
aos.putArchiveEntry(entry);
|
||||
IOUtils.copy(Files.newInputStream(file.toFile().toPath()), aos);
|
||||
aos.closeArchiveEntry();
|
||||
return super.visitFile(file, attrs);
|
||||
}
|
||||
});
|
||||
File targetFile = new File(finalPath + ".tar***undone");
|
||||
log.info("打包成功,重命名:" + targetFile.renameTo(new File(finalPath + ".tar")));
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
log.info("打包失败,删除文件结果:" + new File(finalPath + ".tar***undone").delete());
|
||||
}
|
||||
});
|
||||
response.success("加入队列成功");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param path 目标路径
|
||||
* @return 删除结果
|
||||
*/
|
||||
public String deleteFile(String path) {
|
||||
Response response = Response.generateResponse();
|
||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
||||
File file = new File(path);
|
||||
|
||||
if(FileUtil.del(file))
|
||||
response.success("删除成功");
|
||||
else
|
||||
response.failure("删除失败");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||
|
||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||
import com.lion.lionwebsite.Domain.User;
|
||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PublicService {
|
||||
|
||||
final CustomConfigurationMapper configurationMapper;
|
||||
|
||||
final UserMapper userMapper;
|
||||
|
||||
final TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
/**
|
||||
* 记录家里ip地址
|
||||
* @param ip ip地址
|
||||
*/
|
||||
public void logIpAddress(String ip) {
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, ip);
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, CustomUtil.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改授权码,如果能够执行此方法,则授权码一定存在
|
||||
* @param AuthCode 原来的授权码
|
||||
* @param newAuthCode 新的授权码
|
||||
* @return 修改结果
|
||||
*/
|
||||
public String alterAuthCode(String AuthCode, String newAuthCode) {
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
userMapper.updateAuthCode(AuthCode, newAuthCode);
|
||||
taskHandlerInterceptor.updateAuthCodes();
|
||||
response.success("修改成功");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public User getUserId(String AuthCode){
|
||||
return userMapper.selectUserByAuthCode(AuthCode);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.CustomConfigurationMapper;
|
||||
|
||||
import com.lion.lionwebsite.Dao.ShareFileMapper;
|
||||
import com.lion.lionwebsite.Dao.UserMapper;
|
||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||
import com.lion.lionwebsite.Domain.ShareFile;
|
||||
import com.lion.lionwebsite.Domain.User;
|
||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Util.FileDownload;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Calendar;
|
||||
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
|
||||
|
||||
|
||||
@Service
|
||||
public class PublicServiceImpl {
|
||||
|
||||
@Resource
|
||||
CustomConfigurationMapper configurationMapper;
|
||||
|
||||
@Resource
|
||||
ShareFileMapper shareFileMapper;
|
||||
|
||||
@Resource
|
||||
UserMapper userMapper;
|
||||
|
||||
@Resource
|
||||
TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
public PublicServiceImpl(CustomConfigurationMapper configurationMapper, ShareFileMapper shareFileMapper, UserMapper userMapper, TaskHandlerInterceptor taskHandlerInterceptor) {
|
||||
this.configurationMapper = configurationMapper;
|
||||
this.shareFileMapper = shareFileMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.taskHandlerInterceptor = taskHandlerInterceptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录家里ip地址
|
||||
* @param ip ip地址
|
||||
*/
|
||||
public void logIpAddress(String ip) {
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, ip);
|
||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, CustomUtil.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过分享码获取文件
|
||||
* @param httpRequest 请求对象
|
||||
* @param httpResponse 响应对象
|
||||
* @param ShareCode 分享码
|
||||
* @throws IOException 响应时的异常
|
||||
*/
|
||||
public boolean GetFile(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String ShareCode) throws IOException {
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
//参数为空的情况
|
||||
if(ShareCode == null) {
|
||||
response.failure("ShareCode invalid");
|
||||
} else {
|
||||
ShareFile shareFile = shareFileMapper.selectShareFileByShareCode(ShareCode);
|
||||
Calendar ExpireTime = Calendar.getInstance();
|
||||
Calendar now = Calendar.getInstance();
|
||||
|
||||
if (shareFile != null) {
|
||||
ExpireTime.setTime(shareFile.getExpireTime());
|
||||
if (ExpireTime.after(now) && new File(shareFile.getFilePath()).isFile()) {
|
||||
FileDownload.export(httpRequest, httpResponse, shareFile.getFilePath());
|
||||
return true;
|
||||
} else {
|
||||
shareFileMapper.deleteShareFile(shareFile.getShareCode());
|
||||
response.failure("ShareCode is expired or File is not exist");
|
||||
}
|
||||
} else
|
||||
response.failure("ShareCode is not exist or expired");
|
||||
}
|
||||
|
||||
httpResponse.getOutputStream().write(response.toJSONString().getBytes(StandardCharsets.UTF_8));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改授权码,如果能够执行此方法,则授权码一定存在
|
||||
* @param AuthCode 原来的授权码
|
||||
* @param newAuthCode 新的授权码
|
||||
* @return 修改结果
|
||||
*/
|
||||
public String alterAuthCode(String AuthCode, String newAuthCode) {
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
userMapper.updateAuthCode(AuthCode, newAuthCode);
|
||||
taskHandlerInterceptor.updateAuthCodes();
|
||||
response.success("修改成功");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public User getUserId(String AuthCode){
|
||||
return userMapper.selectUserByAuthCode(AuthCode);
|
||||
}
|
||||
|
||||
public void getEhThumbnail(String path, HttpServletResponse response){
|
||||
String url;
|
||||
if(!path.contains("/")){
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
|
||||
url = "https://ehgt.org/" + path;
|
||||
try{
|
||||
byte[] imageBytes = getImageBytesFromUrl(url);
|
||||
ServletOutputStream outputStream = response.getOutputStream();
|
||||
outputStream.write(imageBytes);
|
||||
outputStream.close();
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
try {
|
||||
response.sendError(503);
|
||||
}catch (IOException ex){
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] getImageBytesFromUrl(String imageUrl) throws IOException {
|
||||
URL url = new URL(imageUrl);
|
||||
InputStream inputStream = null;
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
// 打开URL连接
|
||||
inputStream = url.openStream();
|
||||
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead;
|
||||
// 从输入流读取数据并写入输出流
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} finally {
|
||||
// 关闭流
|
||||
if (inputStream != null) {
|
||||
inputStream.close();
|
||||
}
|
||||
outputStream.close();
|
||||
}
|
||||
|
||||
// 返回图片的字节数组
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,37 @@ 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.UpdatesListener;
|
||||
import com.pengrad.telegrambot.model.Update;
|
||||
import com.pengrad.telegrambot.request.SendMessage;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PushService {
|
||||
|
||||
long self = 686839482;
|
||||
|
||||
@Resource
|
||||
TelegramBot bot;
|
||||
private static final DateTimeFormatter COMPLETION_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
public PushService(TelegramBot bot) {
|
||||
this.bot = bot;
|
||||
}
|
||||
final TelegramBot bot;
|
||||
|
||||
public void taskCreateReport(String username, String link, Response response){
|
||||
public void taskCreateReport(String username, String taskName, Response response){
|
||||
if(response.isSuccess())
|
||||
sendToMe(String.format("用户%s提交下载%s", username, link));
|
||||
sendToMe(String.format("用户%s提交下载任务:%s", username, taskName));
|
||||
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){
|
||||
sendToMe(gallery.getLink() + " 下载完成");
|
||||
String completionTime = COMPLETION_TIME_FORMATTER.format(Instant.now());
|
||||
sendToMe(String.format("任务下载完成:%s\n完成时间:%s", gallery.getName(), completionTime));
|
||||
}
|
||||
|
||||
public void storageNodeOnline(){
|
||||
@@ -44,7 +45,7 @@ public class PushService {
|
||||
}
|
||||
|
||||
public void sendToMe(String text){
|
||||
System.out.println(text);
|
||||
log.info(text);
|
||||
SendMessage sendMessage = new SendMessage(self, text);
|
||||
bot.execute(sendMessage);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Domain.GalleryForQuery;
|
||||
import com.lion.lionwebsite.Util.FileDownload;
|
||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||
import com.lion.lionwebsite.Util.ImageFileCache;
|
||||
import java.nio.file.Path;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.http.HttpEntity;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
@@ -17,36 +17,45 @@ import org.jsoup.select.Elements;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.fourZeroFour;
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class QueryService {
|
||||
|
||||
String CachePath = "/storage/hentaiCache/";
|
||||
String CachePath = "/storage/galleryCache/thumbnails/";
|
||||
|
||||
public String query(String keyword, String prev, String next) {
|
||||
Response response = Response.generateResponse();
|
||||
String get;
|
||||
String param = "?f_search=" + keyword.replace(" ", "+");
|
||||
String result;
|
||||
// 关键词来自用户输入,必须编码后再拼进查询串:未编码的 &、#、中文、空格
|
||||
// 会被上游当成额外的查询参数或截断,搜索行为不可预期。
|
||||
String param = "?f_search=" + URLEncoder.encode(keyword == null ? "" : keyword, StandardCharsets.UTF_8)
|
||||
+ "&f_sft=on&f_sfu=on&f_sfl=on";
|
||||
|
||||
if(prev != null)
|
||||
param += "&prev=" + prev;
|
||||
param += "&prev=" + URLEncoder.encode(prev, StandardCharsets.UTF_8);
|
||||
else if(next != null)
|
||||
param += "&next=" + next;
|
||||
param += "&next=" + URLEncoder.encode(next, StandardCharsets.UTF_8);
|
||||
|
||||
try{
|
||||
get = requests("https://exhentai.org/" + param, false, null, null);
|
||||
result = GalleryUtil.requests("https://exhentai.org/" + param, "get", null, null);
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
log.error("query failure", e);
|
||||
response.failure("query failure");
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
Document parse = Jsoup.parse(get);
|
||||
Document parse = Jsoup.parse(result);
|
||||
Elements elements = parse.select("body > div.ido > div:nth-child(2) > table > tbody > tr");
|
||||
|
||||
|
||||
ArrayList<GalleryForQuery> galleries = new ArrayList<>();
|
||||
ListIterator<Element> elementListIterator = elements.listIterator();
|
||||
try {
|
||||
@@ -67,14 +76,13 @@ public class QueryService {
|
||||
gallery.setThumbnailUrl(src); //thumbnailSrc
|
||||
gallery.setUploadTime(element.child(1).select("div > div [onclick]").get(1).text()); //uploadTime
|
||||
gallery.setLink(element.child(2).child(0).attr("href")); //link
|
||||
gallery.setGid(GalleryUtil.parseGid(gallery.getLink()) + ""); //gid
|
||||
gallery.setName(element.child(2).child(0).child(0).text()); //name
|
||||
gallery.setPage(Integer.parseInt(element.child(3).child(1).text().split(" ")[0])); //page
|
||||
|
||||
galleries.add(gallery);
|
||||
}
|
||||
|
||||
|
||||
response.success(new ObjectMapper().valueToTree(galleries).toString());
|
||||
response.success(objectMapper.valueToTree(galleries).toString());
|
||||
Elements nextLink = parse.select("#unext");
|
||||
if(nextLink.hasAttr("href"))
|
||||
response.set("next", nextLink.attr("href"));
|
||||
@@ -94,81 +102,24 @@ public class QueryService {
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public void image(HttpServletResponse response, String path){
|
||||
String imageName = path.substring(path.lastIndexOf("/") + 1);
|
||||
File image = new File(CachePath, imageName);
|
||||
if(image.isFile()){ //hit cache
|
||||
log.info("hit cache:{}", imageName);
|
||||
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(image));
|
||||
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(response.getOutputStream())){
|
||||
bufferedInputStream.transferTo(bufferedOutputStream);
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
public void getEhThumbnail(String path, HttpServletRequest request, HttpServletResponse response){
|
||||
if(!path.contains("/")){
|
||||
fourZeroFour(response);
|
||||
return;
|
||||
}
|
||||
else{ // transfer image and save it as cache
|
||||
log.info("miss cache:{}", imageName);
|
||||
try {
|
||||
requests("https://s.exhentai.org" + path, true, response, new FileOutputStream(image));
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String requests(String url, boolean isDirect, HttpServletResponse response, OutputStream local) throws IOException {
|
||||
CloseableHttpClient httpClient = HttpClients.createDefault();
|
||||
CloseableHttpResponse httpResponse;
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
|
||||
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36");
|
||||
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
|
||||
headers.put("Upgrade-Insecure-Requests", "1");
|
||||
headers.put("Cookie", "ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; igneous=cea2e08fb; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1");
|
||||
|
||||
HttpGet httpGet = new HttpGet(url);
|
||||
for (Map.Entry<String, String> header: headers.entrySet()){
|
||||
httpGet.addHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
|
||||
httpResponse = httpClient.execute(httpGet);
|
||||
|
||||
HttpEntity responseEntity = httpResponse.getEntity();
|
||||
int statusCode = httpResponse.getStatusLine().getStatusCode();
|
||||
|
||||
String fileName = path.substring(path.lastIndexOf("/") + 1);
|
||||
String suffix = fileName.substring(fileName.lastIndexOf("."));
|
||||
fileName = fileName.substring(0, fileName.lastIndexOf("."));
|
||||
String sourceUrl = "https://ehgt.org/" + path;
|
||||
try {
|
||||
if (statusCode == 200) {
|
||||
if (isDirect) {
|
||||
InputStream inputStream = new BufferedInputStream(responseEntity.getContent());
|
||||
byte[] bytes = inputStream.readAllBytes();
|
||||
if(response != null) {
|
||||
OutputStream outputStream = response.getOutputStream();
|
||||
outputStream.write(bytes);
|
||||
outputStream.close();
|
||||
}
|
||||
if(local != null){
|
||||
local.write(bytes);
|
||||
local.close();
|
||||
}
|
||||
inputStream.close();
|
||||
return null;
|
||||
} else {
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
String str;
|
||||
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
|
||||
while ((str = bufferedReader.readLine()) != null) {
|
||||
stringBuilder.append(str).append("\n");
|
||||
}
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
} else {
|
||||
System.out.println(statusCode);
|
||||
return null;
|
||||
}
|
||||
}finally {
|
||||
httpClient.close();
|
||||
httpResponse.close();
|
||||
Path image = ImageFileCache.get(Path.of(CachePath), fileName, () -> sourceUrl);
|
||||
FileDownload.export(request, response, image.toString());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
response.setStatus(503);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取缩略图失败: errorType={}", e.getClass().getSimpleName());
|
||||
if (!response.isCommitted()) response.setStatus(404);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.GalleryMapper;
|
||||
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
||||
import com.lion.lionwebsite.Domain.Gallery;
|
||||
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||
import com.lion.lionwebsite.Message.*;
|
||||
@@ -12,276 +12,417 @@ import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.channel.socket.nio.NioSocketChannel;
|
||||
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
|
||||
import io.netty.handler.logging.LoggingHandler;
|
||||
import io.netty.handler.logging.ByteBufFormat;
|
||||
import io.netty.util.concurrent.DefaultPromise;
|
||||
import io.netty.util.concurrent.Promise;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@Service
|
||||
@Data
|
||||
@Slf4j
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
AtomicInteger atomicInteger;
|
||||
AtomicInteger atomicInteger = new AtomicInteger(0);
|
||||
|
||||
public RemoteService(GalleryMapper galleryMapper, PushService pushService){
|
||||
this.galleryMapper = galleryMapper;
|
||||
this.pushService = pushService;
|
||||
atomicInteger = new AtomicInteger(0);
|
||||
eventLoopGroup = new DefaultEventLoop();
|
||||
downloadThread = Executors.newCachedThreadPool();
|
||||
promiseHashMap = new HashMap<>();
|
||||
final WebSocketService webSocketService;
|
||||
|
||||
if(!initChannel()){ //如果远程服务器连接失败,则开启本地监听
|
||||
monitor = new Thread(this::monitorFunc);
|
||||
monitor.start();
|
||||
final SubscriptionStandbySnapshotService subscriptionStandbySnapshotService;
|
||||
|
||||
final ExecutorService subscriptionSyncExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "subscription-standby-sync");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理节点上报的任务状态。原先这些 JDBC 读写直接跑在 Netty 的 IO 线程上,
|
||||
* N 条上报会阻塞该 IO 线程,连带拖慢心跳与请求响应;改为单线程顺序执行,
|
||||
* 既不打乱「按上报顺序覆盖状态」的语义,也避免并发写库。
|
||||
*/
|
||||
ExecutorService statusApplyExecutor = newStatusApplyExecutor();
|
||||
|
||||
private static ExecutorService newStatusApplyExecutor() {
|
||||
return Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "gallery-status-apply");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试钩子:等待已提交的状态处理任务全部执行完毕。
|
||||
*
|
||||
* <p>单线程执行器天然有序,投递一个空任务并等它跑完,即说明此前提交的
|
||||
* 任务都已处理完,从而让「上报后落库」的断言保持确定性。
|
||||
*/
|
||||
void awaitStatusApplied() {
|
||||
try {
|
||||
statusApplyExecutor.submit(() -> { }).get(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.debug("等待状态处理完成失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean();
|
||||
|
||||
final AtomicBoolean subscriptionSyncRunning = new AtomicBoolean();
|
||||
|
||||
@Value("${subscription.standby.sync-enabled:false}")
|
||||
boolean subscriptionSyncEnabled;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
initChannel();
|
||||
}
|
||||
|
||||
public boolean initChannel(){
|
||||
if (stopping || !connecting.compareAndSet(false, true))
|
||||
return !isDead();
|
||||
try {
|
||||
channelFuture = new Bootstrap()
|
||||
.channel(NioSocketChannel.class)
|
||||
.group(new NioEventLoopGroup())
|
||||
.handler(new ChannelInitializer<NioSocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(NioSocketChannel channel){
|
||||
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
|
||||
channel.pipeline().addLast(new MessageCodec());
|
||||
channel.pipeline().addLast(new LoggingHandler());
|
||||
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
|
||||
}
|
||||
}).connect(new InetSocketAddress(ip, port)).sync();
|
||||
if (!isDead())
|
||||
return true;
|
||||
int i;
|
||||
for(i=0; i<20; i++) {
|
||||
try {
|
||||
channelFuture = new Bootstrap()
|
||||
.channel(NioSocketChannel.class)
|
||||
.group(networkGroup)
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000)
|
||||
.handler(new ChannelInitializer<NioSocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(NioSocketChannel channel) {
|
||||
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
|
||||
channel.pipeline().addLast(new MessageCodec());
|
||||
// 只记录事件和字节数,避免把订阅正文、签名等消息内容写入日志。
|
||||
channel.pipeline().addLast(new LoggingHandler(io.netty.handler.logging.LogLevel.DEBUG, ByteBufFormat.SIMPLE));
|
||||
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
|
||||
}
|
||||
}).connect(new InetSocketAddress(ip, port + i)).sync();
|
||||
break;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.error("连接storageNode失败,端口偏移量(重试次数):{}", i);
|
||||
}
|
||||
if (stopping)
|
||||
return false;
|
||||
}
|
||||
|
||||
//超过二十次连不上,主动抛出错误,由下方catch
|
||||
if(i==20) {
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
if (stopping) {
|
||||
channelFuture.channel().close();
|
||||
return false;
|
||||
}
|
||||
log.info("connect success");
|
||||
if(pushService != null)
|
||||
pushService.storageNodeOnline();
|
||||
|
||||
channel = channelFuture.channel();
|
||||
closeMonitorSocket();
|
||||
channel.writeAndFlush(new IdentityMessage("lionwebsite"));
|
||||
|
||||
//子节点上线时,发送未完成的任务
|
||||
resetUndone();
|
||||
// 节点刚上线时可能没有任何快照(例如刚重启),必须推一次;
|
||||
// 这同时会刷新节点「最近收到快照」的时刻,避免内容未变时被判过期。
|
||||
requestSubscriptionSync();
|
||||
return true;
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
log.info("connect node failed, wait for node back online");
|
||||
log.error("connect node failed, wait for node back online", e);
|
||||
return false;
|
||||
} finally {
|
||||
connecting.set(false);
|
||||
if (isDead())
|
||||
startMonitor();
|
||||
}
|
||||
}
|
||||
|
||||
public byte reconnect(){
|
||||
//如果当前就是未连接状态,直接返回-2
|
||||
if (isDead()){
|
||||
return -2;
|
||||
}
|
||||
|
||||
channelFuture.channel().close().awaitUninterruptibly();
|
||||
|
||||
if(initChannel()){
|
||||
return 0;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public byte checkAvailability(){
|
||||
return sendRequest(new AvailableCheckMessage(), 10, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
byte sendRequest(AbstractMessage message, long timeout, TimeUnit unit) {
|
||||
Channel target = channel;
|
||||
if (stopping || target == null || !target.isActive())
|
||||
return -1;
|
||||
message.setMessageId(atomicInteger.getAndIncrement());
|
||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||
promiseHashMap.put(message.messageId, promise);
|
||||
try {
|
||||
target.writeAndFlush(message).addListener(future -> {
|
||||
if (!future.isSuccess())
|
||||
promise.tryFailure(future.cause() == null ? new IOException("节点发送失败") : future.cause());
|
||||
});
|
||||
if (promise.await(timeout, unit) && promise.isSuccess()
|
||||
&& promise.getNow() instanceof ResponseMessage response)
|
||||
return response.getResult();
|
||||
return -1;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return -1;
|
||||
} catch (Exception e) {
|
||||
log.warn("节点请求失败: messageId={}", message.messageId, e);
|
||||
return -1;
|
||||
} finally {
|
||||
promiseHashMap.remove(message.messageId, promise);
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求将当前全部订阅状态异步同步到存储节点,短时间内的多次请求会合并。 */
|
||||
public void requestSubscriptionSync() {
|
||||
if (stopping || !subscriptionSyncEnabled)
|
||||
return;
|
||||
subscriptionSyncQueued.set(true);
|
||||
if (!subscriptionSyncRunning.compareAndSet(false, true))
|
||||
return;
|
||||
try {
|
||||
subscriptionSyncExecutor.execute(this::drainSubscriptionSyncQueue);
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
subscriptionSyncRunning.set(false);
|
||||
if (!stopping) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 这里刻意没有「定期重发整份快照」的定时任务:
|
||||
// 快照只在订阅内容变化时推送(各处 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(){
|
||||
return channelFuture.channel() == null || !channelFuture.channel().isActive();
|
||||
return channelFuture == null || channelFuture.channel() == null || !channelFuture.channel().isActive();
|
||||
}
|
||||
|
||||
public byte addGalleryToQueue(Gallery gallery, byte type){
|
||||
public void resetUndone(){
|
||||
if (channelFuture.channel() == null || !channelFuture.channel().isActive())
|
||||
return;
|
||||
|
||||
Gallery[] galleries = galleryMapper.selectUnDoneGalleries();
|
||||
if(galleries != null && galleries.length != 0) {
|
||||
log.info("发送未下载完成图片至节点,{}本", galleries.length);
|
||||
log.info("{}", Arrays.toString(galleries));
|
||||
for (Gallery gallery : galleries)
|
||||
addGalleryToQueue(gallery);
|
||||
}
|
||||
}
|
||||
|
||||
public byte addGalleryToQueue(Gallery gallery){
|
||||
GalleryTask galleryTask = new GalleryTask();
|
||||
galleryTask.setGid(gallery.getGid());
|
||||
galleryTask.setType(type);
|
||||
galleryTask.setName(gallery.getName());
|
||||
DownloadPostMessage message = new DownloadPostMessage();
|
||||
message.setGalleryTask(galleryTask);
|
||||
return sendRequest(message, 10, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
DownloadPostMessage dpm = new DownloadPostMessage();
|
||||
dpm.messageId = atomicInteger.getAndIncrement();
|
||||
dpm.setGalleryTask(galleryTask);
|
||||
channel.writeAndFlush(dpm);
|
||||
|
||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||
promiseHashMap.put(dpm.messageId, promise);
|
||||
public RetryResult retryGallery(Gallery gallery){
|
||||
CompletableFuture<String> statusFuture = new CompletableFuture<>();
|
||||
retryStatusWaiters.computeIfAbsent(gallery.getGid(), ignored -> new CopyOnWriteArrayList<>())
|
||||
.add(statusFuture);
|
||||
try {
|
||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
||||
if(result){
|
||||
ResponseMessage rsm = (ResponseMessage)promise.getNow();
|
||||
return rsm.getResult();
|
||||
}
|
||||
else return -1;
|
||||
byte submitResult = addGalleryToQueue(gallery);
|
||||
if(submitResult != 0 && !statusFuture.isDone())
|
||||
return new RetryResult(false, "节点未接受重试请求");
|
||||
return new RetryResult(true, statusFuture.get(10, TimeUnit.SECONDS));
|
||||
}catch (TimeoutException e){
|
||||
return new RetryResult(false, "节点已收到重试请求,但未及时返回任务状态");
|
||||
}catch (ExecutionException e){
|
||||
log.warn("等待重试状态失败, gid={}", gallery.getGid(), e);
|
||||
return new RetryResult(false, "获取任务状态失败");
|
||||
}catch (InterruptedException e){
|
||||
e.printStackTrace();
|
||||
return -1;
|
||||
log.warn("等待重试状态被中断, gid={}", gallery.getGid(), e);
|
||||
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, byte type){
|
||||
DeleteGalleryMessage dgm = new DeleteGalleryMessage();
|
||||
dgm.setGalleryName(gallery.getName());
|
||||
dgm.setDeleteType(type);
|
||||
dgm.messageId = atomicInteger.getAndIncrement();
|
||||
|
||||
channel.writeAndFlush(dgm);
|
||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||
promiseHashMap.put(dgm.messageId, promise);
|
||||
try{
|
||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
||||
if(result){
|
||||
ResponseMessage rsm = (ResponseMessage) promise.getNow();
|
||||
return rsm.getResult();
|
||||
}else return -1;
|
||||
}catch (InterruptedException e){
|
||||
return -1;
|
||||
}
|
||||
private void completeRetryStatusWaiters(int gid, String status){
|
||||
CopyOnWriteArrayList<CompletableFuture<String>> waiters = retryStatusWaiters.remove(gid);
|
||||
if(waiters != null)
|
||||
waiters.forEach(waiter -> waiter.complete(status));
|
||||
}
|
||||
|
||||
public byte cachePreview(String galleryName, short page, File file){
|
||||
File parentFile = file.getParentFile();
|
||||
if(!parentFile.isDirectory())
|
||||
if(parentFile.mkdirs())
|
||||
log.info("创建文件夹{}成功", parentFile.getAbsolutePath());
|
||||
else
|
||||
log.error("创建文件夹{}失败", parentFile.getAbsolutePath());
|
||||
public record RetryResult(boolean success, String message) {}
|
||||
|
||||
GalleryRequestMessage grm = new GalleryRequestMessage();
|
||||
grm.setGalleryName(galleryName);
|
||||
grm.setPage(page);
|
||||
|
||||
grm.setType(GalleryRequestMessage.PREVIEW);
|
||||
grm.messageId = atomicInteger.getAndIncrement();
|
||||
DefaultPromise<Object> downloadPromise = new DefaultPromise<>(eventLoopGroup);
|
||||
DefaultPromise<Object> readyPromise = new DefaultPromise<>(eventLoopGroup);
|
||||
|
||||
downloadThread.submit(() -> {
|
||||
try {
|
||||
short port = CustomUtil._findIdlePort();
|
||||
ServerSocketChannel ssChannel = ServerSocketChannel.open();
|
||||
ssChannel.bind(new InetSocketAddress("0.0.0.0", port));
|
||||
grm.setPort(port);
|
||||
readyPromise.setSuccess("");
|
||||
SocketChannel socketChannel = ssChannel.accept();
|
||||
FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
|
||||
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
|
||||
while (socketChannel.read(byteBuffer) != -1){
|
||||
byteBuffer.flip();
|
||||
fileChannel.write(byteBuffer);
|
||||
byteBuffer.clear();
|
||||
}
|
||||
fileChannel.close();
|
||||
socketChannel.close();
|
||||
ssChannel.close();
|
||||
log.info("缓存预览图:" + galleryName + " " + file.getName());
|
||||
downloadPromise.setSuccess("");
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
try{
|
||||
readyPromise.await();
|
||||
channel.writeAndFlush(grm);
|
||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||
promiseHashMap.put(grm.messageId, promise);
|
||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
||||
if(result){
|
||||
ResponseMessage rsm = (ResponseMessage) promise.getNow();
|
||||
if(rsm.getResult() == 0){
|
||||
downloadPromise.await(10, TimeUnit.SECONDS);
|
||||
return (byte) (downloadPromise.isSuccess()?0:1);
|
||||
}else{
|
||||
return rsm.getResult();
|
||||
}
|
||||
}else {
|
||||
return -1;
|
||||
}
|
||||
}catch (InterruptedException e){
|
||||
return -1;
|
||||
}
|
||||
public byte deleteGallery(Gallery gallery){
|
||||
DeleteGalleryMessage message = new DeleteGalleryMessage();
|
||||
message.setGalleryName(gallery.getName());
|
||||
return sendRequest(message, 10, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public String queryPageName(String name, int page){
|
||||
GalleryPageQueryMessage gpqm = new GalleryPageQueryMessage();
|
||||
gpqm.setName(name);
|
||||
gpqm.setPage(page);
|
||||
gpqm.messageId = atomicInteger.getAndIncrement();
|
||||
|
||||
channel.writeAndFlush(gpqm);
|
||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||
promiseHashMap.put(gpqm.messageId, promise);
|
||||
try{
|
||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
||||
if(result){
|
||||
gpqm = (GalleryPageQueryMessage) promise.getNow();
|
||||
if(gpqm.getResult() == 0)
|
||||
return gpqm.getPageName();
|
||||
else {
|
||||
log.error("galleryPageQuery error:" + gpqm.getResult());
|
||||
return null;
|
||||
}
|
||||
}else{
|
||||
return null;
|
||||
}
|
||||
}catch (InterruptedException e){
|
||||
return null;
|
||||
}
|
||||
private void startMonitor() {
|
||||
if (stopping || !monitoring.compareAndSet(false, true))
|
||||
return;
|
||||
monitor = new Thread(this::monitorFunc, "storage-node-monitor");
|
||||
monitor.setDaemon(true);
|
||||
monitor.start();
|
||||
}
|
||||
|
||||
public void updateGallery(Gallery gallery, byte type){
|
||||
GalleryTask galleryTask = new GalleryTask();
|
||||
galleryTask.setGid(gallery.getGid());
|
||||
galleryTask.setType(type);
|
||||
galleryTask.setPages(gallery.getPages());
|
||||
|
||||
UpdateGalleryMessage ugm = new UpdateGalleryMessage();
|
||||
ugm.messageId = atomicInteger.getAndIncrement();
|
||||
ugm.setGalleryTask(galleryTask);
|
||||
channel.writeAndFlush(ugm);
|
||||
|
||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
||||
promiseHashMap.put(ugm.messageId, promise);
|
||||
try {
|
||||
boolean result = promise.await(10, TimeUnit.SECONDS);
|
||||
if(result){
|
||||
ResponseMessage rsm = (ResponseMessage)promise.getNow();
|
||||
if(rsm.getResult() != 0)
|
||||
log.error("更新本子" + gallery.getName() + "出错" + rsm.getResult());
|
||||
}
|
||||
}catch (InterruptedException e){
|
||||
e.printStackTrace();
|
||||
private void closeMonitorSocket() {
|
||||
ServerSocket socket = monitorSocket;
|
||||
if (socket != null) {
|
||||
try { socket.close(); }
|
||||
catch (IOException e) { log.debug("关闭节点监听失败", e); }
|
||||
}
|
||||
}
|
||||
|
||||
public void monitorFunc(){
|
||||
System.out.println("监听端口: " + (port + 1) + " 等待节点上线");
|
||||
try(ServerSocket socket = new ServerSocket(port + 1)) {
|
||||
Socket client;
|
||||
while(true){
|
||||
client = socket.accept();
|
||||
client.close();
|
||||
if(client.getInetAddress().getHostAddress().equals("5.255.110.45")){
|
||||
System.out.println("尝试连接");
|
||||
initChannel();
|
||||
socket.close();
|
||||
break;
|
||||
try (ServerSocket socket = new ServerSocket(CustomUtil._findIdlePort(port + 1))) {
|
||||
monitorSocket = socket;
|
||||
if (stopping || !isDead())
|
||||
return;
|
||||
log.info("监听端口: {}等待节点上线", socket.getLocalPort());
|
||||
while (!stopping) {
|
||||
try (Socket client = socket.accept()) {
|
||||
if (!client.getInetAddress().getHostAddress().equals(ip))
|
||||
continue;
|
||||
OutputStream output = client.getOutputStream();
|
||||
output.write("lionwebsite".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
output.flush();
|
||||
client.shutdownOutput();
|
||||
if (initChannel())
|
||||
break;
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,47 +432,72 @@ public class RemoteService {
|
||||
//如果不是响应信息或者响应信息为失败,则打印
|
||||
if(!(msg instanceof ResponseMessage rm) || rm.getResult() != 0)
|
||||
if(! (msg instanceof MaintainMessage))
|
||||
System.out.println(msg);
|
||||
log.debug("{}", msg);
|
||||
|
||||
//下载状态
|
||||
if(msg instanceof DownloadStatusMessage dsm){
|
||||
GalleryTask[] galleryTasks = dsm.getGalleryTasks();
|
||||
for (GalleryTask galleryTask : galleryTasks) {
|
||||
Gallery gallery = galleryMapper.selectGalleryByGid(galleryTask.getGid());
|
||||
gallery.setProceeding(galleryTask.getProceeding());
|
||||
|
||||
if(!gallery.getName().equals(galleryTask.getName()) && galleryTask.getName() != null)
|
||||
gallery.setName(galleryTask.getName());
|
||||
|
||||
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
|
||||
gallery.setStatus("下载完成");
|
||||
pushService.downloadComplete(gallery);
|
||||
// 落库与通知可能较慢(含 Telegram 推送),交给单线程顺序执行,
|
||||
// 不再占用 Netty IO 线程;异常在此兜住,避免打进 IO 线程。
|
||||
statusApplyExecutor.execute(() -> {
|
||||
try {
|
||||
applyReportedStatus(galleryTasks);
|
||||
} catch (Exception e) {
|
||||
log.warn("处理节点上报的任务状态失败", e);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
else if(msg instanceof ResponseMessage rsm)
|
||||
promiseHashMap.get(rsm.messageId).setSuccess(rsm);
|
||||
else if(msg instanceof GalleryPageQueryMessage gpqm)
|
||||
promiseHashMap.get(gpqm.messageId).setSuccess(gpqm);
|
||||
else if(msg instanceof ResponseMessage rsm) {
|
||||
Promise<AbstractMessage> promise = promiseHashMap.remove(rsm.messageId);
|
||||
if(promise != null)
|
||||
promise.trySuccess(rsm);
|
||||
else
|
||||
log.warn("收到无等待者的响应消息: messageId={}", rsm.messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把节点上报的任务状态写回库里并推送给前端,按上报顺序逐个处理。 */
|
||||
private void applyReportedStatus(GalleryTask[] galleryTasks) {
|
||||
for (GalleryTask galleryTask : galleryTasks) {
|
||||
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
|
||||
public void channelUnregistered(ChannelHandlerContext ctx) {
|
||||
System.out.println(ctx.channel());
|
||||
System.out.println(channel);
|
||||
if(ctx.channel() != null && ctx.channel().remoteAddress().toString().equals(channel.remoteAddress().toString())){
|
||||
System.out.println("activate monitor thread, waiting for node back online");
|
||||
pushService.storageNodeOffline();
|
||||
monitor = new Thread(RemoteService.this::monitorFunc);
|
||||
monitor.start();
|
||||
if (ctx.channel() == channel) {
|
||||
failPendingRequests();
|
||||
if (!stopping) {
|
||||
pushService.storageNodeOffline();
|
||||
startMonitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,334 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.lion.lionwebsite.Dao.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.SubUpdateRecord;
|
||||
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Util.FileDownload;
|
||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.lionsoul.ip2region.xdb.Searcher;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Service
|
||||
public class SubService {
|
||||
SubMapper subMapper;
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SubService {
|
||||
final SubMapper subMapper;
|
||||
final UserMapper userMapper;
|
||||
final SubscriptionRefreshService refreshService;
|
||||
final RemoteService remoteService;
|
||||
final SubscriptionStateCoordinator stateCoordinator;
|
||||
|
||||
byte[] vIndex;
|
||||
/**
|
||||
* 归属地查询是同步外呼(最长十几秒),且失败只影响记录里的一列展示。
|
||||
* 若放在订阅分发路径上,用户取一次订阅就得先等它完成,因此改为后台补齐。
|
||||
*
|
||||
* <p>非 final:测试可替换为受控执行器,避免在单测里触发真实外呼。
|
||||
*/
|
||||
Executor locationExecutor = newFixedLocationExecutor();
|
||||
|
||||
String dbPath = "ip2region.xdb";
|
||||
public SubService(SubMapper subMapper) {
|
||||
this.subMapper = subMapper;
|
||||
try {
|
||||
vIndex = Searcher.loadVectorIndexFromFile(dbPath);
|
||||
}catch (Exception e){
|
||||
System.out.printf("failed to load vector index from `%s`: %s\n", dbPath, e);
|
||||
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();
|
||||
SubscriptionAccount account;
|
||||
Lock lock = stateCoordinator.writeLock();
|
||||
lock.lock();
|
||||
try {
|
||||
account = subMapper.selectSubscriptionAccount(id);
|
||||
if (account == null)
|
||||
return response.failure("子账号不存在").toJSONString();
|
||||
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
|
||||
return response.failure("名称和上游 key 不能为空").toJSONString();
|
||||
for (SubscriptionAccount existing : subMapper.selectAllSubscriptionAccounts()) {
|
||||
if (!existing.getId().equals(id) && (existing.getName().equals(name.trim()) || existing.getUpstreamKey().equals(upstreamKey.trim())))
|
||||
return response.failure("名称或上游 key 已存在").toJSONString();
|
||||
}
|
||||
boolean changed = !account.getUpstreamKey().equals(upstreamKey.trim())
|
||||
|| account.isFilterHighMultiplier() != filterHighMultiplier || account.isEnabled() != enabled;
|
||||
account.setName(name.trim());
|
||||
account.setUpstreamKey(upstreamKey.trim());
|
||||
account.setFilterHighMultiplier(filterHighMultiplier);
|
||||
account.setEnabled(enabled);
|
||||
subMapper.updateSubscriptionAccount(account);
|
||||
if (changed)
|
||||
refreshService.invalidateCache(id);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
if (enabled)
|
||||
refreshService.refresh(id);
|
||||
remoteService.requestSubscriptionSync();
|
||||
return response.success(accountJson(account)).toJSONString();
|
||||
}
|
||||
|
||||
public String deleteSubscriptionAccount(Integer id) {
|
||||
return withWriteLock(() -> deleteSubscriptionAccountUnlocked(id));
|
||||
}
|
||||
|
||||
private String deleteSubscriptionAccountUnlocked(Integer id) {
|
||||
Response response = Response.generateResponse();
|
||||
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
|
||||
if (account == null)
|
||||
return response.failure("子账号不存在").toJSONString();
|
||||
if (account.getBoundUserCount() != null && account.getBoundUserCount() > 0)
|
||||
return response.failure("子账号仍绑定用户,请先改绑").toJSONString();
|
||||
subMapper.deleteSubscriptionAccount(id);
|
||||
refreshService.invalidateCache(id);
|
||||
remoteService.requestSubscriptionSync();
|
||||
return response.success("删除成功").toJSONString();
|
||||
}
|
||||
|
||||
public String refreshSubscriptionAccount(Integer id) {
|
||||
boolean success = refreshService.refresh(id);
|
||||
remoteService.requestSubscriptionSync();
|
||||
return success ? Response._success("刷新成功") : Response._failure("刷新失败,请查看子账号错误状态");
|
||||
}
|
||||
|
||||
public String insertSubBind(String user, Integer accountId) {
|
||||
return withWriteLock(() -> insertSubBindUnlocked(user, accountId));
|
||||
}
|
||||
|
||||
private String insertSubBindUnlocked(String user, Integer accountId) {
|
||||
Response response = Response.generateResponse();
|
||||
if (user == null || user.isBlank() || userMapper.selectUserByUsername(user) == null)
|
||||
return response.failure("用户不存在").toJSONString();
|
||||
SubscriptionAccount account = accountId == null ? firstEnabledAccount() : subMapper.selectSubscriptionAccount(accountId);
|
||||
if (account == null || !account.isEnabled())
|
||||
return response.failure("子账号不存在或已停用").toJSONString();
|
||||
if (!refreshService.hasCompleteCache(account.getId()))
|
||||
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
|
||||
if (subMapper.countSubBindByUser(user) > 0)
|
||||
return response.failure("用户已绑定子账号,请使用改绑").toJSONString();
|
||||
String key = RandomUtil.randomString(8);
|
||||
while (subMapper.selectSubBindExist(key))
|
||||
key = RandomUtil.randomString(8);
|
||||
|
||||
SubBind subBind = new SubBind(key, user);
|
||||
subMapper.insertSubBind(subBind);
|
||||
|
||||
subMapper.insertSubBind(new SubBind(key, user, account.getId(), account.getName(), account.isEnabled(), account.isFilterHighMultiplier()));
|
||||
remoteService.requestSubscriptionSync();
|
||||
return response.success("添加成功").toJSONString();
|
||||
}
|
||||
|
||||
public String resetKey(String user){
|
||||
Response response = Response.generateResponse();
|
||||
public String resetKey(String user) {
|
||||
return withWriteLock(() -> resetKeyUnlocked(user));
|
||||
}
|
||||
|
||||
subMapper.deleteSubBind(user);
|
||||
subMapper.deleteSubUpdateRecord(user);
|
||||
private String resetKeyUnlocked(String user) {
|
||||
Response response = Response.generateResponse();
|
||||
if (subMapper.countSubBindByUser(user) == 0)
|
||||
return response.failure("绑定不存在").toJSONString();
|
||||
String key = RandomUtil.randomString(8);
|
||||
while (subMapper.selectSubBindExist(key))
|
||||
key = RandomUtil.randomString(8);
|
||||
SubBind subBind = new SubBind(key, user);
|
||||
subMapper.insertSubBind(subBind);
|
||||
|
||||
subMapper.updateSubBindKey(user, key);
|
||||
subMapper.deleteSubUpdateRecord(user);
|
||||
remoteService.requestSubscriptionSync();
|
||||
return response.success().toJSONString();
|
||||
}
|
||||
|
||||
public String selectAllSubBind(){
|
||||
Response response = Response.generateResponse();
|
||||
ArrayList<SubBind> subBinds = subMapper.selectAllSubBind();
|
||||
return response.success(CustomUtil.objectMapper.valueToTree(subBinds)).toJSONString();
|
||||
public String rebind(String user, Integer accountId) {
|
||||
return withWriteLock(() -> rebindUnlocked(user, accountId));
|
||||
}
|
||||
|
||||
public String SelectAllSubUpdateRecord(){
|
||||
private String rebindUnlocked(String user, Integer accountId) {
|
||||
Response response = Response.generateResponse();
|
||||
ArrayList<SubUpdateRecord> subUpdateRecords = subMapper.selectAllSubUpdateRecord();
|
||||
return response.success(CustomUtil.objectMapper.valueToTree(subUpdateRecords)).toJSONString();
|
||||
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
|
||||
if (account == null || !account.isEnabled())
|
||||
return response.failure("子账号不存在或已停用").toJSONString();
|
||||
if (!refreshService.hasCompleteCache(account.getId()))
|
||||
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
|
||||
if (subMapper.updateSubBindAccount(user, accountId) == 0)
|
||||
return response.failure("绑定不存在").toJSONString();
|
||||
remoteService.requestSubscriptionSync();
|
||||
return response.success("改绑成功").toJSONString();
|
||||
}
|
||||
|
||||
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key){
|
||||
if(key == null || client == null)
|
||||
public String selectAllSubBind() {
|
||||
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubBind())).toJSONString();
|
||||
}
|
||||
|
||||
public String SelectAllSubUpdateRecord() {
|
||||
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubUpdateRecord())).toJSONString();
|
||||
}
|
||||
|
||||
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key) {
|
||||
if (key == null || client == null)
|
||||
return;
|
||||
|
||||
SubBind subBind = subMapper.selectSubBind(key);
|
||||
if(subBind == null)
|
||||
return;
|
||||
|
||||
String ip;
|
||||
if(request.getRemoteAddr().equals("127.0.0.1")){
|
||||
ip = request.getHeader("X-Forwarded-For");
|
||||
if(ip.contains(","))
|
||||
ip = ip.split(",")[0].trim();
|
||||
if(ip.contains(":"))
|
||||
ip = ip.split(":")[0].trim();
|
||||
} else
|
||||
ip = request.getRemoteAddr();
|
||||
|
||||
String UA = request.getHeader("User-Agent");
|
||||
if(UA == null)
|
||||
return;
|
||||
|
||||
StringBuilder location = new StringBuilder();
|
||||
Searcher searcher;
|
||||
try {
|
||||
searcher = Searcher.newWithVectorIndex(dbPath, vIndex);
|
||||
String region = searcher.search(ip);
|
||||
String[] temp = region.split("\\|");
|
||||
for (int i = 2; i < temp.length; i++)
|
||||
if(!temp[i].equals("0"))
|
||||
location.append(temp[i]).append(" ");
|
||||
} catch (Exception e) {
|
||||
System.out.printf("failed to create vectorIndex cached searcher with `%s`: %s\n", dbPath, e);
|
||||
if (subBind == null || subBind.getSubscriptionAccountId() == null || !subBind.isSubscriptionAccountEnabled()) {
|
||||
sendStatus(response, HttpServletResponse.SC_NOT_FOUND, "subscription not found");
|
||||
return;
|
||||
}
|
||||
String ip = resolveClientIp(request);
|
||||
String ua = request.getHeader("User-Agent");
|
||||
if (ua == null) return;
|
||||
recordUpdate(subBind.getUser(), ip, ua);
|
||||
if (!"v2".equals(client) && !"cat".equals(client)) {
|
||||
sendStatus(response, HttpServletResponse.SC_BAD_REQUEST, "client error");
|
||||
return;
|
||||
}
|
||||
java.nio.file.Path path = refreshService.cachedPath(subBind.getSubscriptionAccountId(), client);
|
||||
if (!Files.isRegularFile(path)) {
|
||||
sendStatus(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "subscription unavailable");
|
||||
return;
|
||||
}
|
||||
FileDownload.export(request, response, path.toString());
|
||||
}
|
||||
|
||||
SubUpdateRecord subUpdateRecord = new SubUpdateRecord(0, subBind.getUser(), ip, UA, new Date(), location.toString().trim());
|
||||
subMapper.insertSubUpdateRecord(subUpdateRecord);
|
||||
if(subMapper.selectUpdateRecordCount(subBind.getUser()) > 10)
|
||||
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(subBind.getUser()));
|
||||
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;
|
||||
}
|
||||
|
||||
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){}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public String deleteSubBind(String user){
|
||||
Response response = Response.generateResponse();
|
||||
/**
|
||||
* 查询 IP 归属地。第三方站点结构变化或网络异常都只降级为 unknown,
|
||||
* 绝不让定位失败影响订阅分发本身。
|
||||
*/
|
||||
static String resolveLocation(String ip) {
|
||||
try {
|
||||
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
|
||||
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
|
||||
return tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim()
|
||||
: (tds.isEmpty() ? "unknown" : tds.get(1).text());
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
public String deleteSubBind(String user) {
|
||||
return withWriteLock(() -> deleteSubBindUnlocked(user));
|
||||
}
|
||||
|
||||
private String deleteSubBindUnlocked(String user) {
|
||||
subMapper.deleteSubBind(user);
|
||||
subMapper.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();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.GalleryMapper;
|
||||
import com.lion.lionwebsite.Dao.TagMapper;
|
||||
import com.lion.lionwebsite.Domain.Gallery;
|
||||
import com.lion.lionwebsite.Domain.Tag;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Service
|
||||
public class TagService {
|
||||
TagMapper tagMapper;
|
||||
|
||||
GalleryMapper galleryMapper;
|
||||
|
||||
public TagService(TagMapper tagMapper, GalleryMapper galleryMapper) {
|
||||
this.tagMapper = tagMapper;
|
||||
this.galleryMapper = galleryMapper;
|
||||
}
|
||||
|
||||
public String createTag(String tagStr){
|
||||
Response response = Response.generateResponse();
|
||||
//查看tag是否存在
|
||||
if(tagMapper.selectTagExistByTag(tagStr) != 0){
|
||||
response.failure("该tag:" + tagStr + "已存在");
|
||||
}else{
|
||||
Tag tag = new Tag(0, tagStr, 0);
|
||||
tagMapper.insertTag(tag);
|
||||
response.success("创建tag:" + tagStr + "成功");
|
||||
response.set("tid", String.valueOf(tag.getId()));
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public String selectAllTag(){
|
||||
Response response = Response.generateResponse();
|
||||
ArrayList<Tag> tags = tagMapper.selectAllTag();
|
||||
HashMap<Integer, Tag> tagHashMap = new HashMap<>();
|
||||
for (Tag tag : tags) {
|
||||
tagHashMap.put(tag.getId(), tag);
|
||||
}
|
||||
|
||||
if(tags.isEmpty())
|
||||
response.failure("当前还没有标签");
|
||||
else
|
||||
response.success(new ObjectMapper().valueToTree(tagHashMap).toString());
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public String deleteTag(int tagId){
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
if(tagMapper.selectTagUsage(tagId) == 0){
|
||||
tagMapper.deleteTagById(tagId);
|
||||
response.success("标签删除成功");
|
||||
}else{
|
||||
response.failure("标签删除失败,还有本子引用该标签(如果还显示可以删除,请刷新)");
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public String markTag(int gid, int tid){
|
||||
Response response = Response.generateResponse();
|
||||
|
||||
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
if(gallery == null)//查看本子是否存在
|
||||
response.failure("本子不存在,无法标记标签");
|
||||
else if (tagMapper.selectTagExistById(tid) == 0) //查看标签是否存在
|
||||
response.failure("标签不存在,无法标记标签");
|
||||
else
|
||||
if(tagMapper.selectIsMark(gid, tid) != 0) //查看是否已有该条记录
|
||||
response.failure("当前本子已有该标签");
|
||||
else {
|
||||
if (tagMapper.markTag(gid, tid) == 1) {
|
||||
response.success("标记本子成功");
|
||||
tagMapper.incrTagUsage(tid);
|
||||
}
|
||||
else
|
||||
response.failure("未知错误");
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public String createTagAndMark(int gid, String tagString){
|
||||
Response response = Response.generateResponse();
|
||||
Gallery gallery = galleryMapper.selectGalleryByGid(gid);
|
||||
//查看tag是否存在
|
||||
if(tagMapper.selectTagExistByTag(tagString) != 0){
|
||||
response.failure("该tag:" + tagString + "已存在,请刷新再尝试");
|
||||
}else if(gallery == null) {//查看本子是否存在
|
||||
response.failure("本子不存在,取消创建标签,请刷新再尝试");
|
||||
}else{
|
||||
Tag tag = new Tag(0, tagString, 0);
|
||||
tagMapper.insertTag(tag);
|
||||
if (tagMapper.markTag(gid, tag.getId()) == 1) {
|
||||
response.success("创建标签并标记成功");
|
||||
response.set("tid", String.valueOf(tag.getId()));
|
||||
tagMapper.incrTagUsage(tag.getId());
|
||||
}
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public String disMarkTag(int gid, int tid){
|
||||
Response response = Response.generateResponse();
|
||||
if(tagMapper.disMarkTag(gid, tid) == 0)
|
||||
response.failure("取消标记失败,可能并没有标记");
|
||||
else {
|
||||
response.success("取消标记成功");
|
||||
tagMapper.decrTagUsage(tid);
|
||||
}
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
}
|
||||
+33
-26
@@ -1,39 +1,32 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import com.lion.lionwebsite.Dao.CollectMapper;
|
||||
import com.lion.lionwebsite.Dao.GalleryMapper;
|
||||
import com.lion.lionwebsite.Dao.UserMapper;
|
||||
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 com.lion.lionwebsite.Util.CustomUtil;
|
||||
import com.lion.lionwebsite.Util.Response;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl{
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserService{
|
||||
|
||||
@Resource
|
||||
UserMapper userMapper;
|
||||
final UserMapper userMapper;
|
||||
|
||||
@Resource
|
||||
GalleryMapper galleryMapper;
|
||||
final GalleryMapper galleryMapper;
|
||||
|
||||
@Resource
|
||||
CollectMapper collectMapper;
|
||||
final CollectMapper collectMapper;
|
||||
|
||||
@Resource
|
||||
TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
public UserServiceImpl(UserMapper userMapper, GalleryMapper galleryMapper, CollectMapper collectMapper, TaskHandlerInterceptor taskHandlerInterceptor) {
|
||||
this.userMapper = userMapper;
|
||||
this.galleryMapper = galleryMapper;
|
||||
this.collectMapper = collectMapper;
|
||||
this.taskHandlerInterceptor = taskHandlerInterceptor;
|
||||
}
|
||||
final TaskHandlerInterceptor taskHandlerInterceptor;
|
||||
|
||||
public String addAuthCode(String targetAuthCode, String people) {
|
||||
Response response = Response.generateResponse();
|
||||
@@ -43,7 +36,7 @@ public class UserServiceImpl{
|
||||
taskHandlerInterceptor.updateAuthCodes();
|
||||
response.success("插入成功");
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
log.error("插入授权码失败", e);
|
||||
response.failure("插入失败");
|
||||
}
|
||||
|
||||
@@ -62,7 +55,7 @@ public class UserServiceImpl{
|
||||
response.failure("授权码不存在");
|
||||
}
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
log.error("修改授权码失败", e);
|
||||
response.failure("修改失败");
|
||||
}
|
||||
|
||||
@@ -80,7 +73,7 @@ public class UserServiceImpl{
|
||||
response.failure("授权码不存在");
|
||||
}
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
log.error("修改用户名失败", e);
|
||||
response.failure("修改失败");
|
||||
}
|
||||
|
||||
@@ -104,7 +97,7 @@ public class UserServiceImpl{
|
||||
response.success("删除成功");
|
||||
taskHandlerInterceptor.updateAuthCodes();
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
log.error("删除授权码失败", e);
|
||||
response.failure("删除失败");
|
||||
}
|
||||
|
||||
@@ -135,7 +128,7 @@ public class UserServiceImpl{
|
||||
Response response = Response.generateResponse();
|
||||
User[] users = userMapper.selectAllUser();
|
||||
|
||||
response.success(new ObjectMapper().valueToTree(users).toString());
|
||||
response.success(objectMapper.valueToTree(users).toString());
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
@@ -143,4 +136,18 @@ public class UserServiceImpl{
|
||||
public int getUserId(String AuthCode){
|
||||
return userMapper.selectUserByAuthCode(AuthCode).getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员判定。项目没有角色表,历史上以 id=3(狮子)作为管理员,
|
||||
* 前端也是用 userId === 3 判断;这里把这个约定收敛到一处,
|
||||
* 权限相关的服务端校验统一走它。
|
||||
*/
|
||||
public static final int ADMIN_USER_ID = 3;
|
||||
|
||||
public boolean isAdmin(String AuthCode){
|
||||
if(AuthCode == null)
|
||||
return false;
|
||||
User user = userMapper.selectUserByAuthCode(AuthCode);
|
||||
return user != null && user.getId() == ADMIN_USER_ID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.lion.lionwebsite.Service;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import com.lion.lionwebsite.Domain.GalleryTask;
|
||||
import com.lion.lionwebsite.Util.CustomUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.socket.*;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class WebSocketService implements WebSocketHandler {
|
||||
|
||||
CopyOnWriteArrayList<WebSocketSession> sessions;
|
||||
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
public WebSocketService() {
|
||||
sessions = new CopyOnWriteArrayList<>();
|
||||
objectMapper = CustomUtil.objectMapper;
|
||||
}
|
||||
|
||||
public void updateTaskProcessing(GalleryTask[] galleryTasks){
|
||||
if(sessions.isEmpty())
|
||||
return;
|
||||
for (GalleryTask galleryTask : galleryTasks)
|
||||
if(galleryTask.getStatus() == GalleryTask.COMPRESS_COMPLETE) {
|
||||
sessions.forEach(s -> {
|
||||
try {
|
||||
s.sendMessage(new TextMessage("{\"type\": \"fullUpdate\"}"));
|
||||
} catch (Exception e) {
|
||||
log.warn("WebSocket send fullUpdate failed", e);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ObjectNode objectNode = objectMapper.createObjectNode();
|
||||
objectNode.put("type", "updateTasks");
|
||||
objectNode.set("data", objectMapper.valueToTree(galleryTasks));
|
||||
log.debug("{}", objectNode);
|
||||
sessions.forEach(s -> {
|
||||
try {
|
||||
s.sendMessage(new TextMessage(objectNode.toString()));
|
||||
}catch (Exception e){
|
||||
log.warn("WebSocket send updateTasks failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(@NotNull WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void handleMessage(@NotNull WebSocketSession session, @NotNull WebSocketMessage<?> message) throws Exception {
|
||||
if(message.getPayload().toString().equals("DownloaderWebsocket"))
|
||||
sessions.add(session);
|
||||
else
|
||||
session.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(@NotNull WebSocketSession session, @NotNull Throwable exception) {}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(@NotNull WebSocketSession session, @NotNull CloseStatus closeStatus) {
|
||||
sessions.remove(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPartialMessages() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
package com.lion.lionwebsite.Util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lion.lionwebsite.Domain.MaskDomain;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URL;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -16,6 +14,7 @@ import java.util.regex.Pattern;
|
||||
|
||||
|
||||
@Data
|
||||
@Slf4j
|
||||
public class CustomUtil {
|
||||
|
||||
public static final double ONE_KB = 1024;
|
||||
@@ -28,27 +27,6 @@ public class CustomUtil {
|
||||
|
||||
public static ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MaskDomain[] maskDomains;
|
||||
public String restoreUrl(String link){
|
||||
URL url;
|
||||
|
||||
try {
|
||||
url = new URL(link);
|
||||
}catch (MalformedURLException e){
|
||||
return null;
|
||||
}
|
||||
|
||||
for(MaskDomain maskDomain: maskDomains){
|
||||
if(url.getHost().equals(maskDomain.getMask())){
|
||||
return link.replace(maskDomain.getMask(), maskDomain.getRaw());
|
||||
}
|
||||
else if(url.getHost().equals(maskDomain.getRaw())){
|
||||
return link;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String fileSizeToString(long fileSize){
|
||||
if (fileSize < ONE_KB) {
|
||||
return fileSize + "B";
|
||||
@@ -104,12 +82,13 @@ public class CustomUtil {
|
||||
*
|
||||
* @return 可用端口的起始位置 -1为没有(几乎没有可能)
|
||||
*/
|
||||
public static short _findIdlePort(){
|
||||
for(int i=20000; i<65535; i++){
|
||||
public static int _findIdlePort(int port) {
|
||||
for(int i=port; i<65535; i++){
|
||||
try(ServerSocket ignored = new ServerSocket(i)){
|
||||
ignored.close();
|
||||
return (short) i;
|
||||
return i;
|
||||
}catch (IOException ignored) {
|
||||
log.trace("port {} unavailable", i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
@@ -119,7 +98,7 @@ public class CustomUtil {
|
||||
try{
|
||||
response.sendError(404);
|
||||
}catch (IOException e){
|
||||
e.printStackTrace();
|
||||
log.warn("sendError 404 failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +1,85 @@
|
||||
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.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.catalina.connector.ClientAbortException;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class FileDownload {
|
||||
public static void export(HttpServletRequest request, HttpServletResponse response, String path) {
|
||||
File file = new File(path);
|
||||
|
||||
String fileName = file.getName();
|
||||
|
||||
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);
|
||||
if (!file.isFile()) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
//要下载的长度(endByte 为总长度 -1,这时候要加回去)
|
||||
long contentLength = endByte - startByte + 1;
|
||||
//文件类型
|
||||
String contentType = request.getServletContext().getMimeType(fileName);
|
||||
|
||||
if (StrUtil.isEmpty(contentType)) {
|
||||
contentType = "attachment";
|
||||
}
|
||||
|
||||
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||
response.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
|
||||
// 这里文件名换你想要的,inline 表示浏览器可以直接使用
|
||||
// 参考资料:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentType + ";filename=\"" + URLUtil.encode(fileName) + "\"");
|
||||
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength));
|
||||
// [要下载的开始位置]-[结束位置]/[文件总大小]
|
||||
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + startByte + rangeSeparator + endByte + "/" + file.length());
|
||||
|
||||
BufferedOutputStream outputStream;
|
||||
RandomAccessFile randomAccessFile = null;
|
||||
//已传送数据大小
|
||||
long transmitted = 0;
|
||||
try {
|
||||
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);
|
||||
// Size and content refer to the same opened file, even if a cache is replaced.
|
||||
try (RandomAccessFile input = new RandomAccessFile(file, "r")) {
|
||||
long size = input.length();
|
||||
long start = 0;
|
||||
long end = size - 1;
|
||||
boolean partial = false;
|
||||
String range = request.getHeader(HttpHeaders.RANGE);
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
try {
|
||||
List<HttpRange> ranges = HttpRange.parseRanges(range);
|
||||
// Multiple ranges are intentionally ignored; send the full representation.
|
||||
if (ranges.size() == 1) {
|
||||
if (size == 0) throw new IllegalArgumentException("empty file");
|
||||
start = ranges.getFirst().getRangeStart(size);
|
||||
end = ranges.getFirst().getRangeEnd(size);
|
||||
if (start < 0 || start >= size || end < start)
|
||||
throw new IllegalArgumentException("unsatisfiable range");
|
||||
partial = true;
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + size);
|
||||
response.setContentLengthLong(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 处理不足 buff.length 部分
|
||||
if (transmitted < contentLength) {
|
||||
len = randomAccessFile.read(buff, 0, (int) (contentLength - transmitted));
|
||||
outputStream.write(buff, 0, len);
|
||||
long remaining = end - start + 1;
|
||||
response.setStatus(partial ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
|
||||
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||
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) {
|
||||
// ignore 用户停止下载
|
||||
// log.trace("用户停止下载: {}-{}, 已传输 {}", startByte, endByte, transmitted);
|
||||
// The client cancelled its download.
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
try {
|
||||
if (randomAccessFile != null) {
|
||||
randomAccessFile.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
log.warn("文件下载失败: {}", path, e);
|
||||
if (!response.isCommitted()) {
|
||||
response.reset();
|
||||
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,83 @@
|
||||
package com.lion.lionwebsite.Util;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import com.lion.lionwebsite.Domain.Gallery;
|
||||
import com.lion.lionwebsite.Domain.ImageKeyCache;
|
||||
import com.lion.lionwebsite.Exception.ResolutionNotMatchException;
|
||||
import org.apache.http.HttpEntity;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.mime.MultipartEntityBuilder;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import java.nio.file.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.entity.EntityBuilder;
|
||||
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.HttpEntity;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static com.lion.lionwebsite.Util.CustomUtil.objectMapper;
|
||||
|
||||
public class GalleryUtil {
|
||||
|
||||
static String EX_HENTAI = "https://exhentai.org";
|
||||
static String E_HENTAI = "https://e-hentai.org";
|
||||
private static final Logger log = LoggerFactory.getLogger(GalleryUtil.class);
|
||||
|
||||
static String POST = "post";
|
||||
static String GET = "get";
|
||||
|
||||
public static ArrayList<Gallery> galleriesForDownload;
|
||||
static String FORM_DATA = "formData";
|
||||
|
||||
static {
|
||||
galleriesForDownload = new ArrayList<>();
|
||||
static String JSON = "json";
|
||||
|
||||
/**
|
||||
* gid → mpvkey 的内存缓存。key 会轮换,代码里对失配也做了刷新兜底,
|
||||
* 因此这里只需限制为有界 LRU,避免长期运行无上限增长。
|
||||
*/
|
||||
static final Map<String, String> gid2MpvKey = Collections.synchronizedMap(
|
||||
new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
|
||||
return size() > 2048;
|
||||
}
|
||||
});
|
||||
|
||||
/** Reusable HTTP client —不要每次请求新建 */
|
||||
private static final CloseableHttpClient httpClient = HttpClients.custom()
|
||||
.setDefaultRequestConfig(RequestConfig.custom()
|
||||
.setConnectTimeout(Timeout.ofMilliseconds(5_000))
|
||||
.setConnectionRequestTimeout(Timeout.ofMilliseconds(5_000))
|
||||
// HttpClient 5 将 socket 读超时改名为 responseTimeout。
|
||||
.setResponseTimeout(Timeout.ofMilliseconds(15_000)).build()).build();
|
||||
|
||||
/** E-Hentai Cookie, injected from application.yaml via CustomBean */
|
||||
private static String ehentaiCookie = "";
|
||||
|
||||
public static void setEhentaiCookie(String cookie) {
|
||||
ehentaiCookie = cookie;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析/下载本子
|
||||
* @param url 本子链接
|
||||
* 解析/下载图片
|
||||
* @param url 图片链接
|
||||
* @param isDownload 是否下载 否则仅解析
|
||||
* @param targetResolution 目标下载分辨率 不下载时改值为空
|
||||
* @return 解析/下载的本子对象
|
||||
* @return 解析/下载的图片对象
|
||||
* @throws IOException io问题
|
||||
* @throws ResolutionNotMatchException 没有目标分辨率
|
||||
*/
|
||||
@@ -50,33 +88,39 @@ public class GalleryUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
//初始化本子
|
||||
//初始化图片
|
||||
Integer gid = parseGid(url);
|
||||
if (gid == null) {
|
||||
log.warn("链接无法解析出 gid,按无效链接处理");
|
||||
return null;
|
||||
}
|
||||
Gallery gallery = new Gallery();
|
||||
gallery.setLink(url);
|
||||
gallery.setCreateTime(System.currentTimeMillis()/1000);
|
||||
gallery.setGid(Integer.parseInt(url.split("/")[4]));
|
||||
gallery.setGid(gid);
|
||||
gallery.setProceeding(0);
|
||||
|
||||
//访问本子页面
|
||||
String galleryPage = requests(url, null, GET, null);
|
||||
//访问图片页面
|
||||
String galleryPage = requests(url, GET, null, null);
|
||||
Document galleryDoc = Jsoup.parse(galleryPage);
|
||||
|
||||
//收集本子基本信息
|
||||
//收集图片基本信息
|
||||
gallery.setName(galleryDoc.select("#gn").text() + " [" + gallery.getGid() + ']');
|
||||
gallery.setLanguage(galleryDoc.select("#gdd > table > tbody > tr:nth-child(4) > td.gdt2").text().replace("%nbps", ""));
|
||||
gallery.setPages(Integer.parseInt(galleryDoc.select("#gdd > table > tbody > tr:nth-child(6) > td.gdt2").text().split(" ")[0]));
|
||||
gallery.setFileSize(CustomUtil.stringToFileSize(galleryDoc.select("#gdd > table > tbody > tr:nth-child(5) > td.gdt2").text().replace(" ", "")));
|
||||
gallery.setDisplayFileSize(CustomUtil.fileSizeToString(gallery.getFileSize()));
|
||||
String thumb_link = galleryDoc.select("#gd1 > div").getFirst().attr("style");
|
||||
gallery.setThumb_link(thumb_link.substring(thumb_link.indexOf("url(") + 4, thumb_link.lastIndexOf(")")).replace("https://s.exhentai.org", ""));
|
||||
|
||||
//查找下载页面链接并初始化参数
|
||||
String download_link = galleryDoc.select("#gd5 > p:nth-child(2) > a").attr("onclick").split("'")[1];
|
||||
HashMap<String, String> extraProperties = new HashMap<>();
|
||||
extraProperties.put("origin", origin);
|
||||
extraProperties.put("referer", download_link);
|
||||
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
headers.put("origin", origin);
|
||||
headers.put("referer", download_link);
|
||||
|
||||
//访问下载页面,获取分辨率
|
||||
String downloadPage = requests(download_link, extraProperties, GET, null);
|
||||
String downloadPage = requests(download_link, GET, headers, null);
|
||||
Document downloadDoc = Jsoup.parse(downloadPage);
|
||||
Elements resolutions = downloadDoc.select("#db > div > table > tbody > tr > td");
|
||||
download_link = downloadDoc.select("#hathdl_form").attr("action");
|
||||
@@ -102,10 +146,8 @@ public class GalleryUtil {
|
||||
}
|
||||
|
||||
availableResolution.put("Original", tempResolutions.get("Original"));
|
||||
|
||||
gallery.setAvailableResolution(availableResolution);
|
||||
|
||||
|
||||
//如果不是下载,则直接返回
|
||||
if(!isDownload){
|
||||
gallery.setStatus("等待确认下载");
|
||||
@@ -114,39 +156,162 @@ public class GalleryUtil {
|
||||
|
||||
//如果目标分辨率不存在,抛出错误
|
||||
if(!gallery.getAvailableResolution().containsKey(targetResolution)){
|
||||
System.out.println(gallery.getAvailableResolution());
|
||||
log.warn("目标分辨率不存在,可用分辨率: {}", gallery.getAvailableResolution());
|
||||
throw new ResolutionNotMatchException(targetResolution);
|
||||
}
|
||||
|
||||
gallery.setResolution(targetResolution);
|
||||
gallery.setFileSize(CustomUtil.stringToFileSize(availableResolution.get(targetResolution).replace(" ", "")));
|
||||
gallery.setDisplayFileSize(CustomUtil.fileSizeToString(gallery.getFileSize()));
|
||||
|
||||
|
||||
//提交下载请求
|
||||
downloadPage = requests(download_link, extraProperties, POST, targetResolution);
|
||||
HashMap<String, String> body = new HashMap<>();
|
||||
body.put("payload", FORM_DATA);
|
||||
body.put("hathdl_xres", targetResolution.replace("x", "").replace("Original", "org"));
|
||||
downloadPage = requests(download_link, POST, headers, body);
|
||||
downloadDoc = Jsoup.parse(downloadPage);
|
||||
|
||||
//判断下载请求是否提交成功
|
||||
if(downloadDoc.select("#db > p:nth-child(2) > strong").text().startsWith("#"))
|
||||
gallery.setStatus("已提交");
|
||||
else {
|
||||
System.out.println(downloadDoc.select("#db"));
|
||||
log.warn("下载提交失败: {}", downloadDoc.select("#db"));
|
||||
gallery.setStatus("提交失败");
|
||||
}
|
||||
|
||||
return gallery;
|
||||
}
|
||||
|
||||
public static ArrayList<ImageKeyCache> parseImageKeys(String url) throws IOException {
|
||||
String temp = url.substring(url.indexOf("/g/") + 3);
|
||||
String mpvUrl = "https://exhentai.org/mpv/" + temp;
|
||||
String gid = url.split("/")[4];
|
||||
HashMap<String, String> header = new HashMap<>();
|
||||
header.put("Referer", url);
|
||||
String content = requests(mpvUrl, "get", header, null);
|
||||
if(content.trim().isEmpty())
|
||||
return null;
|
||||
Document document = Jsoup.parse(content);
|
||||
if(document.select("body > script").size() < 2)
|
||||
return null;
|
||||
Element script = document.select("body > script").get(1);
|
||||
String[] scripts = script.html().split("\n");
|
||||
ArrayList<ImageKeyCache> imageKeyCaches = new ArrayList<>();
|
||||
AtomicInteger page = new AtomicInteger(1);
|
||||
gid2MpvKey.put(gid, scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", ""));
|
||||
JsonNode nodes = parseImagelist(scripts[2]);
|
||||
nodes.forEach((n) -> {
|
||||
ImageKeyCache imageKeyCache = new ImageKeyCache();
|
||||
imageKeyCache.setGid(gid);
|
||||
imageKeyCache.setImgkey(n.get("k").asText());
|
||||
imageKeyCache.setPage(page.getAndIncrement());
|
||||
imageKeyCaches.add(imageKeyCache);
|
||||
});
|
||||
return imageKeyCaches;
|
||||
}
|
||||
|
||||
public static String queryUpdateLink(String link) throws IOException{
|
||||
String page = requests(link, null, GET, null);
|
||||
Document document = Jsoup.parse(page);
|
||||
/**
|
||||
* 解析 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);
|
||||
}
|
||||
|
||||
Elements select = document.select("#gnd");
|
||||
if(select.size() > 0)
|
||||
return select.select("a").get(0).attributes().get("href");
|
||||
public static String getMpvKey(String url){
|
||||
String gid = String.valueOf(parseGid(url));
|
||||
String key = gid2MpvKey.get(gid);
|
||||
if (key == null) {
|
||||
// refreshMpvKey writes the cache itself; never call it inside computeIfAbsent.
|
||||
refreshMpvKey(url);
|
||||
key = gid2MpvKey.get(gid);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
public static void refreshMpvKey(String url) {
|
||||
String temp = url.substring(url.indexOf("/g/") + 3);
|
||||
String mpvUrl = "https://exhentai.org/mpv/" + temp;
|
||||
HashMap<String, String> header = new HashMap<>();
|
||||
header.put("Referer", url);
|
||||
String content;
|
||||
try {
|
||||
content = requests(mpvUrl, "get", header, null);
|
||||
}catch (Exception e){
|
||||
log.error("刷新mpvKey失败, url: {}", url, e);
|
||||
gid2MpvKey.remove(parseGid(url) + "");
|
||||
return;
|
||||
}
|
||||
Document document = Jsoup.parse(content);
|
||||
Element script = document.select("body > script").get(1);
|
||||
String[] scripts = script.html().split("\n");
|
||||
String mpvKey = scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", "");
|
||||
gid2MpvKey.put(parseGid(url) + "", mpvKey);
|
||||
log.info("刷新key:{}", mpvKey);
|
||||
}
|
||||
|
||||
public static String getImageUrl(String mpvKey, ImageKeyCache imageKeyCache) {
|
||||
String apiUrl = "https://s.exhentai.org/api.php";
|
||||
HashMap<String, String> header = new HashMap<>();
|
||||
header.put("Referer", "https://exhentai.org");
|
||||
HashMap<String, String> body = new HashMap<>();
|
||||
body.put("gid", imageKeyCache.getGid());
|
||||
body.put("mpvkey", mpvKey);
|
||||
body.put("imgkey", imageKeyCache.getImgkey());
|
||||
body.put("method", "imagedispatch");
|
||||
body.put("page", "" + imageKeyCache.getPage());
|
||||
body.put("payload", "json");
|
||||
try {
|
||||
String result = requests(apiUrl, "post", header, body);
|
||||
JsonNode jsonNode = objectMapper.readTree(result);
|
||||
if (jsonNode.has("error") && jsonNode.get("error").asText().equals("Key mismatch"))
|
||||
return null;
|
||||
return jsonNode.get("i").asText();
|
||||
}catch (Exception e){
|
||||
log.error("获取imgurl失败:{}:{}", imageKeyCache.getGid(), imageKeyCache.getPage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String convertImg(String imagePath, String suffix){
|
||||
Path source = Path.of(imagePath);
|
||||
Path target = source.resolveSibling(source.getFileName().toString().replaceFirst("\\Q" + suffix + "\\E$", ".avif"));
|
||||
if (source.equals(target)) return imagePath;
|
||||
Path temporary = null;
|
||||
Process process = null;
|
||||
try {
|
||||
temporary = Files.createTempFile(target.toAbsolutePath().getParent(), ".convert-", ".avif");
|
||||
process = new ProcessBuilder("convert", source.toString(), temporary.toString())
|
||||
.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
|
||||
if (!process.waitFor(60, TimeUnit.SECONDS))
|
||||
throw new IOException("图片转换超时");
|
||||
if (process.exitValue() != 0 || Files.size(temporary) == 0)
|
||||
throw new IOException("图片转换失败");
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
Files.deleteIfExists(source);
|
||||
return target.toString();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
log.warn("文件{}转换失败", imagePath, e);
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
if (temporary != null) {
|
||||
try { Files.deleteIfExists(temporary); }
|
||||
catch (IOException e) { log.warn("清理图片转换临时文件失败", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,77 +326,96 @@ public class GalleryUtil {
|
||||
if(url.length() < 40)
|
||||
return null;
|
||||
|
||||
if(!url.contains("/g/") || !url.contains("hentai"))
|
||||
if(!url.contains("/g/") || !url.contains("exhentai"))
|
||||
return null;
|
||||
else if (url.contains(EX_HENTAI))
|
||||
return EX_HENTAI;
|
||||
else if (url.contains(E_HENTAI))
|
||||
return E_HENTAI;
|
||||
else{
|
||||
System.out.println(url);
|
||||
return null;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 自用request
|
||||
* @param url 链接
|
||||
* @param extraProperties 额外参数
|
||||
* @param headers 额外参数
|
||||
* @param method 请求方法
|
||||
* @param targetResolution 目标分辨率
|
||||
* @return 请求页面
|
||||
* @throws IOException 可能会抛出IO错误
|
||||
*/
|
||||
public static String requests(String url, HashMap<String, String> extraProperties, String method, String targetResolution) throws IOException {
|
||||
CloseableHttpClient httpClient = HttpClients.createDefault();
|
||||
public static String requests(String url, String method, HashMap<String, String> headers, HashMap<String, String> body) throws IOException {
|
||||
CloseableHttpResponse httpResponse;
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
if(headers == null)
|
||||
headers = new HashMap<>();
|
||||
|
||||
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36");
|
||||
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
|
||||
headers.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8");
|
||||
headers.put("Upgrade-Insecure-Requests", "1");
|
||||
headers.put("Cookie", "ipb_member_id=5774855; ipb_pass_hash=4b061c3abe25289568b5a8e0123fb3b9; igneous=cea2e08fb; sk=oye107wk02gtomb56x65dmv4qzbn; nw=1");
|
||||
|
||||
if(extraProperties != null)
|
||||
headers.putAll(extraProperties);
|
||||
if(url.contains("hentai")) {
|
||||
headers.put("Cookie", ehentaiCookie);
|
||||
headers.put("Upgrade-Insecure-Requests", "1");
|
||||
}
|
||||
|
||||
if(method.equals(GET)){
|
||||
HttpGet httpGet = new HttpGet(url);
|
||||
for (Map.Entry<String, String> header: headers.entrySet()){
|
||||
for (Map.Entry<String, String> header: headers.entrySet())
|
||||
httpGet.addHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
|
||||
httpResponse = httpClient.execute(httpGet);
|
||||
} else {
|
||||
HttpPost httpPost = new HttpPost(url);
|
||||
for (Map.Entry<String, String> header: headers.entrySet()){
|
||||
for (Map.Entry<String, String> header: headers.entrySet())
|
||||
httpPost.addHeader(header.getKey(), header.getValue());
|
||||
|
||||
if(body != null) {
|
||||
String payload;
|
||||
if((payload = body.remove("payload")).equals(JSON)) {
|
||||
EntityBuilder entityBuilder = EntityBuilder.create();
|
||||
entityBuilder.setContentType(ContentType.APPLICATION_JSON);
|
||||
entityBuilder.setText(objectMapper.writeValueAsString(body));
|
||||
httpPost.setEntity(entityBuilder.build());
|
||||
} else if(payload.equals(FORM_DATA)) {
|
||||
MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create();
|
||||
body.forEach(multipartEntityBuilder::addTextBody);
|
||||
httpPost.setEntity(multipartEntityBuilder.build());
|
||||
}
|
||||
}
|
||||
|
||||
MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create();
|
||||
multipartEntityBuilder.addTextBody("hathdl_xres",
|
||||
targetResolution.replace("x", "").replace("Original", "org"));
|
||||
|
||||
httpPost.setEntity(multipartEntityBuilder.build());
|
||||
httpResponse = httpClient.execute(httpPost);
|
||||
}
|
||||
HttpEntity responseEntity = httpResponse.getEntity();
|
||||
int statusCode = httpResponse.getStatusLine().getStatusCode();
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
if(statusCode == 200){
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()));
|
||||
String str;
|
||||
while((str = reader.readLine()) != null)
|
||||
stringBuilder.append(str).append("\n");
|
||||
try (httpResponse) {
|
||||
HttpEntity responseEntity = httpResponse.getEntity();
|
||||
int statusCode = httpResponse.getCode();
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
if (statusCode == 200 && responseEntity != null) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent()))) {
|
||||
String str;
|
||||
while ((str = reader.readLine()) != null)
|
||||
stringBuilder.append(str).append("\n");
|
||||
}
|
||||
} else {
|
||||
log.warn("{}:{}", url, statusCode);
|
||||
}
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
else{
|
||||
System.out.println(statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从链接里安全提取 gid。
|
||||
*
|
||||
* <p>历史实现只捕 {@code IndexOutOfBoundsException}:非数字段会抛
|
||||
* {@code NumberFormatException},{@code link == null} 会抛 NPE,两者都会在
|
||||
* 「无 @ControllerAdvice」的项目里穿透成 500。这里与
|
||||
* {@code GalleryManageService.parseGidFromLink} 保持一致的宽松语义:
|
||||
* 任何畸形输入都返回 null,由调用方转成业务失败。
|
||||
*/
|
||||
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 {
|
||||
return Integer.parseInt(segments[0]);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
httpClient.close();
|
||||
httpResponse.close();
|
||||
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
|
||||
public class Response {
|
||||
@@ -19,7 +19,8 @@ public class Response {
|
||||
}
|
||||
|
||||
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){
|
||||
@@ -50,17 +51,30 @@ public class Response {
|
||||
setResult("failure");
|
||||
}
|
||||
|
||||
public void failure(String result){
|
||||
public Response failure(String result){
|
||||
failure();
|
||||
setData(result);
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getData(){
|
||||
JsonNode node = result.get("data");
|
||||
return node == null ? null : node.asText();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link #getData()} instead. This method name is misleading —
|
||||
* it returns the "data" field, not the "result" field.
|
||||
*/
|
||||
@Deprecated
|
||||
public String getResult(){
|
||||
return result.get("data").asText();
|
||||
return getData();
|
||||
}
|
||||
|
||||
/** 未设置 result 键时视为失败,而不是抛 NPE。 */
|
||||
public boolean isSuccess(){
|
||||
return result.get("result").asText().equals("success");
|
||||
JsonNode node = result.get("result");
|
||||
return node != null && "success".equals(node.asText());
|
||||
}
|
||||
|
||||
|
||||
@@ -82,13 +96,6 @@ public class Response {
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public static String _default(){
|
||||
Response response = Response.generateResponse();
|
||||
response.failure("参数错误");
|
||||
|
||||
return response.toJSONString();
|
||||
}
|
||||
|
||||
public String toJSONString(){
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.lion.lionwebsite.Util;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Concurrent callers for the same key share one in-flight operation, including its failure. */
|
||||
public final class SingleFlight<K, V> {
|
||||
private final ConcurrentHashMap<K, CompletableFuture<V>> running = new ConcurrentHashMap<>();
|
||||
|
||||
public V run(K key, Callable<V> operation) throws Exception {
|
||||
CompletableFuture<V> mine = new CompletableFuture<>();
|
||||
CompletableFuture<V> existing = running.putIfAbsent(key, mine);
|
||||
if (existing != null) {
|
||||
try { return existing.get(); }
|
||||
catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof Exception cause) throw cause;
|
||||
throw new IllegalStateException(e.getCause());
|
||||
}
|
||||
}
|
||||
try {
|
||||
V result = operation.call();
|
||||
mine.complete(result);
|
||||
return result;
|
||||
} catch (Exception | Error e) {
|
||||
mine.completeExceptionally(e);
|
||||
throw e;
|
||||
} finally {
|
||||
running.remove(key, mine);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.lion.lionwebsite.Util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 抓取上游订阅时伪装的客户端身份。
|
||||
*
|
||||
* <p>此前请求只带 HttpClient 的默认头,实测上游收到的是
|
||||
* {@code User-agent: Apache-HttpClient/5.6.4 (Java/25.0.4)},
|
||||
* 对订阅站点而言等同于「这是一个 Java 程序在批量拉取」。
|
||||
* 这里按订阅格式给出与真实客户端一致的常见头组合。
|
||||
*
|
||||
* <p>注意:只能伪装到 HTTP 头这一层。真实 Clash/Mihomo 使用 Go 的 TLS 栈与
|
||||
* HTTP/2,TLS 指纹与 Java HttpClient 仍有差异;若上游做主动指纹识别,
|
||||
* 需要另外引入真实客户端进程,本枚举解决不了。
|
||||
*/
|
||||
public enum SubscriptionClientProfile {
|
||||
MIHOMO("mihomo/v1.19.2", "application/yaml, */*", "zh-CN,zh;q=0.9"),
|
||||
CLASH_VERGE("clash-verge/v2.0.3", "application/yaml, */*", "zh-CN,zh;q=0.9"),
|
||||
CLASH_WINDOWS("ClashforWindows/0.20.39", "*/*", "zh-CN,zh;q=0.9"),
|
||||
V2RAYN("v2rayN/6.45", "*/*", "zh-CN,zh;q=0.9"),
|
||||
V2RAYNG("v2rayNG/1.9.16", "*/*", "zh-CN,zh;q=0.9"),
|
||||
SHADOWROCKET("Shadowrocket/2.2.39", "*/*", "zh-CN,zh;q=0.9");
|
||||
|
||||
static final List<SubscriptionClientProfile> CLASH_PROFILES = List.of(MIHOMO, CLASH_VERGE, CLASH_WINDOWS);
|
||||
static final List<SubscriptionClientProfile> V2_PROFILES = List.of(V2RAYN, V2RAYNG, SHADOWROCKET);
|
||||
|
||||
private final String userAgent;
|
||||
private final String accept;
|
||||
private final String acceptLanguage;
|
||||
|
||||
SubscriptionClientProfile(String userAgent, String accept, String acceptLanguage) {
|
||||
this.userAgent = userAgent;
|
||||
this.accept = accept;
|
||||
this.acceptLanguage = acceptLanguage;
|
||||
}
|
||||
|
||||
public String userAgent() {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
public String accept() {
|
||||
return accept;
|
||||
}
|
||||
|
||||
public String acceptLanguage() {
|
||||
return acceptLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按账号与订阅格式确定性地选择一个身份。
|
||||
*
|
||||
* <p>刻意不使用随机数:同一个账号每次都用同一个客户端,才符合真实用户的样子;
|
||||
* 逐请求更换 User-Agent 本身就是更明显的机器特征。不同账号使用不同客户端,
|
||||
* 又便于在必要时按账号区分上游流量。
|
||||
*
|
||||
* @param accountId 子账号 ID,可为 null(视为 0)
|
||||
* @param clash true 表示 Clash 格式,false 表示 V2Ray 格式
|
||||
*/
|
||||
public static SubscriptionClientProfile forAccount(Integer accountId, boolean clash) {
|
||||
List<SubscriptionClientProfile> candidates = clash ? CLASH_PROFILES : V2_PROFILES;
|
||||
int id = accountId == null ? 0 : accountId;
|
||||
return candidates.get(Math.floorMod(id, candidates.size()));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,14 +1,33 @@
|
||||
server:
|
||||
port: 8888
|
||||
servlet:
|
||||
session:
|
||||
# nginx 的 location / 会把浏览器请求改写成后端 /personal/…,而浏览器地址栏仍是
|
||||
# /user 这类路径。若沿用容器按请求路径推导的 Cookie Path(/personal),浏览器
|
||||
# 判定 /user 不匹配就不会带上会话,面板会一直 401。这里固定为 /。
|
||||
cookie:
|
||||
path: /
|
||||
http-only: true
|
||||
same-site: lax
|
||||
timeout: 14d
|
||||
tomcat:
|
||||
max-swallow-size: 10000MB
|
||||
http2:
|
||||
enabled: true
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
datasource-main:
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
url: jdbc:sqlite:/root/LionWebsite/LionWebsite.db
|
||||
jdbc-url: jdbc:sqlite:LionWebsite.db
|
||||
datasource-cache:
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
jdbc-url: jdbc:sqlite:cache.db
|
||||
# 定时任务默认只有 1 个线程。订阅刷新一轮最多 2 个账号、每个 2 次请求(各 15 秒超时),
|
||||
# 最坏可占用约 1 分钟;单线程会让连接自检、cookie 检测等任务在此期间全部排队。
|
||||
task:
|
||||
scheduling:
|
||||
pool:
|
||||
size: 2
|
||||
mvc:
|
||||
view:
|
||||
prefix: /resources/templates/
|
||||
@@ -21,23 +40,52 @@ spring:
|
||||
max-request-size: 10000MB
|
||||
enabled: true
|
||||
|
||||
mybatis:
|
||||
type-aliases-package: com.lion.lionwebsite.Dao
|
||||
|
||||
personal-service:
|
||||
StoragePath: /storage/
|
||||
# Application secrets — override via environment variables or external config in production
|
||||
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/
|
||||
cache-size: 100
|
||||
remote:
|
||||
ip: "5.255.110.45"
|
||||
|
||||
remote-service:
|
||||
ip: 5.255.110.45
|
||||
local:
|
||||
dou-nai-clash: "https://aaaa.gay/link/{key}?client=clashmeta"
|
||||
dou-nai-v2ray: "https://aaaa.gay/link/{key}?client=v2"
|
||||
|
||||
local-service:
|
||||
fires: https://api.dler.io/sub?target=clash&new_name=true&url=https%3A%2F%2Ffast.losadhwselfff2332dasd.xyz%2Flink%2Fz0pfwyTvC5naXkbb%3Fclash%3D1&insert=false&config=https%3A%2F%2Fraw.githubusercontent.com%2FACL4SSR%2FACL4SSR%2Fmaster%2FClash%2Fconfig%2FACL4SSR_Online.ini
|
||||
DouNaiClash: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta
|
||||
DouNaiV2ray: https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2
|
||||
subscription:
|
||||
cache-root: sub/accounts
|
||||
# 分散刷新:每个账号在 window-hours 内至少刷新一次,两两之间至少相隔 min-gap-minutes;
|
||||
# max-per-tick 限制单个 tick 刷新几个账号,避免重启后一批到期账号同时开火。
|
||||
refresh:
|
||||
window-hours: 24
|
||||
min-gap-minutes: 60
|
||||
# 5 分钟一个轻量 tick,只处理已到期账号。
|
||||
tick-interval-ms: 300000
|
||||
# 重启后延迟 2 分钟再开始,先让应用完成启动。
|
||||
initial-delay-ms: 120000
|
||||
max-per-tick: 2
|
||||
# 失败后的重试间隔,必须小于 window-hours。
|
||||
retry-delay-minutes: 60
|
||||
stale-alert-multiplier: 2.0
|
||||
upstream:
|
||||
# Use environment variables or an external config file in production.
|
||||
v2-url-template: "https://aaaa.gay/link/{key}?client=v2"
|
||||
clash-url-template: "https://aaaa.gay/link/{key}?client=clashmeta"
|
||||
high-multiplier-threshold: 2.0
|
||||
refresh-interval-ms: 86400000
|
||||
standby:
|
||||
sync-enabled: "${SUBSCRIPTION_STANDBY_SYNC_ENABLED:false}"
|
||||
sync-secret: "${SUBSCRIPTION_SYNC_SECRET:}"
|
||||
# 快照只在订阅内容变化时推送(另在节点上线时补推一次),没有周期性重发。
|
||||
# 备机的过期语义是「主站失联」,由节点按「收到主站的任何消息」判定新鲜度,
|
||||
# 主站每 30 分钟的可用性检查即可持续刷新,无需为续期周期性传输快照。
|
||||
|
||||
bot:
|
||||
token: 5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA
|
||||
token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
|
||||
|
||||
# 一键登录:机器人用同一密钥自签 HMAC 票据,主站只做验签与时间窗校验。
|
||||
# secret 必须通过环境变量注入,不得写入仓库;为空时所有票据一律拒绝。
|
||||
personal:
|
||||
login:
|
||||
secret: "${PERSONAL_LOGIN_SECRET:}"
|
||||
ticket-ttl-seconds: 300
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user