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

- 删除 /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
@@ -22,7 +22,7 @@ import java.util.Random;
@Configuration
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.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,
Chat.class, MessageEntity.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.PersonalService;
import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@@ -31,46 +29,6 @@ public class PersonalController {
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")
public String updateSub() throws IOException {
Response response = Response.generateResponse();
@@ -16,16 +16,12 @@ import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedList;
import java.util.List;
@RestController
@Slf4j
@RequiredArgsConstructor
public class PublicController {
final List<String> black_share_codes = new LinkedList<>();
final PublicService publicService;
final RemoteService remoteService;
@@ -60,24 +56,6 @@ public class PublicController {
subService.updateSub(response, request, client, key);
}
@GetMapping("/GetFile/{path}")
public void getFile(HttpServletRequest request, HttpServletResponse response, String ShareCode, @PathVariable("path") String path) throws IOException {
synchronized (black_share_codes) {
if (black_share_codes.contains(ShareCode))
return;
}
log.info("ShareCode:{}", ShareCode);
log.info("Path:{}", path);
boolean result = publicService.GetFile(request, response, ShareCode);
if(!result)
black_share_codes.add(ShareCode);
if(black_share_codes.size() > 100)
black_share_codes.removeFirst();
}
@PostMapping("/validate")
public String validate(String AuthCode){
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 ShareFileMapper shareFileMapper;
final ImageCacheMapper imageCacheMapper;
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.GalleryMapper;
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.GalleryUtil;
import lombok.Data;
@@ -45,8 +43,6 @@ public class LocalService{
final CustomConfigurationMapper configurationMapper;
final ShareFileMapper shareFileMapper;
final GalleryMapper galleryMapper;
final PushService pushService;
@@ -150,23 +146,6 @@ public class LocalService{
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的部分
*/
@@ -1,43 +1,17 @@
package com.lion.lionwebsite.Service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.RandomUtil;
import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper;
import com.lion.lionwebsite.Dao.normal.ShareFileMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.Response;
import tools.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.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;
import java.util.HashMap;
import java.util.Map;
@Service
@@ -46,221 +20,8 @@ import java.util.concurrent.Executors;
public class PersonalService{
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;
/**
* 获取文件列表,同时带上分享码以及过期时间
* @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 订阅文件上次更新时间
@@ -289,90 +50,6 @@ public class PersonalService{
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){
Response response = Response.generateResponse();
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.ShareFileMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.CustomConfiguration;
import com.lion.lionwebsite.Domain.ShareFile;
import com.lion.lionwebsite.Domain.User;
import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.Response;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
@Service
@RequiredArgsConstructor
public class PublicService {
final CustomConfigurationMapper configurationMapper;
final ShareFileMapper shareFileMapper;
final UserMapper userMapper;
final TaskHandlerInterceptor taskHandlerInterceptor;
@@ -44,41 +32,6 @@ public class PublicService {
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 原来的授权码