package com.mokee.admin.service.impl; import cn.hutool.crypto.digest.MD5; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.mokee.admin.mapper.SysFileMapper; import com.mokee.admin.service.ConfigService; import com.mokee.admin.service.FileService; import com.mokee.common.entity.SysFile; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; /** * 文件服务实现 — 文件二进制数据存储于本地磁盘 *

* 磁盘路径结构:{basePath}/{yyyy-MM}/{storageFileName} * 例:/data/mokee/gw/file/2026-06/a1b2c3d4e5f6.png *

* 向下兼容:存量 BLOB 数据(file_data 不为空)仍可正常读取。 * * @author mokee */ @Slf4j @Service @RequiredArgsConstructor public class FileServiceImpl implements FileService { private final SysFileMapper sysFileMapper; private final ConfigService configService; private static final DateTimeFormatter YYYY_MM = DateTimeFormatter.ofPattern("yyyy-MM"); private static final String DEFAULT_BASE_PATH = "/data/mokee/gw/file"; /** 获取配置的存储根目录 */ private String getBasePath() { return configService.getValue("file.storage.disk.path", DEFAULT_BASE_PATH); } /** * 从原始文件名提取扩展名 */ private String ext(String fileName) { if (fileName == null) return ""; int i = fileName.lastIndexOf('.'); return i > 0 ? fileName.substring(i) : ""; } @Override public SysFile upload(MultipartFile file, String fileType, String systemId, String userId, String uploadUserName) throws IOException { byte[] data = file.getBytes(); // 计算 MD5 String md5 = MD5.create().digestHex(data); // 构建实体(先生成 ID 用于存储文件名) String fileId = cn.hutool.core.lang.UUID.randomUUID().toString().replace("-", ""); String storageFileName = fileId + ext(file.getOriginalFilename()); LocalDateTime now = LocalDateTime.now(); SysFile sf = SysFile.builder() .id(fileId) .systemId(systemId) .fileName(file.getOriginalFilename()) .storageFileName(storageFileName) .fileType(fileType != null ? fileType : "other") .fileSize(file.getSize()) .mimeType(file.getContentType()) .md5(md5) .uploadUserId(userId) .uploadUserName(uploadUserName) .fileData(null) // 新文件不写入 BLOB .createdAt(now) .build(); // 写入磁盘 String basePath = getBasePath(); Path dir = Paths.get(basePath, now.format(YYYY_MM)); try { Files.createDirectories(dir); } catch (IOException e) { log.error("创建存储目录失败: {} — 请检查磁盘权限和路径配置", dir); throw new IOException("存储目录创建失败: " + dir + " (请检查磁盘权限和路径配置)", e); } Path filePath = dir.resolve(storageFileName); try { Files.write(filePath, data); } catch (IOException e) { log.error("写入文件失败: {} — 可能磁盘空间不足或权限不足", filePath); throw new IOException("文件写入失败: " + filePath + " (可能磁盘空间不足或权限不足)", e); } log.info("文件已写入磁盘: {} ({} bytes, md5={})", filePath, file.getSize(), md5); // 存元信息到 DB sysFileMapper.insert(sf); return sf; } @Override public IPage page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId) { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType); if (fileName != null && !fileName.isEmpty()) w.like(SysFile::getFileName, fileName); if (uploadUserName != null && !uploadUserName.isEmpty()) w.like(SysFile::getUploadUserName, uploadUserName); if (systemId != null) w.eq(SysFile::getSystemId, systemId); w.orderByDesc(SysFile::getCreatedAt); w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); return sysFileMapper.selectPage(new Page<>(page, size), w); } @Override public List list(String fileType, String systemId) { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType); if (systemId != null) w.eq(SysFile::getSystemId, systemId); w.orderByDesc(SysFile::getCreatedAt); w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); return sysFileMapper.selectList(w); } @Override public SysFile getById(String id) { return sysFileMapper.selectById(id); } @Override public SysFile getMetaById(String id) { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); w.eq(SysFile::getId, id); w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); return sysFileMapper.selectOne(w); } @Override public Path getDiskPath(SysFile sf) { String basePath = getBasePath(); // 存量兼容:旧文件 storageFileName 为空时,用 id + 扩展名兜底 String storageName = sf.getStorageFileName(); if (storageName == null || storageName.isEmpty()) { storageName = sf.getId() + ext(sf.getFileName()); } return Paths.get(basePath, sf.getCreatedAt().format(YYYY_MM), storageName); } @Override public void delete(String id) throws IOException { SysFile sf = sysFileMapper.selectById(id); if (sf == null) return; // 删除磁盘文件(失败不阻断 DB 删除) try { Path path = getDiskPath(sf); if (Files.exists(path)) { Files.delete(path); log.info("磁盘文件已删除: {}", path); } } catch (Exception e) { log.warn("删除磁盘文件失败(继续删DB记录) - id: {}, error: {}", id, e.getMessage()); } // 删除 DB 记录 sysFileMapper.deleteById(id); } }