移除文件管理与文件分享,接口范围收敛到订阅与用户管理
- 删除 /personal/files、uploadFile、private/**、compress、delete 与对应服务实现,
连同压缩线程池、TAR 打包与 PersonalArchiveTest
- 删除 /personal/share、extendShareTime、cancelShare 与 /GetFile/{path},
以及 ShareFile 实体、ShareFileMapper、PublicService.GetFile、每天四点的
checkShareCode 定时任务和分享码失败黑名单
- CustomBean 去掉 ShareFile 的 Native 反射登记
- 同步收敛 PersonalServiceTest、PublicServiceTest、PersonalControllerTest、
PublicControllerTest、LocalServiceTest 与三个画廊测试
- 文档同步,并附上 /login 方案 docs/login-command-plan.md
测试:mvn -o test 410 项通过
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
# LionWebsite 一键登录指令方案(/login)
|
||||||
|
|
||||||
|
状态:待用户确认,未实施。
|
||||||
|
关联待办:`todos/open-td-20260921-a1b2c3-lionwebsite-login-tg.md`。
|
||||||
|
|
||||||
|
## 1. 现状
|
||||||
|
|
||||||
|
- 个人面板入口是 `https://personal.lionwebsite.xyz/index`(桌面)与 `/mobile`(移动)。
|
||||||
|
nginx 把 `/` 代理到后端 `/personal/`,把 `/index`、`/mobile` 映射到静态入口文件。
|
||||||
|
- 面板没有登录页。`PrivateMain` 与 `PrivateMainForMobile` 的 `src/store/index.js`
|
||||||
|
把 `authCode: "alone"` 写死,所有请求都带 `?AuthCode=alone`。
|
||||||
|
- 后端 `PersonalInterceptor` 对 `/personal/**`、`/remote/**` 直接比较字面量 `alone`;
|
||||||
|
`AdaptorFilter` 也用它决定移动端跳转。`/remote/**` 目前没有对应控制器,属历史遗留。
|
||||||
|
- `alone` 不在 `User` 表里(表内是 big lion / pubraseer / temp / au283602 / 0619 /
|
||||||
|
liondown / bigcat)。它是独立的固定字面量:不轮换、不区分人,且随 JS 产物公开。
|
||||||
|
- 存储节点另有两处写死 `alone`:`storageNode` 的 `CustomUtil.java`(向 `/message2me`
|
||||||
|
推送)与 `MultiThreadedHTTPServer.java`(本机 HTTP 鉴权)。
|
||||||
|
- 机器人(QQ `LionQQBot` + Telegram `PersonalHub`,主机 vm103)目前没有 `/login`。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
1. 机器人在 QQ 与 Telegram 都能执行 `/login`,返回一条可点击的个人面板登录地址。
|
||||||
|
2. 地址自带短期凭证,点开即登录,不需要手输授权码。
|
||||||
|
3. 长期固定的 `alone` 不再出现在前端产物与聊天消息里。
|
||||||
|
4. 凭证可过期、可吊销,登录行为在后端留痕。
|
||||||
|
|
||||||
|
## 3. 推荐方案:短期签名链接 + 会话 Cookie
|
||||||
|
|
||||||
|
### 3.1 后端(`lionwebsite-backend`,主机 us9929)
|
||||||
|
|
||||||
|
1. 新增登录端点。nginx 无需改动:`location /` 已把请求改写成 `/personal/...`,
|
||||||
|
因此 `https://personal.lionwebsite.xyz/login?t=...` 会落到后端 `/personal/login`。
|
||||||
|
- `GET /login?t=<ticket>`:校验票据 → 下发 HttpOnly Cookie(`personal_session`,
|
||||||
|
`SameSite=Lax`、`Secure`,有效期建议 30 天)→ 302 跳 `/index`。
|
||||||
|
- 票据格式建议 `v1.<签发时间戳>.<HMAC-SHA256(共享密钥, 时间戳|用户)>`,有效期 5 分钟。
|
||||||
|
2. `PersonalInterceptor` 的放行条件改为「有效会话 Cookie **或** 合法 `AuthCode`」。
|
||||||
|
过渡期保留 `AuthCode`,避免影响下载器前端与存储节点推送。
|
||||||
|
3. `InterceptorConfiguration` 必须排除 `/personal/login`,否则登录端点会被自己拦住。
|
||||||
|
4. 共享密钥通过 `application.yaml` 的环境变量覆盖位注入(如 `personal.login-secret`),
|
||||||
|
密钥值不进仓库、不进日志。
|
||||||
|
5. 可选 `GET /login/logout` 清除 Cookie。
|
||||||
|
|
||||||
|
选签名票据而不是「后端签发一次性 token」的理由:机器人与后端之间不需要新增网络调用
|
||||||
|
和状态存储,双方共享一个密钥即可。代价是票据在 5 分钟窗口内可重放,对私聊场景可接受;
|
||||||
|
若要求真正一次性,改为后端 mint 端点 + 内存 token 表。
|
||||||
|
|
||||||
|
### 3.2 机器人(QQ + Telegram,主机 vm103)
|
||||||
|
|
||||||
|
- QQ:在 LionQQBot 插件里新增 `/login`(别名 `/登录`),复用既有命令注册与主人校验。
|
||||||
|
- Telegram:`sync_commands` 菜单增加 `/login`,`telegram.py` 派发到同名处理函数。
|
||||||
|
- 处理函数:用共享密钥生成票据,回复
|
||||||
|
`https://personal.lionwebsite.xyz/login?t=<ticket>`,并附一句有效期提示。
|
||||||
|
- 密钥存 PersonalHub 秘密库,共享盘只保留 `secret://` 引用。
|
||||||
|
- 按现行约定,新增命令必须同时覆盖 QQ 与 Telegram,帮助菜单同步更新。
|
||||||
|
|
||||||
|
### 3.3 前端(`PrivateMain` 桌面 / `PrivateMainForMobile` 移动)
|
||||||
|
|
||||||
|
- 删除 `authCode: "alone"` 常量与 `?AuthCode=` 查询串,改为依赖同源 Cookie
|
||||||
|
(axios 同源请求默认携带 Cookie)。
|
||||||
|
- 未登录或被拒时显示提示页「登录已过期,请在机器人里发送 /login」,不要静默失败。
|
||||||
|
- 下载器前端(`lionwebsite-frontend-desktop` / `-mobile`)使用每个用户自己的授权码,
|
||||||
|
本次不动。
|
||||||
|
|
||||||
|
### 3.4 收尾:退役 `alone`
|
||||||
|
|
||||||
|
- `PersonalInterceptor`、`AdaptorFilter` 不再比较字面量;移动端跳转改为原样透传查询串。
|
||||||
|
- 存储节点两处 `alone` 换成配置项,属 storageNode 仓库,单独一次发布。
|
||||||
|
- 轮换后确认无调用方仍依赖旧字面量。
|
||||||
|
|
||||||
|
## 4. 更小的备选
|
||||||
|
|
||||||
|
- 方案 B(最小改动):前端增加「从 URL 读取 `AuthCode` 并记住」的逻辑,机器人 `/login`
|
||||||
|
直接回 `https://personal.lionwebsite.xyz/index?AuthCode=alone`。半天内可上线,但长期
|
||||||
|
密钥仍会进入聊天记录、浏览器历史与 nginx 日志,也没有解决 `alone` 写死的问题。
|
||||||
|
- 方案 C(只治理配置):把 `alone` 从代码搬到配置并轮换,机器人从配置读取。安全提升有限。
|
||||||
|
|
||||||
|
## 5. 需要确认的三点
|
||||||
|
|
||||||
|
1. 采用推荐方案(Cookie 会话),还是先上备选 B?
|
||||||
|
2. 登录链接只允许主人使用,还是允许机器人给其他授权用户分别签发(对应 `User` 表账号)?
|
||||||
|
3. 会话有效期:建议 30 天滑动过期,可调整。
|
||||||
|
|
||||||
|
## 6. 实施顺序
|
||||||
|
|
||||||
|
1. 后端票据签发与 Cookie 校验、拦截器放行,含单元测试。
|
||||||
|
2. 前端去掉 `alone`,补未登录提示。
|
||||||
|
3. 机器人 QQ 与 Telegram 双向 `/login`。
|
||||||
|
4. 存储节点两处 `alone` 改为配置。
|
||||||
|
5. 退役字面量 `alone`。
|
||||||
+12
-18
@@ -6,8 +6,8 @@
|
|||||||
──────────────────────────────────────────────────────────────────────────────
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
名称: LionWebsite
|
名称: LionWebsite
|
||||||
技术栈: Spring Boot 4.1.1, Java 21 字节码(生产 JDK 25), Maven, SQLite, MyBatis 4, Netty 4.2
|
技术栈: Spring Boot 4.1.1, Java 21 字节码(生产 JDK 25), Maven, SQLite, MyBatis 4, Netty 4.2
|
||||||
定位: 个人/私有 Web 应用,兼具 E-Hentai 画廊下载管理、个人文件服务、
|
定位: 个人/私有 Web 应用,兼具 E-Hentai 画廊下载管理、代理订阅
|
||||||
代理订阅管理等功能。
|
管理等功能。
|
||||||
运行端口: 8888
|
运行端口: 8888
|
||||||
数据库: 双 SQLite 数据库 — LionWebsite.db (主库) + cache.db (缓存库)
|
数据库: 双 SQLite 数据库 — LionWebsite.db (主库) + cache.db (缓存库)
|
||||||
构建目标: 当前以 JVM/JAR 运行;保留 GraalVM Native Image 配置但尚未在 JDK 25 完成原生验证
|
构建目标: 当前以 JVM/JAR 运行;保留 GraalVM Native Image 配置但尚未在 JDK 25 完成原生验证
|
||||||
@@ -29,8 +29,8 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
├── Controller/
|
├── Controller/
|
||||||
│ ├── GalleryManageController.java 画廊任务 CRUD、收藏、图片在线缓存 /GalleryManage
|
│ ├── GalleryManageController.java 画廊任务 CRUD、收藏、图片在线缓存 /GalleryManage
|
||||||
│ ├── QueryController.java E-Hentai 搜索代理 /query
|
│ ├── QueryController.java E-Hentai 搜索代理 /query
|
||||||
│ ├── PublicController.java 根路由、IP、订阅、文件分享、验证 /、/ip、/sub/、/GetFile/、/validate
|
│ ├── PublicController.java 根路由、IP、订阅、验证 /、/ip、/sub/、/validate
|
||||||
│ ├── PersonalController.java 个人文件管理 /personal/
|
│ ├── PersonalController.java 个人管理 (订阅更新/最后更新时间/IP/留言) /personal/
|
||||||
│ ├── SubController.java 订阅绑定管理 /personal/subBind/
|
│ ├── SubController.java 订阅绑定管理 /personal/subBind/
|
||||||
│ └── UserController.java 用户管理 /personal/user
|
│ └── UserController.java 用户管理 /personal/user
|
||||||
│
|
│
|
||||||
@@ -41,8 +41,8 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
│ ├── PushService.java Telegram Bot 通知 (admin 告警)
|
│ ├── PushService.java Telegram Bot 通知 (admin 告警)
|
||||||
│ ├── QueryService.java E-Hentai 搜索 + 缩略图代理缓存 (转 AVIF)
|
│ ├── QueryService.java E-Hentai 搜索 + 缩略图代理缓存 (转 AVIF)
|
||||||
│ ├── LocalServiceImpl.java 定时任务 (连接检测/额度重置/Cookie验证/订阅更新/缩略图清理)
|
│ ├── LocalServiceImpl.java 定时任务 (连接检测/额度重置/Cookie验证/订阅更新/缩略图清理)
|
||||||
│ ├── PublicServiceImpl.java IP 记录、分享码文件获取、授权码修改
|
│ ├── PublicServiceImpl.java IP 记录、授权码修改、身份查询
|
||||||
│ ├── PersonalServiceImpl.java 文件管理 (浏览/上传/下载/分享/压缩/删除/TAR打包)
|
│ ├── PersonalServiceImpl.java 订阅最后更新时间、家里 IP、留言转发
|
||||||
│ ├── SubService.java 代理订阅绑定/重置/查询/更新记录
|
│ ├── SubService.java 代理订阅绑定/重置/查询/更新记录
|
||||||
│ ├── CollectService.java 画廊收藏/取消收藏
|
│ ├── CollectService.java 画廊收藏/取消收藏
|
||||||
│ └── UserServiceImpl.java 用户 CRUD + 授权码管理
|
│ └── UserServiceImpl.java 用户 CRUD + 授权码管理
|
||||||
@@ -52,7 +52,6 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
│ │ ├── GalleryMapper.java gallery 表 CRUD
|
│ │ ├── GalleryMapper.java gallery 表 CRUD
|
||||||
│ │ ├── UserMapper.java user 表 CRUD
|
│ │ ├── UserMapper.java user 表 CRUD
|
||||||
│ │ ├── CollectMapper.java collect 表 CRUD
|
│ │ ├── CollectMapper.java collect 表 CRUD
|
||||||
│ │ ├── ShareFileMapper.java ShareFile 表 CRUD
|
|
||||||
│ │ ├── CustomConfigurationMapper.java 配置键值对读写
|
│ │ ├── CustomConfigurationMapper.java 配置键值对读写
|
||||||
│ │ └── SubMapper.java 订阅绑定 & 更新记录
|
│ │ └── SubMapper.java 订阅绑定 & 更新记录
|
||||||
│ └── cache/ 缓存库 Mapper
|
│ └── cache/ 缓存库 Mapper
|
||||||
@@ -66,7 +65,6 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
│ ├── GidToKey.java 画廊 GID → MPV Key 映射
|
│ ├── GidToKey.java 画廊 GID → MPV Key 映射
|
||||||
│ ├── ImageKeyCache.java 图片 key 缓存 (gid, page, imgkey)
|
│ ├── ImageKeyCache.java 图片 key 缓存 (gid, page, imgkey)
|
||||||
│ ├── CustomConfiguration.java 配置键常量定义
|
│ ├── CustomConfiguration.java 配置键常量定义
|
||||||
│ ├── ShareFile.java 文件分享 (ShareCode, FilePath, ExpireTime)
|
|
||||||
│ ├── SubBind.java 订阅绑定 (key, user)
|
│ ├── SubBind.java 订阅绑定 (key, user)
|
||||||
│ ├── SubUpdateRecord.java 订阅更新记录 (ip, UA, time, location)
|
│ ├── SubUpdateRecord.java 订阅更新记录 (ip, UA, time, location)
|
||||||
│ └── PageNameCache.java 页面名缓存 (gid, page, pageName)
|
│ └── PageNameCache.java 页面名缓存 (gid, page, pageName)
|
||||||
@@ -115,13 +113,11 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
- 支持图片在线预览: 缓存 MPV key → 按需下载单页 → 转为 AVIF 格式
|
- 支持图片在线预览: 缓存 MPV key → 按需下载单页 → 转为 AVIF 格式
|
||||||
- 画廊收藏/取消收藏
|
- 画廊收藏/取消收藏
|
||||||
|
|
||||||
2. 个人文件服务
|
2. 个人管理服务
|
||||||
- 文件浏览器: 按路径浏览文件/文件夹,显示大小、分享状态
|
- 手动触发订阅更新,返回最近更新时间与已记录的家庭 IP
|
||||||
- 上传: MultipartFile 上传到指定路径
|
- 管理员留言经 PushService 转发到 Telegram
|
||||||
- 下载: 支持 HTTP Range 断点续传
|
|
||||||
- 分享: 生成 8 位随机分享码,设置过期时间,可延长/取消
|
(文件浏览/上传/下载/分享/压缩/删除已在 td-20260921-a1b2c3 中整体下线)
|
||||||
- 压缩: 异步 TAR 打包目录
|
|
||||||
- 删除文件/文件夹
|
|
||||||
|
|
||||||
3. E-Hentai 搜索代理
|
3. E-Hentai 搜索代理
|
||||||
- 代理搜索 exhentai.org,返回格式化结果 (含缩略图 URL)
|
- 代理搜索 exhentai.org,返回格式化结果 (含缩略图 URL)
|
||||||
@@ -141,7 +137,6 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
- 每 30 分钟: 检测存储节点连接,断开则自动重连
|
- 每 30 分钟: 检测存储节点连接,断开则自动重连
|
||||||
- 每周一 4:00: 重置每周下载额度
|
- 每周一 4:00: 重置每周下载额度
|
||||||
- 每天 0:00: 验证 E-Hentai Cookie 有效性
|
- 每天 0:00: 验证 E-Hentai Cookie 有效性
|
||||||
- 每天 4:00: 清理过期分享码
|
|
||||||
- 每 24 小时: 更新代理订阅配置文件
|
- 每 24 小时: 更新代理订阅配置文件
|
||||||
- 每周一 4:00: 清理缩略图缓存 (保留最近 10000 个)
|
- 每周一 4:00: 清理缩略图缓存 (保留最近 10000 个)
|
||||||
|
|
||||||
@@ -151,13 +146,12 @@ src/main/java/com/lion/lionwebsite/
|
|||||||
- /personal 和 /remote 路径限 AuthCode="alone" 用户
|
- /personal 和 /remote 路径限 AuthCode="alone" 用户
|
||||||
- HumanInterceptor 拒绝无 User-Agent 请求
|
- HumanInterceptor 拒绝无 User-Agent 请求
|
||||||
- AdaptorFilter 记录所有请求日志 (IP/路径/UA/时间)
|
- AdaptorFilter 记录所有请求日志 (IP/路径/UA/时间)
|
||||||
- 分享码失败黑名单 (连续失败则屏蔽)
|
|
||||||
|
|
||||||
六、依赖
|
六、依赖
|
||||||
──────────────────────────────────────────────────────────────────────────────
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
spring-boot-starter-webmvc, spring-boot-starter-websocket, mybatis-spring-boot-starter 4.1
|
spring-boot-starter-webmvc, spring-boot-starter-websocket, mybatis-spring-boot-starter 4.1
|
||||||
jsoup (HTML 解析), hutool-all (工具集), sqlite-jdbc (数据库)
|
jsoup (HTML 解析), hutool-all (工具集), sqlite-jdbc (数据库)
|
||||||
httpclient5 (HTTP 请求), commons-compress (TAR 打包)
|
httpclient5 (HTTP 请求)
|
||||||
commons-io, commons-lang3, netty-all (TCP 通信)
|
commons-io, commons-lang3, netty-all (TCP 通信)
|
||||||
java-telegram-bot-api (Telegram Bot), graalvm native-maven-plugin (AOT)
|
java-telegram-bot-api (Telegram Bot), graalvm native-maven-plugin (AOT)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import java.util.Random;
|
|||||||
@Configuration
|
@Configuration
|
||||||
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
|
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
|
||||||
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
|
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
|
||||||
PageNameCache.class, ShareFile.class, User.class,
|
PageNameCache.class, User.class,
|
||||||
SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class,
|
SendResponse.class, Message.class, com.pengrad.telegrambot.model.User.class,
|
||||||
Chat.class, MessageEntity.class,
|
Chat.class, MessageEntity.class,
|
||||||
AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class,
|
AbstractMethodError.class, DeleteGalleryMessage.class, DownloadPostMessage.class, DownloadStatusMessage.class,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.lion.lionwebsite.Controller;
|
|||||||
import com.lion.lionwebsite.Service.LocalService;
|
import com.lion.lionwebsite.Service.LocalService;
|
||||||
import com.lion.lionwebsite.Service.PersonalService;
|
import com.lion.lionwebsite.Service.PersonalService;
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -11,7 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
@@ -31,46 +29,6 @@ public class PersonalController {
|
|||||||
resp.sendRedirect("/index");
|
resp.sendRedirect("/index");
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/files")
|
|
||||||
public String file(String path){
|
|
||||||
return personalService.getFiles(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/uploadFile")
|
|
||||||
public String uploadFile(String path, String fileName, MultipartFile file){
|
|
||||||
return personalService.uploadFile(path, fileName, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/updateSub")
|
@PostMapping("/updateSub")
|
||||||
public String updateSub() throws IOException {
|
public String updateSub() throws IOException {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
|
|||||||
@@ -16,16 +16,12 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PublicController {
|
public class PublicController {
|
||||||
|
|
||||||
final List<String> black_share_codes = new LinkedList<>();
|
|
||||||
|
|
||||||
final PublicService publicService;
|
final PublicService publicService;
|
||||||
|
|
||||||
final RemoteService remoteService;
|
final RemoteService remoteService;
|
||||||
@@ -60,24 +56,6 @@ public class PublicController {
|
|||||||
subService.updateSub(response, request, client, key);
|
subService.updateSub(response, request, client, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/GetFile/{path}")
|
|
||||||
public void getFile(HttpServletRequest request, HttpServletResponse response, String ShareCode, @PathVariable("path") String path) throws IOException {
|
|
||||||
synchronized (black_share_codes) {
|
|
||||||
if (black_share_codes.contains(ShareCode))
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("ShareCode:{}", ShareCode);
|
|
||||||
log.info("Path:{}", path);
|
|
||||||
boolean result = publicService.GetFile(request, response, ShareCode);
|
|
||||||
|
|
||||||
if(!result)
|
|
||||||
black_share_codes.add(ShareCode);
|
|
||||||
|
|
||||||
if(black_share_codes.size() > 100)
|
|
||||||
black_share_codes.removeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/validate")
|
@PostMapping("/validate")
|
||||||
public String validate(String AuthCode){
|
public String validate(String AuthCode){
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.lion.lionwebsite.Dao.normal;
|
|
||||||
|
|
||||||
import com.lion.lionwebsite.Domain.ShareFile;
|
|
||||||
import org.apache.ibatis.annotations.*;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface ShareFileMapper {
|
|
||||||
@Insert("insert into ShareFile (ShareCode, FilePath, ExpireTime) values (#{ShareCode}, #{FilePath}, #{ExpireTime})")
|
|
||||||
void insertShareFile(@Param("ShareCode")String ShareCode, @Param("FilePath")String FilePath, @Param("ExpireTime") Date ExpireTime);
|
|
||||||
|
|
||||||
@Select("select * from ShareFile where ShareCode=#{ShareCode}")
|
|
||||||
ShareFile selectShareFileByShareCode(String ShareCode);
|
|
||||||
|
|
||||||
@Select("select * from ShareFile where FilePath=#{FilePath}")
|
|
||||||
ShareFile selectShareFileByFilePath(String FilePath);
|
|
||||||
|
|
||||||
@Select("select * from ShareFile where FilePath like '%' || #{FilePath} || '%'")
|
|
||||||
ArrayList<ShareFile> selectShareFilesByFilePath(String FilePath);
|
|
||||||
|
|
||||||
@Delete("delete from ShareFile where ShareCode=#{ShareCode}")
|
|
||||||
void deleteShareFile(String ShareCode);
|
|
||||||
|
|
||||||
@Select("select * from ShareFile")
|
|
||||||
ShareFile[] selectAllShareFile();
|
|
||||||
|
|
||||||
@Update("update ShareFile set ExpireTime=#{ExpireTime}, ShareCode=#{ShareCode} where FilePath=#{FilePath}")
|
|
||||||
void updateShareFile(ShareFile ShareFile);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -42,8 +42,6 @@ public class GalleryManageService {
|
|||||||
|
|
||||||
final UserMapper userMapper;
|
final UserMapper userMapper;
|
||||||
|
|
||||||
final ShareFileMapper shareFileMapper;
|
|
||||||
|
|
||||||
final ImageCacheMapper imageCacheMapper;
|
final ImageCacheMapper imageCacheMapper;
|
||||||
|
|
||||||
final RemoteService remoteService;
|
final RemoteService remoteService;
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package com.lion.lionwebsite.Service;
|
|||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
|
|
||||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||||
import com.lion.lionwebsite.Domain.ShareFile;
|
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -45,8 +43,6 @@ public class LocalService{
|
|||||||
|
|
||||||
final CustomConfigurationMapper configurationMapper;
|
final CustomConfigurationMapper configurationMapper;
|
||||||
|
|
||||||
final ShareFileMapper shareFileMapper;
|
|
||||||
|
|
||||||
final GalleryMapper galleryMapper;
|
final GalleryMapper galleryMapper;
|
||||||
|
|
||||||
final PushService pushService;
|
final PushService pushService;
|
||||||
@@ -150,23 +146,6 @@ public class LocalService{
|
|||||||
return temp;
|
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的部分
|
* 每周,查看缩略图数量,按照访问时间排序,删除超出1w的部分
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,43 +1,17 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
|
||||||
import cn.hutool.core.io.FileUtil;
|
|
||||||
import cn.hutool.core.util.RandomUtil;
|
|
||||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
|
|
||||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
|
||||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
import com.lion.lionwebsite.Domain.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.lion.lionwebsite.Util.Response;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
|
|
||||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
|
|
||||||
import org.apache.tomcat.util.http.fileupload.IOUtils;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.io.BufferedOutputStream;
|
import java.util.HashMap;
|
||||||
import java.io.File;
|
import java.util.Map;
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
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
|
@Service
|
||||||
@@ -46,221 +20,8 @@ import java.util.concurrent.Executors;
|
|||||||
public class PersonalService{
|
public class PersonalService{
|
||||||
final CustomConfigurationMapper configurationMapper;
|
final CustomConfigurationMapper configurationMapper;
|
||||||
|
|
||||||
final UserMapper userMapper;
|
|
||||||
|
|
||||||
final ShareFileMapper shareFileMapper;
|
|
||||||
|
|
||||||
final TaskHandlerInterceptor taskHandlerInterceptor;
|
|
||||||
|
|
||||||
String StoragePath = "/storage/";
|
|
||||||
|
|
||||||
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
|
|
||||||
|
|
||||||
ExecutorService compressThreadPool = Executors.newFixedThreadPool(1);
|
|
||||||
|
|
||||||
final PushService pushService;
|
final PushService pushService;
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取文件列表,同时带上分享码以及过期时间
|
|
||||||
* @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
|
|
||||||
// listFiles() 只在 I/O 出错时返回 null(可读空目录返回长度为 0 的数组),
|
|
||||||
// 报「文件夹为空」会掩盖权限/磁盘问题,这里如实提示。
|
|
||||||
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){
|
|
||||||
log.warn("输出404失败", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件
|
|
||||||
* @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("上传失败");
|
|
||||||
log.error("上传失败: {}", fileName, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 订阅文件上次更新时间
|
* @return 订阅文件上次更新时间
|
||||||
@@ -289,90 +50,6 @@ public class PersonalService{
|
|||||||
return response.toJSONString();
|
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(() -> {
|
|
||||||
Path temporary = Paths.get(finalPath + ".tar***undone");
|
|
||||||
try {
|
|
||||||
writeTar(Paths.get(finalPath), temporary);
|
|
||||||
// writeTar closes the archive (including its trailer) before publication.
|
|
||||||
try {
|
|
||||||
Files.move(temporary, Paths.get(finalPath + ".tar"), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
} catch (AtomicMoveNotSupportedException e) {
|
|
||||||
Files.move(temporary, Paths.get(finalPath + ".tar"), StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
}
|
|
||||||
log.info("打包成功: {}.tar", finalPath);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("打包失败", e);
|
|
||||||
} finally {
|
|
||||||
try { Files.deleteIfExists(temporary); }
|
|
||||||
catch (IOException e) { log.warn("清理打包临时文件失败", e); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
response.success("加入队列成功");
|
|
||||||
return response.toJSONString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void writeTar(Path directory, Path output) throws IOException {
|
|
||||||
try (OutputStream stream = new BufferedOutputStream(Files.newOutputStream(output));
|
|
||||||
TarArchiveOutputStream archive = new TarArchiveOutputStream(stream)) {
|
|
||||||
archive.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
|
|
||||||
Files.walkFileTree(directory, new SimpleFileVisitor<>() {
|
|
||||||
@Override public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
|
|
||||||
if (!path.equals(directory)) {
|
|
||||||
archive.putArchiveEntry(new TarArchiveEntry(path.toFile(), directory.relativize(path).toString()));
|
|
||||||
archive.closeArchiveEntry();
|
|
||||||
}
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
@Override public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
|
|
||||||
archive.putArchiveEntry(new TarArchiveEntry(path.toFile(), directory.relativize(path).toString()));
|
|
||||||
try (InputStream input = Files.newInputStream(path)) {
|
|
||||||
IOUtils.copy(input, archive);
|
|
||||||
}
|
|
||||||
archive.closeArchiveEntry();
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除文件
|
|
||||||
* @param path 目标路径
|
|
||||||
* @return 删除结果
|
|
||||||
*/
|
|
||||||
public String deleteFile(String path) {
|
|
||||||
Response response = Response.generateResponse();
|
|
||||||
path = URLDecoder.decode(path, StandardCharsets.UTF_8);
|
|
||||||
File file = new File(path);
|
|
||||||
|
|
||||||
// hutool 的 FileUtil.del 对不存在的目标返回 true(幂等语义),
|
|
||||||
// 直接据此报成功会让「路径写错/文件已不存在」也回「删除成功」,前端误以为已删除。
|
|
||||||
if (!file.exists())
|
|
||||||
response.failure("文件不存在");
|
|
||||||
else if (FileUtil.del(file))
|
|
||||||
response.success("删除成功");
|
|
||||||
else
|
|
||||||
response.failure("删除失败");
|
|
||||||
|
|
||||||
return response.toJSONString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String message2me(String message){
|
public String message2me(String message){
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
pushService.sendToMe(message);
|
pushService.sendToMe(message);
|
||||||
|
|||||||
@@ -2,35 +2,23 @@ package com.lion.lionwebsite.Service;
|
|||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
|
|
||||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||||
import com.lion.lionwebsite.Domain.ShareFile;
|
|
||||||
import com.lion.lionwebsite.Domain.User;
|
import com.lion.lionwebsite.Domain.User;
|
||||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.FileDownload;
|
|
||||||
import com.lion.lionwebsite.Util.Response;
|
import com.lion.lionwebsite.Util.Response;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Calendar;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PublicService {
|
public class PublicService {
|
||||||
|
|
||||||
final CustomConfigurationMapper configurationMapper;
|
final CustomConfigurationMapper configurationMapper;
|
||||||
|
|
||||||
final ShareFileMapper shareFileMapper;
|
|
||||||
|
|
||||||
final UserMapper userMapper;
|
final UserMapper userMapper;
|
||||||
|
|
||||||
final TaskHandlerInterceptor taskHandlerInterceptor;
|
final TaskHandlerInterceptor taskHandlerInterceptor;
|
||||||
@@ -44,41 +32,6 @@ public class PublicService {
|
|||||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, CustomUtil.now());
|
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 AuthCode 原来的授权码
|
||||||
|
|||||||
@@ -2,24 +2,20 @@ package com.lion.lionwebsite.Controller;
|
|||||||
|
|
||||||
import com.lion.lionwebsite.Service.LocalService;
|
import com.lion.lionwebsite.Service.LocalService;
|
||||||
import com.lion.lionwebsite.Service.PersonalService;
|
import com.lion.lionwebsite.Service.PersonalService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /personal 的 HTTP 契约。
|
* /personal 的 HTTP 契约。
|
||||||
* 这些接口能做到浏览/上传/删除文件,权限由 PersonalInterceptor 另外把关
|
* 文件管理与分享接口下线后,这里只剩订阅更新时间、家里 IP 与留言转发;
|
||||||
* (见 PersonalInterceptorTest),此处只验证参数如何传到服务层。
|
* 权限由 PersonalInterceptor 另外把关(见 PersonalInterceptorTest)。
|
||||||
*/
|
*/
|
||||||
class PersonalControllerTest {
|
class PersonalControllerTest {
|
||||||
|
|
||||||
@@ -43,57 +39,8 @@ class PersonalControllerTest {
|
|||||||
.andExpect(redirectedUrl("/index"));
|
.andExpect(redirectedUrl("/index"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void fileListPassesPathThrough() throws Exception {
|
|
||||||
when(personalService.getFiles("/docs")).thenReturn("{\"result\":\"success\"}");
|
|
||||||
|
|
||||||
mockMvc.perform(get("/personal/files").param("path", "/docs"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(personalService).getFiles("/docs");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传要同时带上目标路径、文件名与文件体。 */
|
|
||||||
@Test
|
|
||||||
void uploadPassesPathFileNameAndContent() throws Exception {
|
|
||||||
var file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes());
|
|
||||||
|
|
||||||
mockMvc.perform(multipart("/personal/uploadFile")
|
|
||||||
.file(file)
|
|
||||||
.param("path", "/docs")
|
|
||||||
.param("fileName", "a.txt"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(personalService).uploadFile(eq("/docs"), eq("a.txt"), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下载走通配路径,path 参数与 request/response 都要透传。 */
|
|
||||||
@Test
|
|
||||||
void downloadForwardsWildcardPath() throws Exception {
|
|
||||||
mockMvc.perform(get("/personal/private/deep/nested/file.txt").param("path", "/deep/nested/file.txt"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(personalService).download(any(HttpServletRequest.class), any(HttpServletResponse.class),
|
|
||||||
eq("/deep/nested/file.txt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void simpleOperationsDelegateWithTheirParameters() throws Exception {
|
void simpleOperationsDelegateWithTheirParameters() throws Exception {
|
||||||
mockMvc.perform(post("/personal/share").param("path", "/a.txt").param("expireHour", "24"));
|
|
||||||
verify(personalService).shareFile("/a.txt", 24);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/personal/compress").param("path", "/dir"));
|
|
||||||
verify(personalService).compress("/dir");
|
|
||||||
|
|
||||||
mockMvc.perform(post("/personal/delete").param("path", "/a.txt"));
|
|
||||||
verify(personalService).deleteFile("/a.txt");
|
|
||||||
|
|
||||||
mockMvc.perform(post("/personal/extendShareTime").param("path", "/a.txt").param("extendHour", "2"));
|
|
||||||
verify(personalService).extendShareTime("/a.txt", 2);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/personal/cancelShare").param("path", "/a.txt"));
|
|
||||||
verify(personalService).cancelShare("/a.txt");
|
|
||||||
|
|
||||||
mockMvc.perform(get("/personal/lastUpdate"));
|
mockMvc.perform(get("/personal/lastUpdate"));
|
||||||
verify(personalService).lastUpdate();
|
verify(personalService).lastUpdate();
|
||||||
|
|
||||||
|
|||||||
@@ -151,59 +151,4 @@ class PublicControllerTest {
|
|||||||
any(HttpServletRequest.class), any(HttpServletResponse.class));
|
any(HttpServletRequest.class), any(HttpServletResponse.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- GetFile 与失败分享码黑名单 ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getFileForwardsToService() throws Exception {
|
|
||||||
when(publicService.GetFile(any(), any(), eq("goodcode"))).thenReturn(true);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "goodcode"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(publicService).GetFile(any(), any(), eq("goodcode"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 失败的分享码会被加入黑名单,后续相同请求直接短路返回,
|
|
||||||
* 不再反复查库/探测文件(防爆破)。这是该接口唯一的限流手段。
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getFileBlacklistsFailingShareCode() throws Exception {
|
|
||||||
when(publicService.GetFile(any(), any(), eq("badcode"))).thenReturn(false);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "badcode"));
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "badcode"));
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "badcode"));
|
|
||||||
|
|
||||||
verify(publicService, times(1)).GetFile(any(), any(), eq("badcode"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 不同分享码各自独立计数,一个坏码不应影响好码。 */
|
|
||||||
@Test
|
|
||||||
void blacklistIsPerShareCode() throws Exception {
|
|
||||||
when(publicService.GetFile(any(), any(), eq("bad-1"))).thenReturn(false);
|
|
||||||
when(publicService.GetFile(any(), any(), eq("good-2"))).thenReturn(true);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "bad-1"));
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "good-2"));
|
|
||||||
|
|
||||||
verify(publicService).GetFile(any(), any(), eq("bad-1"));
|
|
||||||
verify(publicService).GetFile(any(), any(), eq("good-2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 黑名单上限 100:超过后最旧的条目被淘汰,不会无界增长。 */
|
|
||||||
@Test
|
|
||||||
void blacklistIsCappedAtHundredEntries() throws Exception {
|
|
||||||
when(publicService.GetFile(any(), any(), anyString())).thenReturn(false);
|
|
||||||
|
|
||||||
for (int i = 0; i < 105; i++)
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "code-" + i));
|
|
||||||
|
|
||||||
// 105 个不同坏码各触发一次服务调用;最早那批已被挤出,容量保持有界
|
|
||||||
verify(publicService, times(105)).GetFile(any(), any(), anyString());
|
|
||||||
|
|
||||||
// 已被淘汰的 code-0 再次请求会重新走一次服务层(说明它确实被移出了黑名单)
|
|
||||||
mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "code-0"));
|
|
||||||
verify(publicService, times(106)).GetFile(any(), any(), anyString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class GalleryManageServiceTest {
|
|||||||
remote = mock(RemoteService.class);
|
remote = mock(RemoteService.class);
|
||||||
push = mock(PushService.class);
|
push = mock(PushService.class);
|
||||||
service = new GalleryManageService(galleries, collectMapper,
|
service = new GalleryManageService(galleries, collectMapper,
|
||||||
configurationMapper, users, mock(ShareFileMapper.class),
|
configurationMapper, users,
|
||||||
mock(ImageCacheMapper.class), remote, push);
|
mock(ImageCacheMapper.class), remote, push);
|
||||||
|
|
||||||
User user = new User();
|
User user = new User();
|
||||||
@@ -59,7 +59,7 @@ class GalleryManageServiceTest {
|
|||||||
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
|
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
|
||||||
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null); // 缓存未命中
|
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null); // 缓存未命中
|
||||||
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
|
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
|
||||||
configurationMapper, users, mock(ShareFileMapper.class),
|
configurationMapper, users,
|
||||||
imageCacheMapper, remote, push);
|
imageCacheMapper, remote, push);
|
||||||
|
|
||||||
// imagelist 行以分号结尾——即线上真实页面格式
|
// imagelist 行以分号结尾——即线上真实页面格式
|
||||||
@@ -94,7 +94,7 @@ class GalleryManageServiceTest {
|
|||||||
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
|
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
|
||||||
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null);
|
when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null);
|
||||||
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
|
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
|
||||||
configurationMapper, users, mock(ShareFileMapper.class),
|
configurationMapper, users,
|
||||||
imageCacheMapper, remote, push);
|
imageCacheMapper, remote, push);
|
||||||
|
|
||||||
String brokenPage = "<html><body><script>x</script><script>\n"
|
String brokenPage = "<html><body><script>x</script><script>\n"
|
||||||
@@ -126,7 +126,7 @@ class GalleryManageServiceTest {
|
|||||||
void cacheImagesKeyRejectsMalformedLink() {
|
void cacheImagesKeyRejectsMalformedLink() {
|
||||||
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
|
ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class);
|
||||||
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
|
GalleryManageService svc = new GalleryManageService(galleries, collectMapper,
|
||||||
configurationMapper, users, mock(ShareFileMapper.class),
|
configurationMapper, users,
|
||||||
imageCacheMapper, remote, push);
|
imageCacheMapper, remote, push);
|
||||||
|
|
||||||
for (String bad : new String[]{"abc", "https://exhentai.org/g/1/", null}) {
|
for (String bad : new String[]{"abc", "https://exhentai.org/g/1/", null}) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class GalleryQueryTest {
|
|||||||
users = mock(UserMapper.class);
|
users = mock(UserMapper.class);
|
||||||
remote = mock(RemoteService.class);
|
remote = mock(RemoteService.class);
|
||||||
service = new GalleryManageService(galleries, collectMapper,
|
service = new GalleryManageService(galleries, collectMapper,
|
||||||
mock(CustomConfigurationMapper.class), users, mock(ShareFileMapper.class),
|
mock(CustomConfigurationMapper.class), users,
|
||||||
mock(ImageCacheMapper.class), remote, mock(PushService.class));
|
mock(ImageCacheMapper.class), remote, mock(PushService.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class GallerySubmissionTest {
|
|||||||
RemoteService remote = mock(RemoteService.class);
|
RemoteService remote = mock(RemoteService.class);
|
||||||
CustomConfigurationMapper configuration = mock(CustomConfigurationMapper.class);
|
CustomConfigurationMapper configuration = mock(CustomConfigurationMapper.class);
|
||||||
GalleryManageService service = new GalleryManageService(galleries, mock(CollectMapper.class), configuration,
|
GalleryManageService service = new GalleryManageService(galleries, mock(CollectMapper.class), configuration,
|
||||||
users, mock(ShareFileMapper.class), mock(ImageCacheMapper.class), remote, mock(PushService.class));
|
users, mock(ImageCacheMapper.class), remote, mock(PushService.class));
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(7);
|
user.setId(7);
|
||||||
user.setUsername("test");
|
user.setUsername("test");
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ package com.lion.lionwebsite.Service;
|
|||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
import com.lion.lionwebsite.Dao.normal.GalleryMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
|
|
||||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||||
import com.lion.lionwebsite.Domain.ShareFile;
|
|
||||||
import com.lion.lionwebsite.Util.CustomUtil;
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Calendar;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
@@ -26,7 +21,6 @@ import static org.mockito.Mockito.*;
|
|||||||
class LocalServiceTest {
|
class LocalServiceTest {
|
||||||
|
|
||||||
private CustomConfigurationMapper configurationMapper;
|
private CustomConfigurationMapper configurationMapper;
|
||||||
private ShareFileMapper shareFileMapper;
|
|
||||||
private GalleryMapper galleryMapper;
|
private GalleryMapper galleryMapper;
|
||||||
private PushService pushService;
|
private PushService pushService;
|
||||||
private RemoteService remoteService;
|
private RemoteService remoteService;
|
||||||
@@ -36,12 +30,11 @@ class LocalServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
configurationMapper = mock(CustomConfigurationMapper.class);
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
shareFileMapper = mock(ShareFileMapper.class);
|
|
||||||
galleryMapper = mock(GalleryMapper.class);
|
galleryMapper = mock(GalleryMapper.class);
|
||||||
pushService = mock(PushService.class);
|
pushService = mock(PushService.class);
|
||||||
remoteService = mock(RemoteService.class);
|
remoteService = mock(RemoteService.class);
|
||||||
refreshScheduler = mock(SubscriptionRefreshScheduler.class);
|
refreshScheduler = mock(SubscriptionRefreshScheduler.class);
|
||||||
service = new LocalService(configurationMapper, shareFileMapper, galleryMapper,
|
service = new LocalService(configurationMapper, galleryMapper,
|
||||||
pushService, remoteService, refreshScheduler);
|
pushService, remoteService, refreshScheduler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,30 +178,6 @@ class LocalServiceTest {
|
|||||||
"不应再保留 isManual 参数:定时路径已不存在");
|
"不应再保留 isManual 参数:定时路径已不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- checkShareCode ----------
|
|
||||||
|
|
||||||
/** 只清理已过期的分享码,未过期的必须保留。 */
|
|
||||||
@Test
|
|
||||||
void checkShareCodeDeletesOnlyExpiredOnes() {
|
|
||||||
when(shareFileMapper.selectAllShareFile()).thenReturn(new ShareFile[]{
|
|
||||||
share("expired", hoursFromNow(-2)),
|
|
||||||
share("alive", hoursFromNow(2))});
|
|
||||||
|
|
||||||
service.checkShareCode();
|
|
||||||
|
|
||||||
verify(shareFileMapper).deleteShareFile("expired");
|
|
||||||
verify(shareFileMapper, never()).deleteShareFile("alive");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void checkShareCodeHandlesEmptyTable() {
|
|
||||||
when(shareFileMapper.selectAllShareFile()).thenReturn(new ShareFile[0]);
|
|
||||||
|
|
||||||
service.checkShareCode();
|
|
||||||
|
|
||||||
verify(shareFileMapper, never()).deleteShareFile(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 定时作业的调度声明 ----------
|
// ---------- 定时作业的调度声明 ----------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,7 +189,6 @@ class LocalServiceTest {
|
|||||||
assertCron("CheckConnectionAvailability", "0 0/30 * * * *");
|
assertCron("CheckConnectionAvailability", "0 0/30 * * * *");
|
||||||
assertCron("reset", "0 0 4 * * MON");
|
assertCron("reset", "0 0 4 * * MON");
|
||||||
assertCron("verifyCookie", "0 0 0 * * *");
|
assertCron("verifyCookie", "0 0 0 * * *");
|
||||||
assertCron("checkShareCode", "0 0 4 * * *");
|
|
||||||
assertCron("clearThumbnailCache", "0 0 4 1 * *");
|
assertCron("clearThumbnailCache", "0 0 4 1 * *");
|
||||||
// 订阅刷新已改为 SubscriptionRefreshScheduler 的分散 tick,
|
// 订阅刷新已改为 SubscriptionRefreshScheduler 的分散 tick,
|
||||||
// 其周期与初始延迟由 SubscriptionRefreshSchedulerTest 锁定。
|
// 其周期与初始延迟由 SubscriptionRefreshSchedulerTest 锁定。
|
||||||
@@ -233,20 +201,6 @@ class LocalServiceTest {
|
|||||||
assertEquals(expected, annotation.cron(), method + " 的 cron 与运维约定不一致");
|
assertEquals(expected, annotation.cron(), method + " 的 cron 与运维约定不一致");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShareFile share(String code, Date expire) {
|
|
||||||
ShareFile sf = new ShareFile();
|
|
||||||
sf.setShareCode(code);
|
|
||||||
sf.setFilePath("/tmp/" + code);
|
|
||||||
sf.setExpireTime(expire);
|
|
||||||
return sf;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Date hoursFromNow(int hours) {
|
|
||||||
Calendar c = Calendar.getInstance();
|
|
||||||
c.add(Calendar.HOUR_OF_DAY, hours);
|
|
||||||
return c.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** now() 只用于断言「时间被写入」,这里确认它确实是格式化的当前时间。 */
|
/** now() 只用于断言「时间被写入」,这里确认它确实是格式化的当前时间。 */
|
||||||
@Test
|
@Test
|
||||||
void nowIsFormattedTimestamp() {
|
void nowIsFormattedTimestamp() {
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
|
||||||
|
|
||||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import java.nio.file.*;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class PersonalArchiveTest {
|
|
||||||
@Test void archiveContainsCompleteFilesAndCanBeOpenedImmediately(@TempDir Path root) throws Exception {
|
|
||||||
Path source = Files.createDirectory(root.resolve("source"));
|
|
||||||
Files.createDirectory(source.resolve("nested"));
|
|
||||||
for (int i = 0; i < 100; i++) Files.writeString(source.resolve("nested/" + i + ".txt"), "content-" + i);
|
|
||||||
Path archive = root.resolve("result.tar");
|
|
||||||
PersonalService.writeTar(source, archive);
|
|
||||||
var contents = new HashMap<String, String>();
|
|
||||||
try (var input = new TarArchiveInputStream(Files.newInputStream(archive))) {
|
|
||||||
org.apache.commons.compress.archivers.tar.TarArchiveEntry entry;
|
|
||||||
while ((entry = input.getNextTarEntry()) != null) {
|
|
||||||
if (!entry.isDirectory()) contents.put(entry.getName(), new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertEquals(100, contents.size());
|
|
||||||
for (int i = 0; i < 100; i++) assertEquals("content-" + i, contents.get("nested/" + i + ".txt"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +1,28 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
|
|
||||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
|
||||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||||
import com.lion.lionwebsite.Domain.ShareFile;
|
|
||||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.io.StringWriter;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Calendar;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 个人文件服务的文件系统行为。
|
* 个人管理服务在移除文件管理后的剩余契约:订阅时间、家里 IP 与留言转发。
|
||||||
* 全部操作指向 JUnit 的临时目录(通过 setStoragePath 重定向),绝不触碰线上 /storage,
|
* 文件浏览/上传/分享/打包/删除相关行为已随接口一并下线。
|
||||||
* 重点覆盖目录列举、分享码生命周期与删除/打包等破坏性操作的成功与失败两侧。
|
|
||||||
*/
|
*/
|
||||||
class PersonalServiceTest {
|
class PersonalServiceTest {
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path storage;
|
|
||||||
|
|
||||||
private CustomConfigurationMapper configurationMapper;
|
private CustomConfigurationMapper configurationMapper;
|
||||||
private UserMapper userMapper;
|
|
||||||
private ShareFileMapper shareFileMapper;
|
|
||||||
private TaskHandlerInterceptor interceptor;
|
|
||||||
private PushService pushService;
|
private PushService pushService;
|
||||||
private PersonalService service;
|
private PersonalService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
configurationMapper = mock(CustomConfigurationMapper.class);
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
userMapper = mock(UserMapper.class);
|
|
||||||
shareFileMapper = mock(ShareFileMapper.class);
|
|
||||||
interceptor = mock(TaskHandlerInterceptor.class);
|
|
||||||
pushService = mock(PushService.class);
|
pushService = mock(PushService.class);
|
||||||
service = new PersonalService(configurationMapper, userMapper, shareFileMapper,
|
service = new PersonalService(configurationMapper, pushService);
|
||||||
interceptor, pushService);
|
|
||||||
// 把根目录从 /storage/ 重定向到临时目录,确保测试隔离
|
|
||||||
service.setStoragePath(storage.toString() + "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
service.getCompressThreadPool().shutdownNow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CustomConfiguration 只有 @Data,没有全参构造,测试里用 setter 组装。 */
|
/** CustomConfiguration 只有 @Data,没有全参构造,测试里用 setter 组装。 */
|
||||||
@@ -76,268 +37,6 @@ class PersonalServiceTest {
|
|||||||
return json.contains("\"result\":\"success\"");
|
return json.contains("\"result\":\"success\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShareFile share(String code, String path, Date expire) {
|
|
||||||
ShareFile sf = new ShareFile();
|
|
||||||
sf.setShareCode(code);
|
|
||||||
sf.setFilePath(path);
|
|
||||||
sf.setExpireTime(expire);
|
|
||||||
return sf;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Date hoursFromNow(int hours) {
|
|
||||||
Calendar c = Calendar.getInstance();
|
|
||||||
c.add(Calendar.HOUR_OF_DAY, hours);
|
|
||||||
return c.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- getFiles ----------
|
|
||||||
|
|
||||||
/** 目录列举:文件与子目录都要出现,且带类型标记;文件还要带大小。 */
|
|
||||||
@Test
|
|
||||||
void getFilesListsFilesAndFoldersWithMetadata() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("docs"));
|
|
||||||
Files.writeString(dir.resolve("b.txt"), "hello");
|
|
||||||
Files.createDirectory(dir.resolve("sub"));
|
|
||||||
|
|
||||||
when(shareFileMapper.selectShareFilesByFilePath(anyString())).thenReturn(new ArrayList<>());
|
|
||||||
|
|
||||||
String json = service.getFiles("docs");
|
|
||||||
|
|
||||||
assertTrue(ok(json), "实际输出: " + json);
|
|
||||||
assertTrue(json.contains("b.txt"));
|
|
||||||
assertTrue(json.contains("sub"));
|
|
||||||
assertTrue(json.contains("FOLDER"));
|
|
||||||
assertTrue(json.contains("FILE"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已分享且未过期的文件应带上分享码与过期时间。 */
|
|
||||||
@Test
|
|
||||||
void getFilesAttachesActiveShareCode() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("docs"));
|
|
||||||
Path file = Files.writeString(dir.resolve("shared.txt"), "x");
|
|
||||||
when(shareFileMapper.selectShareFilesByFilePath(anyString()))
|
|
||||||
.thenReturn(new ArrayList<>(List.of(
|
|
||||||
share("CODE1234", file.toFile().getAbsolutePath(), hoursFromNow(5)))));
|
|
||||||
|
|
||||||
String json = service.getFiles("docs");
|
|
||||||
|
|
||||||
assertTrue(json.contains("CODE1234"), "应带上分享码: " + json);
|
|
||||||
assertTrue(json.contains("expireTime"));
|
|
||||||
verify(shareFileMapper, never()).deleteShareFile(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已过期的分享码在列举时就地清理,且不出现在结果里。 */
|
|
||||||
@Test
|
|
||||||
void getFilesPurgesExpiredShareCode() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("docs"));
|
|
||||||
Path file = Files.writeString(dir.resolve("stale.txt"), "x");
|
|
||||||
when(shareFileMapper.selectShareFilesByFilePath(anyString()))
|
|
||||||
.thenReturn(new ArrayList<>(List.of(
|
|
||||||
share("OLDCODE1", file.toFile().getAbsolutePath(), hoursFromNow(-1)))));
|
|
||||||
|
|
||||||
String json = service.getFiles("docs");
|
|
||||||
|
|
||||||
assertFalse(json.contains("OLDCODE1"), "过期分享码不应返回");
|
|
||||||
verify(shareFileMapper).deleteShareFile("OLDCODE1");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 路径不是目录时只回空响应(既不 success 也不 failure),沿用既有行为。 */
|
|
||||||
@Test
|
|
||||||
void getFilesReturnsEmptyResponseForNonDirectory() throws Exception {
|
|
||||||
Files.writeString(storage.resolve("plain.txt"), "x");
|
|
||||||
|
|
||||||
String json = service.getFiles("plain.txt");
|
|
||||||
|
|
||||||
assertEquals("{}", json, "非目录路径应返回空对象,实际: " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 空目录返回「成功 + 空列表」(前端据此显示一个空列表,只剩「返回上级」项),
|
|
||||||
* 这是既有契约,不应改动为失败。
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getFilesReturnsEmptyListForEmptyDirectory() throws Exception {
|
|
||||||
Files.createDirectory(storage.resolve("empty"));
|
|
||||||
|
|
||||||
String json = service.getFiles("empty");
|
|
||||||
|
|
||||||
assertTrue(ok(json), "实际输出: " + json);
|
|
||||||
assertTrue(json.contains("\"data\":\"[]\""), "实际输出: " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** path 中的 URL 编码必须被还原后才能定位真实文件。 */
|
|
||||||
@Test
|
|
||||||
void getFilesDecodesUrlEncodedPath() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("my docs"));
|
|
||||||
Files.writeString(dir.resolve("a.txt"), "x");
|
|
||||||
when(shareFileMapper.selectShareFilesByFilePath(anyString())).thenReturn(new ArrayList<>());
|
|
||||||
|
|
||||||
String json = service.getFiles("my%20docs");
|
|
||||||
|
|
||||||
assertTrue(ok(json), "URL 编码路径应能定位到 'my docs': " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- download ----------
|
|
||||||
|
|
||||||
/** 文件不存在时走 response.getWriter() 直接输出 404 文本(不是 HTTP 状态码)。 */
|
|
||||||
@Test
|
|
||||||
void downloadWrites404WhenFileMissing() throws Exception {
|
|
||||||
var request = mock(HttpServletRequest.class);
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var sink = new StringWriter();
|
|
||||||
when(response.getWriter()).thenReturn(new PrintWriter(sink));
|
|
||||||
|
|
||||||
service.download(request, response, storage.resolve("missing.txt").toString());
|
|
||||||
|
|
||||||
assertEquals("404 NOT FOUND", sink.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文件存在时交给 FileDownload 导出,不应写 404 文本。 */
|
|
||||||
@Test
|
|
||||||
void downloadExportsExistingFile() throws Exception {
|
|
||||||
Path file = Files.writeString(storage.resolve("real.txt"), "payload");
|
|
||||||
var request = mock(HttpServletRequest.class);
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var sink = new StringWriter();
|
|
||||||
when(response.getWriter()).thenReturn(new PrintWriter(sink));
|
|
||||||
when(request.getHeader("Range")).thenReturn(null);
|
|
||||||
when(request.getMethod()).thenReturn("GET");
|
|
||||||
when(request.getServletContext()).thenReturn(mock(jakarta.servlet.ServletContext.class));
|
|
||||||
when(response.getOutputStream()).thenReturn(new jakarta.servlet.ServletOutputStream() {
|
|
||||||
@Override public boolean isReady() { return true; }
|
|
||||||
@Override public void setWriteListener(jakarta.servlet.WriteListener l) { }
|
|
||||||
@Override public void write(int b) { }
|
|
||||||
});
|
|
||||||
|
|
||||||
service.download(request, response, file.toString());
|
|
||||||
|
|
||||||
assertEquals("", sink.toString(), "不应输出 404 文本");
|
|
||||||
verify(response).setStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- uploadFile ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadRejectsIncompleteParameters() {
|
|
||||||
var file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes());
|
|
||||||
|
|
||||||
assertFalse(ok(service.uploadFile(null, "a.txt", file)));
|
|
||||||
assertFalse(ok(service.uploadFile("docs", null, file)));
|
|
||||||
assertFalse(ok(service.uploadFile("docs", "a.txt", null)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadRejectsNonExistentDirectory() {
|
|
||||||
var file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes());
|
|
||||||
|
|
||||||
String json = service.uploadFile("no-such-dir", "a.txt", file);
|
|
||||||
|
|
||||||
assertFalse(ok(json));
|
|
||||||
assertTrue(json.contains("该路径不存在或者不是文件夹"), "实际输出: " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadWritesFileIntoDirectory() throws Exception {
|
|
||||||
Files.createDirectory(storage.resolve("docs"));
|
|
||||||
var file = new MockMultipartFile("file", "a.txt", "text/plain", "payload".getBytes());
|
|
||||||
|
|
||||||
assertTrue(ok(service.uploadFile("docs", "a.txt", file)));
|
|
||||||
assertEquals("payload", Files.readString(storage.resolve("docs/a.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同名文件必须拒绝,避免静默覆盖已有数据。 */
|
|
||||||
@Test
|
|
||||||
void uploadRefusesToOverwriteExistingFile() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("docs"));
|
|
||||||
Files.writeString(dir.resolve("a.txt"), "original");
|
|
||||||
var file = new MockMultipartFile("file", "a.txt", "text/plain", "new".getBytes());
|
|
||||||
|
|
||||||
String json = service.uploadFile("docs", "a.txt", file);
|
|
||||||
|
|
||||||
assertFalse(ok(json));
|
|
||||||
assertTrue(json.contains("目标文件已存在"), "实际输出: " + json);
|
|
||||||
assertEquals("original", Files.readString(dir.resolve("a.txt")), "原文件不应被覆盖");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- shareFile ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shareFileGeneratesEightCharCodeForExistingFile() throws Exception {
|
|
||||||
Path file = Files.writeString(storage.resolve("a.txt"), "x");
|
|
||||||
|
|
||||||
String json = service.shareFile(file.toString(), 24);
|
|
||||||
|
|
||||||
assertTrue(ok(json), "实际输出: " + json);
|
|
||||||
var captor = org.mockito.ArgumentCaptor.forClass(String.class);
|
|
||||||
verify(shareFileMapper).insertShareFile(captor.capture(), eq(file.toString()), any(Date.class));
|
|
||||||
assertEquals(8, captor.getValue().length(), "分享码应为 8 位");
|
|
||||||
assertTrue(json.contains("shareCode"));
|
|
||||||
assertTrue(json.contains("expireTime"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文件夹或不存在路径不能生成分享码。 */
|
|
||||||
@Test
|
|
||||||
void shareFileRejectsDirectoryAndMissingPath() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("docs"));
|
|
||||||
|
|
||||||
String forDir = service.shareFile(dir.toString(), 1);
|
|
||||||
assertFalse(ok(forDir));
|
|
||||||
assertTrue(forDir.contains("此路径为文件夹或不存在"));
|
|
||||||
|
|
||||||
String missing = service.shareFile(storage.resolve("nope.txt").toString(), 1);
|
|
||||||
assertFalse(ok(missing));
|
|
||||||
|
|
||||||
verify(shareFileMapper, never()).insertShareFile(anyString(), anyString(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- extendShareTime ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void extendShareTimePushesExpiryForward() {
|
|
||||||
ShareFile existing = share("CODE1234", "/x/a.txt", hoursFromNow(1));
|
|
||||||
when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")).thenReturn(existing);
|
|
||||||
|
|
||||||
String json = service.extendShareTime("/x/a.txt", 5);
|
|
||||||
|
|
||||||
assertTrue(ok(json), "实际输出: " + json);
|
|
||||||
assertTrue(existing.getExpireTime().after(hoursFromNow(4)), "过期时间应被延后");
|
|
||||||
verify(shareFileMapper).updateShareFile(existing);
|
|
||||||
assertTrue(json.contains("expireTime"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void extendShareTimeRejectsUnsharedPath() {
|
|
||||||
when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")).thenReturn(null);
|
|
||||||
|
|
||||||
String json = service.extendShareTime("/x/a.txt", 5);
|
|
||||||
|
|
||||||
assertFalse(ok(json));
|
|
||||||
assertTrue(json.contains("该文件未被分享"));
|
|
||||||
verify(shareFileMapper, never()).updateShareFile(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- cancelShare ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cancelShareDeletesByShareCode() {
|
|
||||||
when(shareFileMapper.selectShareFileByFilePath("/x/a.txt"))
|
|
||||||
.thenReturn(share("CODE1234", "/x/a.txt", hoursFromNow(1)));
|
|
||||||
|
|
||||||
assertTrue(ok(service.cancelShare("/x/a.txt")));
|
|
||||||
verify(shareFileMapper).deleteShareFile("CODE1234");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cancelShareRejectsUnsharedPath() {
|
|
||||||
when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")).thenReturn(null);
|
|
||||||
|
|
||||||
String json = service.cancelShare("/x/a.txt");
|
|
||||||
|
|
||||||
assertFalse(ok(json));
|
|
||||||
assertTrue(json.contains("该文件未被分享"));
|
|
||||||
verify(shareFileMapper, never()).deleteShareFile(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- lastUpdate / getIp ----------
|
// ---------- lastUpdate / getIp ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -364,79 +63,6 @@ class PersonalServiceTest {
|
|||||||
assertTrue(json.contains("203.0.113.7"));
|
assertTrue(json.contains("203.0.113.7"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- compress ----------
|
|
||||||
|
|
||||||
/** 打包是异步的:接口本身立即返回成功,结果由后续轮询文件是否存在得知。 */
|
|
||||||
@Test
|
|
||||||
void compressQueuesDirectoryAndProducesTar() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("pack"));
|
|
||||||
Files.writeString(dir.resolve("a.txt"), "content");
|
|
||||||
|
|
||||||
String json = service.compress(dir.toString());
|
|
||||||
|
|
||||||
assertTrue(ok(json), "实际输出: " + json);
|
|
||||||
assertTrue(json.contains("加入队列成功"));
|
|
||||||
|
|
||||||
Path tar = storage.resolve("pack.tar");
|
|
||||||
for (int i = 0; i < 100 && !Files.exists(tar); i++)
|
|
||||||
Thread.sleep(50);
|
|
||||||
assertTrue(Files.exists(tar), "应在后台生成 pack.tar");
|
|
||||||
assertTrue(Files.size(tar) > 0);
|
|
||||||
assertFalse(Files.exists(storage.resolve("pack.tar***undone")), "临时文件应被清理");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 选中的不是文件夹时必须同步拒绝,不占用线程池。 */
|
|
||||||
@Test
|
|
||||||
void compressRejectsNonDirectory() throws Exception {
|
|
||||||
Path file = Files.writeString(storage.resolve("a.txt"), "x");
|
|
||||||
|
|
||||||
String json = service.compress(file.toString());
|
|
||||||
|
|
||||||
assertFalse(ok(json));
|
|
||||||
assertTrue(json.contains("选中的路径不是文件夹"), "实际输出: " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- deleteFile ----------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteFileRemovesTarget() throws Exception {
|
|
||||||
Path file = Files.writeString(storage.resolve("gone.txt"), "x");
|
|
||||||
|
|
||||||
assertTrue(ok(service.deleteFile(file.toString())));
|
|
||||||
assertFalse(Files.exists(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除不存在的路径必须回业务失败。
|
|
||||||
* 修复前直接依据 hutool `FileUtil.del` 的返回值(对不存在目标返回 true)报「删除成功」,
|
|
||||||
* 会让路径写错/文件已被删的情况也显示成功,误导用户。
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void deleteFileReportsFailureForMissingPath() {
|
|
||||||
String json = service.deleteFile(storage.resolve("never-existed.txt").toString());
|
|
||||||
|
|
||||||
assertFalse(ok(json), "实际输出: " + json);
|
|
||||||
assertTrue(json.contains("文件不存在"), "实际输出: " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 空目录同样不存在(不是文件),删除应回失败。 */
|
|
||||||
@Test
|
|
||||||
void deleteFileReportsFailureForMissingDirectory() {
|
|
||||||
String json = service.deleteFile(storage.resolve("no-such-dir").toString());
|
|
||||||
|
|
||||||
assertFalse(ok(json), "实际输出: " + json);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除目录要连同其内容一起移除。 */
|
|
||||||
@Test
|
|
||||||
void deleteFileRemovesDirectoryRecursively() throws Exception {
|
|
||||||
Path dir = Files.createDirectory(storage.resolve("tree"));
|
|
||||||
Files.writeString(dir.resolve("inner.txt"), "x");
|
|
||||||
|
|
||||||
assertTrue(ok(service.deleteFile(dir.toString())));
|
|
||||||
assertFalse(Files.exists(dir));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- message2me ----------
|
// ---------- message2me ----------
|
||||||
|
|
||||||
/** 留言必须原样转发到 Telegram,且返回成功。 */
|
/** 留言必须原样转发到 Telegram,且返回成功。 */
|
||||||
|
|||||||
@@ -1,36 +1,23 @@
|
|||||||
package com.lion.lionwebsite.Service;
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
|
||||||
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
|
|
||||||
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
import com.lion.lionwebsite.Dao.normal.UserMapper;
|
||||||
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
import com.lion.lionwebsite.Domain.CustomConfiguration;
|
||||||
import com.lion.lionwebsite.Domain.ShareFile;
|
|
||||||
import com.lion.lionwebsite.Domain.User;
|
import com.lion.lionwebsite.Domain.User;
|
||||||
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
|
||||||
import jakarta.servlet.ServletOutputStream;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Calendar;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公开接口侧的服务:IP 记录、分享码取文件、改授权码。
|
* 公开服务在移除分享取件后的剩余契约:家里 IP 记录、改授权码与身份查询。
|
||||||
* GetFile 是唯一的「拿分享码换文件」入口,过期/不存在/文件被删三条路径都要
|
* 分享码换文件的 GetFile 已随文件分享功能下线。
|
||||||
* 既拒绝下载又清掉失效分享码。
|
|
||||||
*/
|
*/
|
||||||
class PublicServiceTest {
|
class PublicServiceTest {
|
||||||
|
|
||||||
private CustomConfigurationMapper configurationMapper;
|
private CustomConfigurationMapper configurationMapper;
|
||||||
private ShareFileMapper shareFileMapper;
|
|
||||||
private UserMapper userMapper;
|
private UserMapper userMapper;
|
||||||
private TaskHandlerInterceptor interceptor;
|
private TaskHandlerInterceptor interceptor;
|
||||||
private PublicService service;
|
private PublicService service;
|
||||||
@@ -38,153 +25,46 @@ class PublicServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
configurationMapper = mock(CustomConfigurationMapper.class);
|
configurationMapper = mock(CustomConfigurationMapper.class);
|
||||||
shareFileMapper = mock(ShareFileMapper.class);
|
|
||||||
userMapper = mock(UserMapper.class);
|
userMapper = mock(UserMapper.class);
|
||||||
interceptor = mock(TaskHandlerInterceptor.class);
|
interceptor = mock(TaskHandlerInterceptor.class);
|
||||||
service = new PublicService(configurationMapper, shareFileMapper, userMapper, interceptor);
|
service = new PublicService(configurationMapper, userMapper, interceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShareFile share(String code, String path, Date expire) {
|
private static boolean ok(String json) {
|
||||||
ShareFile sf = new ShareFile();
|
return json.contains("\"result\":\"success\"");
|
||||||
sf.setShareCode(code);
|
|
||||||
sf.setFilePath(path);
|
|
||||||
sf.setExpireTime(expire);
|
|
||||||
return sf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Date hoursFromNow(int hours) {
|
// ---------- logIpAddress ----------
|
||||||
Calendar c = Calendar.getInstance();
|
|
||||||
c.add(Calendar.HOUR_OF_DAY, hours);
|
|
||||||
return c.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 记录家里 IP 应同时写入地址与时间两个配置项。 */
|
/** 记录 IP 时同时写地址与观测时间,两处都要落库。 */
|
||||||
@Test
|
@Test
|
||||||
void logIpAddressWritesBothAddressAndTime() {
|
void logIpAddressWritesBothAddressAndTime() {
|
||||||
service.logIpAddress("203.0.113.7");
|
service.logIpAddress("203.0.113.7");
|
||||||
|
|
||||||
verify(configurationMapper).updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, "203.0.113.7");
|
verify(configurationMapper).updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, "203.0.113.7");
|
||||||
verify(configurationMapper).updateConfiguration(eq(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME), anyString());
|
verify(configurationMapper)
|
||||||
|
.updateConfiguration(eq(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- alterAuthCode ----------
|
||||||
|
|
||||||
|
/** 改码成功后必须刷新拦截器里的授权码缓存,否则新码要等重启才生效。 */
|
||||||
@Test
|
@Test
|
||||||
void alterAuthCodeUpdatesUserAndRefreshesCache() {
|
void alterAuthCodeUpdatesCache() {
|
||||||
String json = service.alterAuthCode("old", "new");
|
String json = service.alterAuthCode("old", "new");
|
||||||
|
|
||||||
assertTrue(json.contains("\"result\":\"success\""));
|
|
||||||
verify(userMapper).updateAuthCode("old", "new");
|
verify(userMapper).updateAuthCode("old", "new");
|
||||||
verify(interceptor).updateAuthCodes();
|
verify(interceptor).updateAuthCodes();
|
||||||
|
assertTrue(ok(json), "实际输出: " + json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- getUserId ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getUserIdReturnsMappedUser() {
|
void getUserIdDelegatesToMapper() {
|
||||||
User u = new User(3, "code", "alice", null, true);
|
User expected = new User(7, "code", "alice", null, true);
|
||||||
when(userMapper.selectUserByAuthCode("code")).thenReturn(u);
|
when(userMapper.selectUserByAuthCode("code")).thenReturn(expected);
|
||||||
|
|
||||||
assertSame(u, service.getUserId("code"));
|
assertSame(expected, service.getUserId("code"));
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- GetFile ----------
|
|
||||||
|
|
||||||
/** ShareCode 为空时只回业务失败,不查库。 */
|
|
||||||
@Test
|
|
||||||
void getFileRejectsNullShareCode() throws Exception {
|
|
||||||
var request = mock(HttpServletRequest.class);
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var out = new ByteArrayOutputStream();
|
|
||||||
when(response.getOutputStream()).thenReturn(servletOutputStream(out));
|
|
||||||
|
|
||||||
assertFalse(service.GetFile(request, response, null));
|
|
||||||
|
|
||||||
assertTrue(out.toString(StandardCharsets.UTF_8).contains("ShareCode invalid"));
|
|
||||||
verify(shareFileMapper, never()).selectShareFileByShareCode(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分享码不存在时回失败,且不应误删任何记录。 */
|
|
||||||
@Test
|
|
||||||
void getFileRejectsUnknownShareCode() throws Exception {
|
|
||||||
when(shareFileMapper.selectShareFileByShareCode("nope")).thenReturn(null);
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var out = new ByteArrayOutputStream();
|
|
||||||
when(response.getOutputStream()).thenReturn(servletOutputStream(out));
|
|
||||||
|
|
||||||
assertFalse(service.GetFile(mock(HttpServletRequest.class), response, "nope"));
|
|
||||||
|
|
||||||
assertTrue(out.toString(StandardCharsets.UTF_8).contains("ShareCode is not exist or expired"));
|
|
||||||
verify(shareFileMapper, never()).deleteShareFile(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已过期的分享码必须删除记录,避免脏数据累积。 */
|
|
||||||
@Test
|
|
||||||
void getFileDeletesExpiredShare() throws Exception {
|
|
||||||
when(shareFileMapper.selectShareFileByShareCode("old"))
|
|
||||||
.thenReturn(share("old", "/tmp/whatever.txt", hoursFromNow(-1)));
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var out = new ByteArrayOutputStream();
|
|
||||||
when(response.getOutputStream()).thenReturn(servletOutputStream(out));
|
|
||||||
|
|
||||||
assertFalse(service.GetFile(mock(HttpServletRequest.class), response, "old"));
|
|
||||||
|
|
||||||
assertTrue(out.toString(StandardCharsets.UTF_8).contains("ShareCode is expired or File is not exist"));
|
|
||||||
verify(shareFileMapper).deleteShareFile("old");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 未过期但文件已被删,同样要清掉分享码。 */
|
|
||||||
@Test
|
|
||||||
void getFileDeletesShareWhenFileMissing() throws Exception {
|
|
||||||
when(shareFileMapper.selectShareFileByShareCode("gone"))
|
|
||||||
.thenReturn(share("gone", "/nonexistent/definitely/missing.txt", hoursFromNow(5)));
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var out = new ByteArrayOutputStream();
|
|
||||||
when(response.getOutputStream()).thenReturn(servletOutputStream(out));
|
|
||||||
|
|
||||||
assertFalse(service.GetFile(mock(HttpServletRequest.class), response, "gone"));
|
|
||||||
|
|
||||||
verify(shareFileMapper).deleteShareFile("gone");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 有效分享码 + 存在的文件:应导出文件并返回 true,且不得删除记录。 */
|
|
||||||
@Test
|
|
||||||
void getFileServesValidShare() throws Exception {
|
|
||||||
java.nio.file.Path file = java.nio.file.Files.createTempFile("share-test", ".txt");
|
|
||||||
java.nio.file.Files.writeString(file, "hello share");
|
|
||||||
try {
|
|
||||||
when(shareFileMapper.selectShareFileByShareCode("good"))
|
|
||||||
.thenReturn(share("good", file.toString(), hoursFromNow(5)));
|
|
||||||
|
|
||||||
var request = mock(HttpServletRequest.class);
|
|
||||||
var response = mock(HttpServletResponse.class);
|
|
||||||
var out = new ByteArrayOutputStream();
|
|
||||||
when(request.getHeader("Range")).thenReturn(null);
|
|
||||||
when(request.getMethod()).thenReturn("GET");
|
|
||||||
when(request.getServletContext()).thenReturn(mock(jakarta.servlet.ServletContext.class));
|
|
||||||
when(response.getOutputStream()).thenReturn(servletOutputStream(out));
|
|
||||||
|
|
||||||
assertTrue(service.GetFile(request, response, "good"));
|
|
||||||
|
|
||||||
assertEquals("hello share", out.toString(StandardCharsets.UTF_8));
|
|
||||||
verify(shareFileMapper, never()).deleteShareFile(anyString());
|
|
||||||
} finally {
|
|
||||||
java.nio.file.Files.deleteIfExists(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用 ByteArrayOutputStream 包一个最小 ServletOutputStream,避免依赖容器实现。 */
|
|
||||||
private static ServletOutputStream servletOutputStream(ByteArrayOutputStream sink) {
|
|
||||||
return new ServletOutputStream() {
|
|
||||||
@Override
|
|
||||||
public boolean isReady() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setWriteListener(jakarta.servlet.WriteListener listener) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void write(int b) {
|
|
||||||
sink.write(b);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user