移除文件管理与文件分享,接口范围收敛到订阅与用户管理

- 删除 /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:
root
2026-09-21 10:02:07 +08:00
parent 305036ddfe
commit bee2f11fd6
20 changed files with 136 additions and 1228 deletions
+89
View File
@@ -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
View File
@@ -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);
}
};
} }
} }