diff --git a/schoolNewsServ/.bin/mysql/sql/createTableFile.sql b/schoolNewsServ/.bin/mysql/sql/createTableFile.sql new file mode 100644 index 0000000..a076c87 --- /dev/null +++ b/schoolNewsServ/.bin/mysql/sql/createTableFile.sql @@ -0,0 +1,31 @@ +-- -------------------------------------------------------- +-- 文件上传记录表 +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tb_sys_file` ( + `id` VARCHAR(50) NOT NULL COMMENT '主键ID', + `file_id` VARCHAR(64) NOT NULL COMMENT '文件ID', + `file_name` VARCHAR(255) NOT NULL COMMENT '存储文件名(UUID生成)', + `original_name` VARCHAR(255) NOT NULL COMMENT '原始文件名', + `file_path` VARCHAR(500) NOT NULL COMMENT '文件存储路径', + `file_url` VARCHAR(500) DEFAULT NULL COMMENT '文件访问URL', + `file_size` BIGINT NOT NULL COMMENT '文件大小(字节)', + `file_type` VARCHAR(50) DEFAULT NULL COMMENT '文件类型(如:image、document、video等)', + `mime_type` VARCHAR(100) DEFAULT NULL COMMENT 'MIME类型(如:image/jpeg)', + `storage_type` VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '存储类型(local-本地存储、minio-MinIO存储、oss-阿里云OSS等)', + `module` VARCHAR(50) DEFAULT NULL COMMENT '所属模块(如:user、news、course等)', + `business_id` VARCHAR(64) DEFAULT NULL COMMENT '业务ID(关联的业务数据ID)', + `uploader` VARCHAR(64) DEFAULT NULL COMMENT '上传者用户ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` DATETIME DEFAULT NULL COMMENT '删除时间', + `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除(0-否,1-是)', + PRIMARY KEY (`id`), + INDEX `idx_file_id` (`file_id`), + INDEX `idx_file_name` (`file_name`), + INDEX `idx_uploader` (`uploader`), + INDEX `idx_module_business` (`module`, `business_id`), + INDEX `idx_storage_type` (`storage_type`), + INDEX `idx_deleted` (`deleted`), + INDEX `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件上传记录表'; + diff --git a/schoolNewsServ/api/api-file/pom.xml b/schoolNewsServ/api/api-file/pom.xml new file mode 100644 index 0000000..cd324d2 --- /dev/null +++ b/schoolNewsServ/api/api-file/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + org.xyzh + api + ${school-news.version} + + + org.xyzh + api-file + ${school-news.version} + jar + api-file + 文件模块API + + + 21 + 21 + + + \ No newline at end of file diff --git a/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java b/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java new file mode 100644 index 0000000..b14f4c2 --- /dev/null +++ b/schoolNewsServ/api/api-file/src/main/java/org/xyzh/api/file/FileService.java @@ -0,0 +1,124 @@ +package org.xyzh.api.file; + +import org.springframework.web.multipart.MultipartFile; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.system.TbSysFile; + +/** + * @description 文件服务接口 + * @filename FileService.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +public interface FileService { + + /** + * @description 上传文件 + * @param file 文件对象 + * @param module 所属模块 + * @param businessId 业务ID + * @return ResultDomain 上传结果,包含文件信息 + * @author system + * @since 2025-10-16 + */ + ResultDomain uploadFile(MultipartFile file, String module, String businessId); + + /** + * @description 上传文件(带上传者信息) + * @param file 文件对象 + * @param module 所属模块 + * @param businessId 业务ID + * @param uploader 上传者用户ID + * @return ResultDomain 上传结果,包含文件信息 + * @author system + * @since 2025-10-16 + */ + ResultDomain uploadFile(MultipartFile file, String module, String businessId, String uploader); + + /** + * @description 下载文件 + * @param fileId 文件ID + * @return ResultDomain 文件字节数组 + * @author system + * @since 2025-10-16 + */ + ResultDomain downloadFile(String fileId); + + /** + * @description 删除文件(逻辑删除) + * @param fileId 文件ID + * @return ResultDomain 删除结果 + * @author system + * @since 2025-10-16 + */ + ResultDomain deleteFile(String fileId); + + /** + * @description 物理删除文件(同时删除存储和数据库记录) + * @param fileId 文件ID + * @return ResultDomain 删除结果 + * @author system + * @since 2025-10-16 + */ + ResultDomain deleteFilePhysically(String fileId); + + /** + * @description 根据文件ID查询文件信息 + * @param fileId 文件ID + * @return ResultDomain 文件信息 + * @author system + * @since 2025-10-16 + */ + ResultDomain getFileById(String fileId); + + /** + * @description 根据业务ID查询文件列表 + * @param module 所属模块 + * @param businessId 业务ID + * @return ResultDomain 文件列表 + * @author system + * @since 2025-10-16 + */ + ResultDomain getFilesByBusinessId(String module, String businessId); + + /** + * @description 根据上传者查询文件列表 + * @param uploader 上传者用户ID + * @return ResultDomain 文件列表 + * @author system + * @since 2025-10-16 + */ + ResultDomain getFilesByUploader(String uploader); + + /** + * @description 获取文件访问URL + * @param fileId 文件ID + * @return ResultDomain 文件访问URL + * @author system + * @since 2025-10-16 + */ + ResultDomain getFileUrl(String fileId); + + /** + * @description 批量上传文件 + * @param files 文件对象列表 + * @param module 所属模块 + * @param businessId 业务ID + * @param uploader 上传者用户ID(可选) + * @return ResultDomain 上传结果,包含文件信息列表 + * @author system + * @since 2025-10-16 + */ + ResultDomain batchUploadFiles(MultipartFile[] files, String module, String businessId, String uploader); + + /** + * @description 批量删除文件(逻辑删除) + * @param fileIds 文件ID列表 + * @return ResultDomain 删除结果 + * @author system + * @since 2025-10-16 + */ + ResultDomain batchDeleteFiles(String[] fileIds); +} + diff --git a/schoolNewsServ/api/pom.xml b/schoolNewsServ/api/pom.xml index 2cf0650..a1ecc67 100644 --- a/schoolNewsServ/api/pom.xml +++ b/schoolNewsServ/api/pom.xml @@ -21,6 +21,7 @@ api-ai api-study api-news + api-file diff --git a/schoolNewsServ/file/pom.xml b/schoolNewsServ/file/pom.xml new file mode 100644 index 0000000..26ea5dd --- /dev/null +++ b/schoolNewsServ/file/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + org.xyzh + school-news + ${school-news.version} + + + org.xyzh + file + ${school-news.version} + jar + file + 文件模块 + + + 21 + 21 + 8.5.7 + + + + + + org.xyzh + api-file + ${school-news.version} + + + + + org.xyzh + common-all + ${school-news.version} + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + + + com.mysql + mysql-connector-j + runtime + + + + + io.minio + minio + ${minio.version} + + + + + org.projectlombok + lombok + provided + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + \ No newline at end of file diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/config/FileStorageConfig.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/config/FileStorageConfig.java new file mode 100644 index 0000000..390df04 --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/config/FileStorageConfig.java @@ -0,0 +1,78 @@ +package org.xyzh.file.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * @description 文件存储配置类 + * @filename FileStorageConfig.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Data +@Component +@ConfigurationProperties(prefix = "file.storage") +public class FileStorageConfig { + + /** + * @description 默认存储类型 + */ + private String defaultType = "local"; + + /** + * @description 存储配置列表 + */ + private List storages = new ArrayList<>(); + + /** + * @description 单个存储配置 + */ + @Data + public static class StorageProperties { + /** + * @description 存储类型(local、minio) + */ + private String type; + + /** + * @description 是否启用 + */ + private Boolean enabled = true; + + /** + * @description 本地存储基础路径 + */ + private String basePath; + + /** + * @description 本地存储URL前缀 + */ + private String urlPrefix; + + /** + * @description MinIO端点 + */ + private String endpoint; + + /** + * @description MinIO访问密钥 + */ + private String accessKey; + + /** + * @description MinIO密钥 + */ + private String secretKey; + + /** + * @description MinIO桶名称 + */ + private String bucketName; + } +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/controller/FileController.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/controller/FileController.java new file mode 100644 index 0000000..08a1105 --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/controller/FileController.java @@ -0,0 +1,203 @@ +package org.xyzh.file.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.xyzh.api.file.FileService; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.system.TbSysFile; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * @description 文件控制器 + * @filename FileController.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Slf4j +@RestController +@RequestMapping("/file") +public class FileController { + + @Autowired + private FileService fileService; + + /** + * @description 上传文件 + * @param file 文件对象 + * @param module 所属模块 + * @param businessId 业务ID + * @param uploader 上传者用户ID(可选) + * @return ResultDomain 上传结果 + * @author system + * @since 2025-10-16 + */ + @PostMapping("/upload") + public ResultDomain uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "module", required = false, defaultValue = "common") String module, + @RequestParam(value = "businessId", required = false) String businessId, + @RequestParam(value = "uploader", required = false) String uploader) { + + log.info("上传文件请求: module={}, businessId={}, uploader={}, fileName={}", + module, businessId, uploader, file.getOriginalFilename()); + + return fileService.uploadFile(file, module, businessId, uploader); + } + + /** + * @description 下载文件 + * @param fileId 文件ID + * @return ResponseEntity 文件字节数组 + * @author system + * @since 2025-10-16 + */ + @GetMapping("/download/{fileId}") + public ResponseEntity downloadFile(@PathVariable String fileId) { + log.info("下载文件请求: fileId={}", fileId); + + ResultDomain fileResult = fileService.getFileById(fileId); + if (!fileResult.isSuccess() || fileResult.getData() == null) { + return ResponseEntity.notFound().build(); + } + + ResultDomain dataResult = fileService.downloadFile(fileId); + if (!dataResult.isSuccess() || dataResult.getData() == null) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + TbSysFile sysFile = (TbSysFile) fileResult.getData(); + byte[] fileData = (byte[]) dataResult.getData(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType( + sysFile.getMimeType() != null ? sysFile.getMimeType() : MediaType.APPLICATION_OCTET_STREAM_VALUE)); + headers.setContentDispositionFormData("attachment", + URLEncoder.encode(sysFile.getOriginalName(), StandardCharsets.UTF_8)); + headers.setContentLength(fileData.length); + + return new ResponseEntity<>(fileData, headers, HttpStatus.OK); + } + + /** + * @description 删除文件(逻辑删除) + * @param fileId 文件ID + * @return ResultDomain 删除结果 + * @author system + * @since 2025-10-16 + */ + @DeleteMapping("/{fileId}") + public ResultDomain deleteFile(@PathVariable String fileId) { + log.info("删除文件请求: fileId={}", fileId); + return fileService.deleteFile(fileId); + } + + /** + * @description 物理删除文件 + * @param fileId 文件ID + * @return ResultDomain 删除结果 + * @author system + * @since 2025-10-16 + */ + @DeleteMapping("/physical/{fileId}") + public ResultDomain deleteFilePhysically(@PathVariable String fileId) { + log.info("物理删除文件请求: fileId={}", fileId); + return fileService.deleteFilePhysically(fileId); + } + + /** + * @description 根据文件ID查询文件信息 + * @param fileId 文件ID + * @return ResultDomain 文件信息 + * @author system + * @since 2025-10-16 + */ + @GetMapping("/{fileId}") + public ResultDomain getFileById(@PathVariable String fileId) { + return fileService.getFileById(fileId); + } + + /** + * @description 根据业务ID查询文件列表 + * @param module 所属模块 + * @param businessId 业务ID + * @return ResultDomain 文件列表 + * @author system + * @since 2025-10-16 + */ + @GetMapping("/business/{module}/{businessId}") + public ResultDomain getFilesByBusinessId( + @PathVariable String module, + @PathVariable String businessId) { + return fileService.getFilesByBusinessId(module, businessId); + } + + /** + * @description 根据上传者查询文件列表 + * @param uploader 上传者用户ID + * @return ResultDomain 文件列表 + * @author system + * @since 2025-10-16 + */ + @GetMapping("/uploader/{uploader}") + public ResultDomain getFilesByUploader(@PathVariable String uploader) { + return fileService.getFilesByUploader(uploader); + } + + /** + * @description 获取文件访问URL + * @param fileId 文件ID + * @return ResultDomain 文件访问URL + * @author system + * @since 2025-10-16 + */ + @GetMapping("/url/{fileId}") + public ResultDomain getFileUrl(@PathVariable String fileId) { + return fileService.getFileUrl(fileId); + } + + /** + * @description 批量上传文件 + * @param files 文件对象数组 + * @param module 所属模块 + * @param businessId 业务ID + * @param uploader 上传者用户ID(可选) + * @return ResultDomain 上传结果 + * @author system + * @since 2025-10-16 + */ + @PostMapping("/batch-upload") + public ResultDomain batchUploadFiles( + @RequestParam("files") MultipartFile[] files, + @RequestParam(value = "module", required = false, defaultValue = "common") String module, + @RequestParam(value = "businessId", required = false) String businessId, + @RequestParam(value = "uploader", required = false) String uploader) { + + log.info("批量上传文件请求: count={}, module={}, businessId={}, uploader={}", + files != null ? files.length : 0, module, businessId, uploader); + + return fileService.batchUploadFiles(files, module, businessId, uploader); + } + + /** + * @description 批量删除文件 + * @param fileIds 文件ID数组 + * @return ResultDomain 删除结果 + * @author system + * @since 2025-10-16 + */ + @DeleteMapping("/batch") + public ResultDomain batchDeleteFiles(@RequestBody String[] fileIds) { + log.info("批量删除文件请求: count={}", fileIds != null ? fileIds.length : 0); + return fileService.batchDeleteFiles(fileIds); + } +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/mapper/FileMapper.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/mapper/FileMapper.java new file mode 100644 index 0000000..a983eee --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/mapper/FileMapper.java @@ -0,0 +1,93 @@ +package org.xyzh.file.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.xyzh.common.dto.system.TbSysFile; + +import java.util.List; + +/** + * @description 文件Mapper接口 + * @filename FileMapper.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Mapper +public interface FileMapper extends BaseMapper{ + + /** + * @description 插入文件记录 + * @param file 文件对象 + * @return int 影响行数 + * @author system + * @since 2025-10-16 + */ + int insertFile(@Param("file") TbSysFile file); + + /** + * @description 根据文件ID查询文件信息 + * @param fileId 文件ID + * @return TbSysFile 文件信息 + * @author system + * @since 2025-10-16 + */ + TbSysFile selectFileById(@Param("fileId") String fileId); + + /** + * @description 根据文件ID查询文件信息(包括已删除) + * @param fileId 文件ID + * @return TbSysFile 文件信息 + * @author system + * @since 2025-10-16 + */ + TbSysFile selectFileByIdIncludeDeleted(@Param("fileId") String fileId); + + /** + * @description 根据业务ID查询文件列表 + * @param module 所属模块 + * @param businessId 业务ID + * @return List 文件列表 + * @author system + * @since 2025-10-16 + */ + List selectFilesByBusinessId(@Param("module") String module, @Param("businessId") String businessId); + + /** + * @description 根据上传者查询文件列表 + * @param uploader 上传者用户ID + * @return List 文件列表 + * @author system + * @since 2025-10-16 + */ + List selectFilesByUploader(@Param("uploader") String uploader); + + /** + * @description 逻辑删除文件 + * @param fileId 文件ID + * @return int 影响行数 + * @author system + * @since 2025-10-16 + */ + int logicDeleteFileById(@Param("fileId") String fileId); + + /** + * @description 物理删除文件 + * @param fileId 文件ID + * @return int 影响行数 + * @author system + * @since 2025-10-16 + */ + int deleteFileById(@Param("fileId") String fileId); + + /** + * @description 批量逻辑删除文件 + * @param fileIds 文件ID列表 + * @return int 影响行数 + * @author system + * @since 2025-10-16 + */ + int batchLogicDeleteFiles(@Param("fileIds") List fileIds); +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java new file mode 100644 index 0000000..6c65a7f --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/service/FileServiceImpl.java @@ -0,0 +1,375 @@ +package org.xyzh.file.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.xyzh.api.file.FileService; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.system.TbSysFile; +import org.xyzh.common.utils.IDUtils; +import org.xyzh.file.mapper.FileMapper; +import org.xyzh.file.strategy.FileStorageStrategy; +import org.xyzh.file.strategy.FileStorageStrategyFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +/** + * @description 文件服务实现 + * @filename FileServiceImpl.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Slf4j +@Service +public class FileServiceImpl implements FileService { + + @Autowired + private FileMapper fileMapper; + + @Autowired + private FileStorageStrategyFactory strategyFactory; + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain uploadFile(MultipartFile file, String module, String businessId) { + return uploadFile(file, module, businessId, null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain uploadFile(MultipartFile file, String module, String businessId, String uploader) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + if (file == null || file.isEmpty()) { + resultDomain.fail("文件不能为空"); + return resultDomain; + } + + // 获取存储策略 + FileStorageStrategy strategy = strategyFactory.getDefaultStrategy(); + + // 生成文件名 + String originalFileName = file.getOriginalFilename(); + String fileExtension = ""; + if (originalFileName != null && originalFileName.contains(".")) { + fileExtension = originalFileName.substring(originalFileName.lastIndexOf(".")); + } + String fileName = UUID.randomUUID().toString().replace("-", "") + fileExtension; + + // 上传文件 + String filePath = strategy.upload(file, fileName, module); + + // 保存文件记录 + TbSysFile sysFile = new TbSysFile(); + String fileId = IDUtils.generateID(); + sysFile.setID(fileId); + sysFile.setFileID(fileId); + sysFile.setFileName(fileName); + sysFile.setOriginalName(originalFileName); + sysFile.setFilePath(filePath); + sysFile.setFileUrl(strategy.getFileUrl(filePath)); + sysFile.setFileSize(file.getSize()); + sysFile.setMimeType(file.getContentType()); + sysFile.setStorageType(strategy.getStorageType()); + sysFile.setModule(module); + sysFile.setBusinessID(businessId); + sysFile.setUploader(uploader); + sysFile.setCreateTime(new Date()); + sysFile.setUpdateTime(new Date()); + sysFile.setDeleted(false); + + // 判断文件类型 + String mimeType = file.getContentType(); + if (mimeType != null) { + if (mimeType.startsWith("image/")) { + sysFile.setFileType("image"); + } else if (mimeType.startsWith("video/")) { + sysFile.setFileType("video"); + } else if (mimeType.startsWith("audio/")) { + sysFile.setFileType("audio"); + } else if (mimeType.contains("pdf")) { + sysFile.setFileType("pdf"); + } else if (mimeType.contains("word") || mimeType.contains("document")) { + sysFile.setFileType("document"); + } else if (mimeType.contains("excel") || mimeType.contains("spreadsheet")) { + sysFile.setFileType("excel"); + } else { + sysFile.setFileType("other"); + } + } + + fileMapper.insertFile(sysFile); + + log.info("文件上传成功: fileId={}, fileName={}, storageType={}", + sysFile.getID(), fileName, strategy.getStorageType()); + + resultDomain.success("文件上传成功", sysFile); + return resultDomain; + } catch (Exception e) { + log.error("文件上传失败", e); + resultDomain.fail("文件上传失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public ResultDomain downloadFile(String fileId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + TbSysFile sysFile = fileMapper.selectFileById(fileId); + if (sysFile == null || sysFile.getDeleted()) { + resultDomain.fail("文件不存在或已被删除"); + return resultDomain; + } + + // 获取存储策略 + FileStorageStrategy strategy = strategyFactory.getStrategy(sysFile.getStorageType()); + + // 下载文件 + byte[] fileData = strategy.download(sysFile.getFilePath()); + + log.info("文件下载成功: fileId={}, fileName={}", fileId, sysFile.getFileName()); + + resultDomain.success("文件下载成功", fileData); + return resultDomain; + } catch (Exception e) { + log.error("文件下载失败: fileId={}", fileId, e); + resultDomain.fail("文件下载失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain deleteFile(String fileId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + TbSysFile sysFile = fileMapper.selectFileById(fileId); + if (sysFile == null) { + resultDomain.fail("文件不存在"); + return resultDomain; + } + + if (sysFile.getDeleted()) { + resultDomain.fail("文件已被删除"); + return resultDomain; + } + + // 逻辑删除 + int result = fileMapper.logicDeleteFileById(fileId); + if (result > 0) { + log.info("文件逻辑删除成功: fileId={}", fileId); + resultDomain.success("文件删除成功", sysFile); + return resultDomain; + } else { + resultDomain.fail("文件删除失败"); + return resultDomain; + } + } catch (Exception e) { + log.error("文件删除失败: fileId={}", fileId, e); + resultDomain.fail("文件删除失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain deleteFilePhysically(String fileId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + TbSysFile sysFile = fileMapper.selectFileByIdIncludeDeleted(fileId); + if (sysFile == null) { + resultDomain.fail("文件不存在"); + return resultDomain; + } + + // 获取存储策略 + FileStorageStrategy strategy = strategyFactory.getStrategy(sysFile.getStorageType()); + + // 删除物理文件 + boolean deleted = strategy.delete(sysFile.getFilePath()); + if (!deleted) { + log.warn("物理文件删除失败,可能文件不存在: filePath={}", sysFile.getFilePath()); + } + + // 删除数据库记录 + fileMapper.deleteFileById(fileId); + + log.info("文件物理删除成功: fileId={}", fileId); + + resultDomain.success("文件物理删除成功", sysFile); + return resultDomain; + } catch (Exception e) { + log.error("文件物理删除失败: fileId={}", fileId, e); + resultDomain.fail("文件物理删除失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public ResultDomain getFileById(String fileId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + TbSysFile sysFile = fileMapper.selectFileById(fileId); + if (sysFile == null || sysFile.getDeleted()) { + resultDomain.fail("文件不存在或已被删除"); + return resultDomain; + } + resultDomain.success("查询成功", sysFile); + return resultDomain; + } catch (Exception e) { + log.error("查询文件失败: fileId={}", fileId, e); + resultDomain.fail("查询文件失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public ResultDomain getFilesByBusinessId(String module, String businessId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + List files = fileMapper.selectFilesByBusinessId(module, businessId); + resultDomain.success("查询成功", files); + return resultDomain; + } catch (Exception e) { + log.error("查询业务文件列表失败: module={}, businessId={}", module, businessId, e); + resultDomain.fail("查询文件列表失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public ResultDomain getFilesByUploader(String uploader) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + List files = fileMapper.selectFilesByUploader(uploader); + resultDomain.success("查询成功", files); + return resultDomain; + } catch (Exception e) { + log.error("查询用户文件列表失败: uploader={}", uploader, e); + resultDomain.fail("查询文件列表失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + public ResultDomain getFileUrl(String fileId) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + TbSysFile sysFile = fileMapper.selectFileById(fileId); + if (sysFile == null || sysFile.getDeleted()) { + resultDomain.fail("文件不存在或已被删除"); + return resultDomain; + } + + // 如果数据库中已有URL且不是MinIO类型(MinIO的URL有时效性),直接返回 + if (sysFile.getFileUrl() != null && !"minio".equals(sysFile.getStorageType())) { + resultDomain.success("获取成功", sysFile.getFileUrl()); + return resultDomain; + } + + // 重新生成URL + FileStorageStrategy strategy = strategyFactory.getStrategy(sysFile.getStorageType()); + String url = strategy.getFileUrl(sysFile.getFilePath()); + + resultDomain.success("获取成功", url); + return resultDomain; + } catch (Exception e) { + log.error("获取文件URL失败: fileId={}", fileId, e); + resultDomain.fail("获取文件URL失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain batchUploadFiles(MultipartFile[] files, String module, String businessId, String uploader) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + if (files == null || files.length == 0) { + resultDomain.fail("文件列表不能为空"); + return resultDomain; + } + + List uploadedFiles = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + + for (int i = 0; i < files.length; i++) { + MultipartFile file = files[i]; + try { + ResultDomain uploadResult = uploadFile(file, module, businessId, uploader); + if (uploadResult.isSuccess() && uploadResult.getData() != null) { + uploadedFiles.add((TbSysFile) uploadResult.getData()); + } else { + errorMessages.add(String.format("文件%d上传失败: %s", i + 1, uploadResult.getMessage())); + } + } catch (Exception e) { + log.error("批量上传文件时出错: index={}, fileName={}", i, file.getOriginalFilename(), e); + errorMessages.add(String.format("文件%d(%s)上传失败: %s", i + 1, file.getOriginalFilename(), e.getMessage())); + } + } + + if (uploadedFiles.isEmpty()) { + resultDomain.fail("所有文件上传失败: " + String.join("; ", errorMessages)); + return resultDomain; + } + + if (!errorMessages.isEmpty()) { + log.warn("批量上传部分失败: 成功={}, 失败={}", uploadedFiles.size(), errorMessages.size()); + resultDomain.success(String.format("批量上传完成: 成功%d个,失败%d个。失败信息: %s", + uploadedFiles.size(), errorMessages.size(), String.join("; ", errorMessages)), uploadedFiles); + } else { + log.info("批量上传全部成功: count={}", uploadedFiles.size()); + resultDomain.success(String.format("批量上传成功: 共%d个文件", uploadedFiles.size()), uploadedFiles); + } + + return resultDomain; + } catch (Exception e) { + log.error("批量上传文件失败", e); + resultDomain.fail("批量上传文件失败: " + e.getMessage()); + return resultDomain; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain batchDeleteFiles(String[] fileIds) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + if (fileIds == null || fileIds.length == 0) { + resultDomain.fail("文件ID列表不能为空"); + return resultDomain; + } + + List fileIdList = Arrays.asList(fileIds); + int result = fileMapper.batchLogicDeleteFiles(fileIdList); + + log.info("批量逻辑删除文件成功: count={}", result); + + resultDomain.success("批量删除成功", new ArrayList()); + return resultDomain; + } catch (Exception e) { + log.error("批量删除文件失败", e); + resultDomain.fail("批量删除文件失败: " + e.getMessage()); + return resultDomain; + } + } +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java new file mode 100644 index 0000000..27bc175 --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategy.java @@ -0,0 +1,65 @@ +package org.xyzh.file.strategy; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + * @description 文件存储策略接口 + * @filename FileStorageStrategy.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +public interface FileStorageStrategy { + + /** + * @description 上传文件 + * @param file 文件对象 + * @param fileName 文件名 + * @param module 所属模块 + * @return String 文件存储路径 + * @throws IOException IO异常 + * @author system + * @since 2025-10-16 + */ + String upload(MultipartFile file, String fileName, String module) throws IOException; + + /** + * @description 下载文件 + * @param filePath 文件路径 + * @return byte[] 文件字节数组 + * @throws IOException IO异常 + * @author system + * @since 2025-10-16 + */ + byte[] download(String filePath) throws IOException; + + /** + * @description 删除文件 + * @param filePath 文件路径 + * @return boolean 删除是否成功 + * @throws IOException IO异常 + * @author system + * @since 2025-10-16 + */ + boolean delete(String filePath) throws IOException; + + /** + * @description 获取文件访问URL + * @param filePath 文件路径 + * @return String 文件访问URL + * @author system + * @since 2025-10-16 + */ + String getFileUrl(String filePath); + + /** + * @description 获取存储类型 + * @return String 存储类型 + * @author system + * @since 2025-10-16 + */ + String getStorageType(); +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategyFactory.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategyFactory.java new file mode 100644 index 0000000..5fb90cf --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/FileStorageStrategyFactory.java @@ -0,0 +1,121 @@ +package org.xyzh.file.strategy; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.xyzh.file.config.FileStorageConfig; +import org.xyzh.file.strategy.impl.LocalFileStorageStrategy; +import org.xyzh.file.strategy.impl.MinIOFileStorageStrategy; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @description 文件存储策略工厂 + * @filename FileStorageStrategyFactory.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Slf4j +@Component +public class FileStorageStrategyFactory { + + private final String defaultStorageType; + private final Map strategyMap = new ConcurrentHashMap<>(); + + public FileStorageStrategyFactory(FileStorageConfig config) { + this.defaultStorageType = config.getDefaultType(); + + // 根据配置动态创建存储策略 + for (FileStorageConfig.StorageProperties storage : config.getStorages()) { + if (storage.getEnabled() == null || !storage.getEnabled()) { + log.info("跳过未启用的存储策略: {}", storage.getType()); + continue; + } + + FileStorageStrategy strategy = createStrategy(storage); + if (strategy != null) { + strategyMap.put(storage.getType(), strategy); + log.info("注册存储策略: {}", storage.getType()); + } + } + + if (strategyMap.isEmpty()) { + log.warn("没有配置任何存储策略,将使用默认本地存储"); + strategyMap.put("local", new LocalFileStorageStrategy("./uploads", "http://localhost:8080/files")); + } + + log.info("文件存储策略工厂初始化完成,默认存储类型: {}, 已注册策略: {}", + defaultStorageType, strategyMap.keySet()); + } + + /** + * @description 根据配置创建存储策略 + * @param storage 存储配置 + * @return FileStorageStrategy 存储策略 + * @author system + * @since 2025-10-16 + */ + private FileStorageStrategy createStrategy(FileStorageConfig.StorageProperties storage) { + try { + return switch (storage.getType().toLowerCase()) { + case "local" -> new LocalFileStorageStrategy( + storage.getBasePath(), + storage.getUrlPrefix() + ); + case "minio" -> new MinIOFileStorageStrategy( + storage.getEndpoint(), + storage.getAccessKey(), + storage.getSecretKey(), + storage.getBucketName() + ); + default -> { + log.warn("未知的存储类型: {}", storage.getType()); + yield null; + } + }; + } catch (Exception e) { + log.error("创建存储策略失败: type={}", storage.getType(), e); + return null; + } + } + + /** + * @description 获取默认存储策略 + * @return FileStorageStrategy 存储策略 + * @author system + * @since 2025-10-16 + */ + public FileStorageStrategy getDefaultStrategy() { + return getStrategy(defaultStorageType); + } + + /** + * @description 根据类型获取存储策略 + * @param storageType 存储类型 + * @return FileStorageStrategy 存储策略 + * @author system + * @since 2025-10-16 + */ + public FileStorageStrategy getStrategy(String storageType) { + FileStorageStrategy strategy = strategyMap.get(storageType); + if (strategy == null) { + log.warn("未找到存储类型 {} 的策略,使用默认策略: {}", storageType, defaultStorageType); + strategy = strategyMap.get(defaultStorageType); + } + return strategy; + } + + /** + * @description 注册新的存储策略 + * @param storageType 存储类型 + * @param strategy 存储策略 + * @author system + * @since 2025-10-16 + */ + public void registerStrategy(String storageType, FileStorageStrategy strategy) { + strategyMap.put(storageType, strategy); + log.info("注册新的存储策略: {}", storageType); + } +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java new file mode 100644 index 0000000..85c1a80 --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/LocalFileStorageStrategy.java @@ -0,0 +1,80 @@ +package org.xyzh.file.strategy.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; +import org.xyzh.file.strategy.FileStorageStrategy; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * @description 本地文件存储策略实现 + * @filename LocalFileStorageStrategy.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Slf4j +public class LocalFileStorageStrategy implements FileStorageStrategy { + + private final String basePath; + private final String urlPrefix; + + public LocalFileStorageStrategy(String basePath, String urlPrefix) { + this.basePath = basePath; + this.urlPrefix = urlPrefix; + log.info("初始化本地存储策略: basePath={}, urlPrefix={}", basePath, urlPrefix); + } + + @Override + public String upload(MultipartFile file, String fileName, String module) throws IOException { + // 创建模块目录 + String modulePath = basePath + File.separator + module; + File moduleDir = new File(modulePath); + if (!moduleDir.exists()) { + moduleDir.mkdirs(); + } + + // 保存文件 + String filePath = modulePath + File.separator + fileName; + Path path = Paths.get(filePath); + Files.write(path, file.getBytes()); + + log.info("文件上传成功,本地路径: {}", filePath); + return module + "/" + fileName; + } + + @Override + public byte[] download(String filePath) throws IOException { + Path path = Paths.get(basePath + File.separator + filePath); + if (!Files.exists(path)) { + throw new IOException("文件不存在: " + filePath); + } + return Files.readAllBytes(path); + } + + @Override + public boolean delete(String filePath) throws IOException { + Path path = Paths.get(basePath + File.separator + filePath); + if (Files.exists(path)) { + Files.delete(path); + log.info("文件删除成功: {}", filePath); + return true; + } + return false; + } + + @Override + public String getFileUrl(String filePath) { + return urlPrefix + "/" + filePath; + } + + @Override + public String getStorageType() { + return "local"; + } +} + diff --git a/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java new file mode 100644 index 0000000..782c276 --- /dev/null +++ b/schoolNewsServ/file/src/main/java/org/xyzh/file/strategy/impl/MinIOFileStorageStrategy.java @@ -0,0 +1,141 @@ +package org.xyzh.file.strategy.impl; + +import io.minio.*; +import io.minio.http.Method; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; +import org.xyzh.file.strategy.FileStorageStrategy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; + +/** + * @description MinIO文件存储策略实现 + * @filename MinIOFileStorageStrategy.java + * @author system + * @copyright xyzh + * @since 2025-10-16 + */ +@Slf4j +public class MinIOFileStorageStrategy implements FileStorageStrategy { + + private final String bucketName; + private final MinioClient minioClient; + + public MinIOFileStorageStrategy(String endpoint, String accessKey, String secretKey, String bucketName) { + this.bucketName = bucketName; + + // 初始化MinIO客户端 + this.minioClient = MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + + // 检查并创建bucket + try { + boolean bucketExists = minioClient.bucketExists( + BucketExistsArgs.builder().bucket(bucketName).build() + ); + if (!bucketExists) { + minioClient.makeBucket( + MakeBucketArgs.builder().bucket(bucketName).build() + ); + log.info("创建MinIO Bucket成功: {}", bucketName); + } + log.info("初始化MinIO存储策略: endpoint={}, bucket={}", endpoint, bucketName); + } catch (Exception e) { + log.error("初始化MinIO失败", e); + throw new RuntimeException("初始化MinIO失败: " + e.getMessage(), e); + } + } + + @Override + public String upload(MultipartFile file, String fileName, String module) throws IOException { + try { + String objectName = module + "/" + fileName; + + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectName) + .stream(file.getInputStream(), file.getSize(), -1) + .contentType(file.getContentType()) + .build() + ); + + log.info("文件上传MinIO成功: {}", objectName); + return objectName; + } catch (Exception e) { + log.error("MinIO文件上传失败", e); + throw new IOException("MinIO文件上传失败: " + e.getMessage(), e); + } + } + + @Override + public byte[] download(String filePath) throws IOException { + try { + InputStream stream = minioClient.getObject( + GetObjectArgs.builder() + .bucket(bucketName) + .object(filePath) + .build() + ); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = stream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + return outputStream.toByteArray(); + } catch (Exception e) { + log.error("MinIO文件下载失败", e); + throw new IOException("MinIO文件下载失败: " + e.getMessage(), e); + } + } + + @Override + public boolean delete(String filePath) throws IOException { + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(bucketName) + .object(filePath) + .build() + ); + + log.info("MinIO文件删除成功: {}", filePath); + return true; + } catch (Exception e) { + log.error("MinIO文件删除失败", e); + throw new IOException("MinIO文件删除失败: " + e.getMessage(), e); + } + } + + @Override + public String getFileUrl(String filePath) { + try { + // 生成7天有效期的预签名URL + return minioClient.getPresignedObjectUrl( + GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(bucketName) + .object(filePath) + .expiry(7, TimeUnit.DAYS) + .build() + ); + } catch (Exception e) { + log.error("生成MinIO文件URL失败", e); + return null; + } + } + + @Override + public String getStorageType() { + return "minio"; + } +} + diff --git a/schoolNewsServ/file/src/main/resources/application.yaml b/schoolNewsServ/file/src/main/resources/application.yaml new file mode 100644 index 0000000..a211b86 --- /dev/null +++ b/schoolNewsServ/file/src/main/resources/application.yaml @@ -0,0 +1,62 @@ +spring: + application: + name: file-service + + servlet: + multipart: + enabled: true + max-file-size: 100MB + max-request-size: 100MB + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/school_news?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + username: root + password: root + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + idle-timeout: 600000 + max-lifetime: 1800000 + connection-timeout: 30000 + +# MyBatis Plus配置 +mybatis-plus: + mapper-locations: classpath:mapper/*.xml + type-aliases-package: org.xyzh.common.dto + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + global-config: + db-config: + id-type: assign_uuid + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + +# 文件存储配置 +file: + storage: + # 默认存储类型 + default-type: local + + # 存储配置列表(只创建配置了的存储策略) + storages: + # 本地存储配置 + - type: local + enabled: true + base-path: ./uploads + url-prefix: http://localhost:8080/files + + # MinIO存储配置(如不需要可以删除或设置enabled为false) + # - type: minio + # enabled: true + # endpoint: http://localhost:9000 + # access-key: minioadmin + # secret-key: minioadmin + # bucket-name: school-news + +# 服务端口 +server: + port: 8086 + diff --git a/schoolNewsServ/file/src/main/resources/log4j2-spring.xml b/schoolNewsServ/file/src/main/resources/log4j2-spring.xml new file mode 100644 index 0000000..7fafe75 --- /dev/null +++ b/schoolNewsServ/file/src/main/resources/log4j2-spring.xml @@ -0,0 +1,81 @@ + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n + ./file/logs + school-news-file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/schoolNewsServ/file/src/main/resources/mapper/FileMapper.xml b/schoolNewsServ/file/src/main/resources/mapper/FileMapper.xml new file mode 100644 index 0000000..d015e9b --- /dev/null +++ b/schoolNewsServ/file/src/main/resources/mapper/FileMapper.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + id, file_id, file_name, original_name, file_path, file_url, file_size, + file_type, mime_type, storage_type, module, business_id, uploader, + create_time, update_time, delete_time, deleted + + + + + INSERT INTO tb_sys_file ( + id, file_id, file_name, original_name, file_path, file_url, file_size, + file_type, mime_type, storage_type, module, business_id, uploader, + create_time, update_time, deleted + ) VALUES ( + #{file.ID}, #{file.fileID}, #{file.fileName}, #{file.originalName}, #{file.filePath}, #{file.fileUrl}, #{file.fileSize}, + #{file.fileType}, #{file.mimeType}, #{file.storageType}, #{file.module}, #{file.businessID}, #{file.uploader}, + #{file.createTime}, #{file.updateTime}, #{file.deleted} + ) + + + + + + + + + + + + + + + + + UPDATE tb_sys_file + SET deleted = 1, + delete_time = NOW() + WHERE id = #{fileId} + AND deleted = 0 + + + + + DELETE FROM tb_sys_file + WHERE id = #{fileId} + + + + + UPDATE tb_sys_file + SET deleted = 1, + delete_time = NOW() + WHERE id IN + + #{fileId} + + AND deleted = 0 + + +