兼容+功能

This commit is contained in:
ran.shang
2026-01-05 16:35:46 +08:00
parent e99b717e48
commit ea6d25ca97
103 changed files with 1563 additions and 581 deletions

View File

@@ -0,0 +1,59 @@
package com.senmesh.sfile.constant;
/**
* 文件位置枚举
*/
public enum FileLocationEnum {
/**
* 内网
*/
INTERNAL("1", "内网"),
/**
* 外网
*/
EXTERNAL("2", "外网");
private final String location;
private final String desc;
FileLocationEnum(String location, String desc) {
this.location = location;
this.desc = desc;
}
public String getLocation() {
return location;
}
public String getDesc() {
return desc;
}
/**
* 根据位置获取枚举
*
* @param location 位置
* @return FileLocationEnum
*/
public static FileLocationEnum getByLocation(String location) {
for (FileLocationEnum fileLocationEnum : FileLocationEnum.values()) {
if (fileLocationEnum.getLocation().equals(location)) {
return fileLocationEnum;
}
}
return null;
}
/**
* 判断是否为内网
*
* @param location 位置
* @return true-内网,false-外网
*/
public static boolean isInternal(String location) {
return INTERNAL.getLocation().equals(location);
}
}

View File

@@ -43,7 +43,7 @@ public class NormalUserFileController {
@PostMapping("/create")
@ApiOperation("创建")
public AlitaResult<Long> create(@Validated @RequestBody UserFileCreateDTO createDTO) {
public AlitaResult<UserFileDTO> create(@Validated @RequestBody UserFileCreateDTO createDTO) {
return AlitaResult.success(normalUserFileService.create(createDTO));
}

View File

@@ -0,0 +1,32 @@
package com.senmesh.sfile.controller;
import com.senmesh.common.AlitaResult;
import com.senmesh.sfile.dto.TransferUserFileCreateDTO;
import com.senmesh.sfile.service.TransferUserFileService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 传输用户文件Controller
*/
@RestController
@RequestMapping("transfer_user_file")
@Api(tags = "传输用户文件")
public class TransferUserFileController {
@Autowired
private TransferUserFileService transferUserFileService;
@PostMapping("/create")
@ApiOperation("创建")
public AlitaResult<Long> create(@Validated @RequestBody TransferUserFileCreateDTO createDTO) {
return AlitaResult.success(transferUserFileService.create(createDTO));
}
}

View File

@@ -29,11 +29,11 @@ public class UserFileController {
@Autowired
private UserFileService userFileService;
@PostMapping("/create")
/* @PostMapping("/create")
@ApiOperation("创建")
public AlitaResult<Long> create(@Validated @RequestBody UserFileCreateDTO createDTO) {
return AlitaResult.success(userFileService.create(createDTO));
}
}*/
@PostMapping("/modify")
@ApiOperation("修改")

View File

@@ -0,0 +1,32 @@
package com.senmesh.sfile.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
/**
* 扩展用户文件DTO
*/
@ApiModel("扩展用户文件DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExpandUserFileDTO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("文件夹信息")
private UserFileDTO folder;
@ApiModelProperty("文件列表")
private List<UserFileDTO> files;
}

View File

@@ -15,7 +15,7 @@ import lombok.experimental.SuperBuilder;
*/
@ApiModel("文件种类查询DTO")
@Data
@SuperBuilder
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)

View File

@@ -0,0 +1,28 @@
package com.senmesh.sfile.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 传输元数据DTO
*/
@ApiModel("传输元数据DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TransferMetadataDTO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("扩展用户文件DTO")
private ExpandUserFileDTO expandUserFile;
}

View File

@@ -0,0 +1,32 @@
package com.senmesh.sfile.dto;
import com.senmesh.common.dto.BaseOperateDTO;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotNull;
/**
* 传输用户文件创建DTO
*/
@ApiModel("传输用户文件创建DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class TransferUserFileCreateDTO extends BaseOperateDTO {
private static final long serialVersionUID = 1L;
@ApiModelProperty("用户文件ID")
@NotNull(message = "用户文件ID不能为空")
private Long userFileId;
}

View File

@@ -30,7 +30,7 @@ public class UserFileCreateDTO extends BaseOperateDTO {
private static final long serialVersionUID = 1L;
@ApiModelProperty("用户id")
@NotNull(message = "用户id不能为空")
// @NotNull(message = "用户id不能为空")
private Long userId;
@ApiModelProperty("根类型:1普通/2内穿外/3外传内/4我的分享")

View File

@@ -44,6 +44,9 @@ public class UserFileDTO extends BaseDTO {
@ApiModelProperty("文件编码")
private String fileCode;
@ApiModelProperty("文件大小")
private Long fileSize;
@ApiModelProperty("文件状态:1正常2失效11解析中12待审核13传输中14失败")
private String fileStatus;

View File

@@ -4,6 +4,7 @@ import com.senmesh.common.dto.PageQueryDTO;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@@ -14,7 +15,7 @@ import lombok.experimental.SuperBuilder;
*/
@ApiModel("用户文件查询DTO")
@Data
@SuperBuilder
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)

View File

@@ -4,6 +4,7 @@ import com.senmesh.common.dto.PageQueryDTO;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@@ -14,7 +15,7 @@ import lombok.experimental.SuperBuilder;
*/
@ApiModel("用户空间查询DTO")
@Data
@SuperBuilder
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)

View File

@@ -0,0 +1,39 @@
package com.senmesh.sfile.job;
import com.senmesh.common.annotation.AlitaStopWatch;
import com.senmesh.component.file.config.FileProperties;
import com.senmesh.sfile.service.TransferFileMonitorService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 传输文件监听定时任务
*/
@Component
@Slf4j
public class TransferFileMonitorJob {
@Autowired
private TransferFileMonitorService transferFileMonitorService;
@Autowired
private FileProperties fileProperties;
/**
* 定时扫描传输目录
* 默认每分钟执行一次
*/
@Scheduled(cron = "${file.transfer.monitor.cron:0 */1 * * * ?}")
@AlitaStopWatch
public void execute() {
try {
log.debug("开始扫描传输目录");
transferFileMonitorService.scanAndProcess();
} catch (Exception e) {
log.error("传输文件监听处理失败", e);
}
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.senmesh.sfile.dao.FileCategoryMapper">
@@ -11,7 +11,7 @@
<sql id="queryFromSql">
FROM x_file_category t
WHERE t.deleted = 0
WHERE t.deleted = FALSE
<if test='param.id != null'>
AND t.id = #{param.id}
</if>

View File

@@ -1,17 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.senmesh.sfile.dao.UserFileMapper">
<select id="list" resultType="com.senmesh.sfile.dto.UserFileDTO">
SELECT t.*
SELECT t.*, fr.file_size
<include refid="queryFromSql"></include>
ORDER BY t.id DESC
</select>
<sql id="queryFromSql">
FROM x_user_file t
WHERE t.deleted = 0
LEFT JOIN xs_file_record fr ON t.file_code = fr.file_code AND fr.deleted = FALSE
WHERE t.deleted = FALSE
<if test='param.id != null'>
AND t.id = #{param.id}
</if>
@@ -48,12 +49,10 @@
<update id="updatePathByParentIdChange">
UPDATE x_user_file child
INNER JOIN x_user_file current ON current.id = #{fileId} AND current.deleted = 0
LEFT JOIN x_user_file parent ON parent.id = #{newParentId} AND parent.deleted = 0
SET child.path = CASE
SET path = CASE
WHEN child.id = #{fileId} THEN
CASE
WHEN #{newParentId} = 0 THEN CAST(#{fileId} AS CHAR)
WHEN #{newParentId} = 0 THEN CAST(#{fileId} AS VARCHAR)
ELSE CONCAT(parent.path, ',', #{fileId})
END
ELSE
@@ -63,35 +62,44 @@
WHEN #{newParentId} = 0 THEN CONCAT(#{fileId}, ',')
ELSE CONCAT(parent.path, ',', #{fileId}, ',')
END)
END
WHERE (child.id = #{fileId} OR child.path LIKE CONCAT(current.path, ',%'))
AND child.deleted = 0
END,
update_by = #{operateUserId},
update_time = NOW()
FROM x_user_file current
LEFT JOIN x_user_file parent ON parent.id = #{newParentId} AND parent.deleted = FALSE
WHERE current.id = #{fileId}
AND current.deleted = FALSE
AND (child.id = #{fileId} OR child.path LIKE CONCAT(current.path, ',%'))
AND child.deleted = FALSE
AND child.user_id = current.user_id
AND child.root_type = current.root_type
</update>
<update id="deleteFileWithChildren">
UPDATE x_user_file child
INNER JOIN x_user_file current ON current.id = #{fileId} AND current.deleted = 0
SET child.deleted = 1,
child.update_by = #{operateUserId},
child.update_time = NOW()
WHERE (child.id = #{fileId} OR child.path LIKE CONCAT(current.path, ',%'))
AND child.deleted = 0
SET deleted = TRUE,
update_by = #{operateUserId},
update_time = NOW()
FROM x_user_file current
WHERE current.id = #{fileId}
AND current.deleted = FALSE
AND (child.id = #{fileId} OR child.path LIKE CONCAT(current.path, ',%'))
AND child.deleted = FALSE
AND child.user_id = current.user_id
AND child.root_type = current.root_type
</update>
<update id="updateStatusWithChildren">
UPDATE x_user_file child
INNER JOIN x_user_file current ON current.id = #{fileId}
AND current.deleted = 0
SET file_status = #{newStatus},
update_by = #{operateUserId},
update_time = NOW()
FROM x_user_file current
WHERE current.id = #{fileId}
AND current.deleted = FALSE
AND current.file_status = #{oldStatus}
SET child.file_status = #{newStatus},
child.update_by = #{operateUserId},
child.update_time = NOW()
WHERE (child.id = #{fileId} OR child.path LIKE CONCAT(current.path, ',%'))
AND child.deleted = 0
AND (child.id = #{fileId} OR child.path LIKE CONCAT(current.path, ',%'))
AND child.deleted = FALSE
AND child.user_id = current.user_id
AND child.root_type = current.root_type
</update>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.senmesh.sfile.dao.UserSpaceMapper">
@@ -11,7 +11,7 @@
<sql id="queryFromSql">
FROM x_user_space t
WHERE t.deleted = 0
WHERE t.deleted = FALSE
<if test='param.id != null'>
AND t.id = #{param.id}
</if>

View File

@@ -1,6 +1,7 @@
package com.senmesh.sfile.service;
import com.senmesh.sfile.dto.UserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO;
/**
* 普通用户文件Service
@@ -11,8 +12,8 @@ public interface NormalUserFileService {
* 创建普通用户文件
*
* @param createDTO 创建DTO
* @return 主键ID
* @return UserFileDTO
*/
Long create(UserFileCreateDTO createDTO);
UserFileDTO create(UserFileCreateDTO createDTO);
}

View File

@@ -0,0 +1,22 @@
package com.senmesh.sfile.service;
import java.nio.file.Path;
/**
* 传输文件监听服务
*/
public interface TransferFileMonitorService {
/**
* 扫描并处理传输目录中的文件
*/
void scanAndProcess();
/**
* 解包单个传输文件
*
* @param zipFilePath zip文件路径
*/
void unpackTransferFile(Path zipFilePath);
}

View File

@@ -0,0 +1,19 @@
package com.senmesh.sfile.service;
import com.senmesh.sfile.dto.TransferUserFileCreateDTO;
/**
* 传输用户文件Service
*/
public interface TransferUserFileService {
/**
* 创建传输用户文件
* 根据 FileProperties#location 的类型决定是内穿外还是外传内
*
* @param createDTO 创建DTO
* @return 主键ID
*/
Long create(TransferUserFileCreateDTO createDTO);
}

View File

@@ -1,6 +1,7 @@
package com.senmesh.sfile.service;
import com.senmesh.sfile.dto.ExpandUserFileCreateDTO;
import com.senmesh.sfile.dto.ExpandUserFileDTO;
import com.senmesh.sfile.dto.UserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.dto.UserFileModifyDTO;
@@ -18,18 +19,30 @@ public interface UserFileService {
* 创建用户文件
*
* @param createDTO 创建DTO
* @return 主键ID
* @return UserFileDTO
*/
Long create(UserFileCreateDTO createDTO);
UserFileDTO create(UserFileCreateDTO createDTO);
/**
* 根据普通文件创建新的文件(复制)
* 创建一个文件夹,文件名为原文件名+时间戳,然后复制原文件下所有文件类型的子文件
*
* @param expandCreateDTO 扩展创建DTO
* @return 新创建的文件夹ID
* @return 扩展用户文件DTO(包含文件夹信息和文件列表)
*/
Long create(ExpandUserFileCreateDTO expandCreateDTO);
ExpandUserFileDTO create(ExpandUserFileCreateDTO expandCreateDTO);
/**
* 根据文件夹和文件列表创建文件夹和文件(用于传输文件解包)
* 1. 从 folder 中获取 userId 和 rootType
* 2. 创建文件夹
* 3. 遍历文件列表,通过 fileCode 获取文件流,上传文件获取新的 fileCode,然后创建用户文件记录
*
* @param folder 文件夹信息(包含 userId、rootType、fileName 等)
* @param files 文件列表(每个文件包含 fileCode、fileName 等)
* @return 扩展用户文件DTO(包含创建后的文件夹信息和文件列表)
*/
ExpandUserFileDTO create(UserFileDTO folder, List<UserFileDTO> files);
/**
* 修改用户文件

View File

@@ -4,6 +4,7 @@ import com.senmesh.common.util.UserContextHolder;
import com.senmesh.sfile.constant.FileStatusEnum;
import com.senmesh.sfile.constant.RootTypeEnum;
import com.senmesh.sfile.dto.UserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.service.NormalUserFileService;
import com.senmesh.sfile.service.UserFileService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -21,7 +22,7 @@ public class NormalUserFileServiceImpl implements NormalUserFileService {
@Override
@Transactional
public Long create(UserFileCreateDTO createDTO) {
public UserFileDTO create(UserFileCreateDTO createDTO) {
createDTO.setUserId(UserContextHolder.getExistLoginUserId());
// 设置根类型为普通
createDTO.setRootType(RootTypeEnum.NORMAL.getType());

View File

@@ -1,5 +1,6 @@
package com.senmesh.sfile.service.impl;
import com.senmesh.common.util.RandomUtils;
import com.senmesh.common.util.UserContextHolder;
import com.senmesh.sfile.constant.FileStatusEnum;
import com.senmesh.sfile.constant.RootTypeEnum;
@@ -36,15 +37,19 @@ public class ShareUserFileServiceImpl implements ShareUserFileService {
calendar.add(Calendar.DAY_OF_MONTH, validDays);
Date endTime = calendar.getTime();
// 生成随机6位数字访问码
String accessCode = RandomUtils.nextSixVerificationCode();
// 调用UserFileService的重载create方法,根据普通文件创建分享文件
ExpandUserFileCreateDTO expandCreateDTO = ExpandUserFileCreateDTO.builder()
.userFileId(userFileId)
.userId(userId)
.rootType(RootTypeEnum.SHARE.getType())
.fileStatus(FileStatusEnum.NORMAL.getStatus())
.accessCode(accessCode)
.endTime(endTime)
.build();
return userFileService.create(expandCreateDTO);
return userFileService.create(expandCreateDTO).getFolder().getId();
}
}

View File

@@ -0,0 +1,455 @@
package com.senmesh.sfile.service.impl;
import com.senmesh.common.exception.util.Assert;
import com.senmesh.common.util.JsonUtils;
import com.senmesh.component.file.config.FileProperties;
import com.senmesh.component.file.service.FileService;
import com.senmesh.sfile.constant.FileLocationEnum;
import com.senmesh.sfile.constant.FileStatusEnum;
import com.senmesh.sfile.constant.FileTypeEnum;
import com.senmesh.sfile.constant.RootTypeEnum;
import com.senmesh.sfile.dto.ExpandUserFileDTO;
import com.senmesh.sfile.dto.TransferMetadataDTO;
import com.senmesh.sfile.dto.UserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.dto.UserFileModifyStatusDTO;
import com.senmesh.sfile.service.TransferFileMonitorService;
import com.senmesh.sfile.service.UserFileService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
/**
* 传输文件监听服务实现
*/
@Service
@Slf4j
public class TransferFileMonitorServiceImpl implements TransferFileMonitorService {
@Autowired
private FileProperties fileProperties;
@Autowired
private UserFileService userFileService;
@Autowired
private FileService fileService;
@Override
public void scanAndProcess() {
String transferReceiveDir = fileProperties.getTransferReceiveDir();
Path receiveDir = Paths.get(transferReceiveDir);
try {
if (!Files.exists(receiveDir)) {
Files.createDirectories(receiveDir);
log.info("创建接收目录: {}", receiveDir);
return;
}
// 1. 扫描 .zip 文件(解包逻辑)
List<Path> zipFiles = Files.list(receiveDir)
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".zip"))
.filter(p -> !p.toString().endsWith(".tmp"))
.sorted(Comparator.comparing(p -> {
try {
return Files.getLastModifiedTime(p);
} catch (IOException e) {
return FileTime.fromMillis(0);
}
}))
.limit(10)
.collect(Collectors.toList());
if (!zipFiles.isEmpty()) {
log.info("发现 {} 个待处理的传输文件", zipFiles.size());
for (Path zipFile : zipFiles) {
processZipFile(zipFile);
}
}
// 2. 扫描 .finish 文件(完成标记逻辑)
List<Path> finishFiles = Files.list(receiveDir)
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".finish"))
.sorted(Comparator.comparing(p -> {
try {
return Files.getLastModifiedTime(p);
} catch (IOException e) {
return FileTime.fromMillis(0);
}
}))
.limit(10)
.collect(Collectors.toList());
if (!finishFiles.isEmpty()) {
log.info("发现 {} 个完成标记文件", finishFiles.size());
for (Path finishFile : finishFiles) {
processFinishFile(finishFile);
}
}
} catch (Exception e) {
log.error("扫描传输目录失败", e);
}
}
/**
* 处理单个 ZIP 文件(解包逻辑)
*/
private void processZipFile(Path zipFile) {
String fileName = zipFile.getFileName().toString();
try {
// 1. 校验 ZIP 包内文件大小是否匹配,并返回元数据
TransferMetadataDTO metadata = verifyFileMD5(zipFile);
if (metadata == null) {
// 校验不通过,跳过
return;
}
log.info("文件大小校验通过,开始解包: {}", fileName);
// 2. 解包并创建用户文件(传入已解析的元数据,直接创建为正常状态)
ExpandUserFileDTO result = unpackTransferFileInternal(zipFile, metadata);
// 3. 创建 .finish 文件到发送目录,告诉发送方处理完成
createFinishFile(fileName);
// 4. 删除已处理的 ZIP 文件
Files.delete(zipFile);
log.info("处理完成,已删除 ZIP 文件: {}", fileName);
} catch (Exception e) {
log.error("处理文件失败,跳过: {}", fileName, e);
}
}
/**
* 校验文件大小(对比元数据中的大小)
*
* @param zipFile ZIP 文件路径
* @return 校验通过返回 TransferMetadataDTO,否则返回 null
*/
private TransferMetadataDTO verifyFileMD5(Path zipFile) {
String fileName = zipFile.getFileName().toString();
try (ZipFile zf = new ZipFile(zipFile.toFile())) {
// 1. 读取元数据
ZipEntry metadataEntry = zf.getEntry("transferMetadata.json");
if (metadataEntry == null) {
log.debug("ZIP 文件缺少 transferMetadata.json,跳过(可能还在传输中): {}", fileName);
return null;
}
TransferMetadataDTO metadata;
try (InputStream is = zf.getInputStream(metadataEntry)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) > 0) {
baos.write(buffer, 0, len);
}
String metadataJson = new String(baos.toByteArray(), StandardCharsets.UTF_8);
metadata = JsonUtils.toObject(metadataJson, TransferMetadataDTO.class);
}
// 2. 检查是否包含 ExpandUserFileDTO
ExpandUserFileDTO expandUserFile = metadata.getExpandUserFile();
if (expandUserFile == null || expandUserFile.getFiles() == null) {
log.debug("元数据中不包含文件信息,跳过(可能还在传输中): {}", fileName);
return null;
}
// 3. 创建 userFileId -> fileSize 的映射
Map<Long, Long> expectedSizeMap = new HashMap<>();
for (UserFileDTO file : expandUserFile.getFiles()) {
if (file.getFileSize() != null) {
expectedSizeMap.put(file.getId(), file.getFileSize());
}
}
if (expectedSizeMap.isEmpty()) {
log.debug("元数据中不包含文件大小信息,跳过(可能还在传输中): {}", fileName);
return null;
}
log.info("开始校验文件大小: {}", fileName);
// 4. 遍历 ZIP 中的文件,对比大小
Enumeration<? extends ZipEntry> entries = zf.entries();
int fileCount = 0;
int successCount = 0;
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
// 跳过目录和元数据文件
if (entry.isDirectory() || "transferMetadata.json".equals(entry.getName())) {
continue;
}
fileCount++;
// 提取 userFileId(去掉路径)
String entryName = entry.getName();
String userFileIdStr = entryName.contains("/")
? entryName.substring(entryName.lastIndexOf("/") + 1)
: entryName;
// 解析 userFileId
Long userFileId;
try {
userFileId = Long.parseLong(userFileIdStr);
} catch (NumberFormatException e) {
log.warn("无法解析 userFileId: {}", userFileIdStr);
continue;
}
// 获取期望的文件大小
Long expectedSize = expectedSizeMap.get(userFileId);
if (expectedSize == null) {
log.warn("元数据中未找到文件大小信息: userFileId={}", userFileId);
continue;
}
// 获取实际文件大小
long actualSize = entry.getSize();
// 对比大小
if (actualSize != expectedSize) {
log.error("文件大小不匹配: userFileId={}, 期望={} bytes, 实际={} bytes",
userFileId, expectedSize, actualSize);
return null;
}
successCount++;
log.debug("文件大小校验通过: userFileId={} ({} bytes)", userFileId, actualSize);
}
// 5. 检查文件数量是否匹配
if (fileCount == 0 || successCount != expectedSizeMap.size()) {
log.debug("文件数量不匹配: 期望={}, 实际={}, 跳过(可能还在传输中): {}",
expectedSizeMap.size(), successCount, fileName);
return null;
}
log.info("文件大小校验通过: {} (共 {} 个文件)", fileName, successCount);
return metadata;
} catch (Exception e) {
log.error("文件大小校验失败: {}", fileName, e);
return null;
}
}
@Override
@Transactional
public void unpackTransferFile(Path zipFilePath) {
// 先校验并获取元数据
TransferMetadataDTO metadata = verifyFileMD5(zipFilePath);
if (metadata == null) {
throw new RuntimeException("ZIP 文件校验失败或格式不正确");
}
// 调用内部方法
unpackTransferFileInternal(zipFilePath, metadata);
}
/**
* 解包传输文件(内部方法,接收已解析的元数据)
*
* @return 解包后创建的文件夹和文件信息
*/
private ExpandUserFileDTO unpackTransferFileInternal(Path zipFilePath, TransferMetadataDTO metadata) {
String fileName = zipFilePath.getFileName().toString();
log.info("开始解包文件: {}", fileName);
try {
// 1. 从元数据中获取文件夹和文件信息
ExpandUserFileDTO expandUserFile = metadata.getExpandUserFile();
Assert.notNull(expandUserFile, "metadata 缺少 expandUserFile");
UserFileDTO folder = expandUserFile.getFolder();
List<UserFileDTO> files = expandUserFile.getFiles();
Assert.notNull(folder, "expandUserFile 缺少 folder");
Assert.notEmpty(files, "expandUserFile 缺少 files");
// 2. 确定 rootType 并设置到 folder 中
String location = fileProperties.getLocation();
String rootType;
if (FileLocationEnum.INTERNAL.getLocation().equals(location)) {
rootType = RootTypeEnum.EXTERNAL_TO_INTERNAL.getType();
} else {
rootType = RootTypeEnum.INTERNAL_TO_EXTERNAL.getType();
}
folder.setRootType(rootType);
// 3. 创建 userFileId -> UserFileDTO 的映射
Map<Long, UserFileDTO> fileMap = new HashMap<>();
for (UserFileDTO file : files) {
fileMap.put(file.getId(), file);
}
// 4. 解析 ZIP 文件,上传文件并设置 fileCode
try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFilePath.toFile()))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
// 跳过元数据文件和目录
if ("transferMetadata.json".equals(entry.getName()) || entry.isDirectory()) {
zis.closeEntry();
continue;
}
// 提取 userFileId(去掉路径)
String entryName = entry.getName();
String userFileIdStr = entryName.contains("/")
? entryName.substring(entryName.lastIndexOf("/") + 1)
: entryName;
// 解析 userFileId
Long userFileId;
try {
userFileId = Long.parseLong(userFileIdStr);
} catch (NumberFormatException e) {
log.warn("无法解析 userFileId,跳过: {}", userFileIdStr);
zis.closeEntry();
continue;
}
// 获取文件信息
UserFileDTO fileInfo = fileMap.get(userFileId);
if (fileInfo == null) {
log.warn("元数据中未找到文件信息,跳过: userFileId={}", userFileId);
zis.closeEntry();
continue;
}
// 读取文件流到内存
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int len;
while ((len = zis.read(buffer)) > 0) {
baos.write(buffer, 0, len);
}
// 上传文件获取 fileCode
String originalFileName = fileInfo.getFileName();
log.debug("开始上传文件: userFileId={}, 文件名={}", userFileId, originalFileName);
ByteArrayInputStream fileStream = new ByteArrayInputStream(baos.toByteArray());
String fileCode = fileService.upload(originalFileName, fileStream).getFileCode();
log.debug("文件上传成功: userFileId={}, fileCode={}", userFileId, fileCode);
// 设置 fileCode 到文件信息中
fileInfo.setFileCode(fileCode);
zis.closeEntry();
}
}
// 5. 调用 UserFileService 的重载方法创建文件夹和文件
ExpandUserFileDTO result = userFileService.create(folder, files);
log.info("文件解包成功: {}, 创建文件夹ID: {}", fileName, result.getFolder().getId());
return result;
} catch (Exception e) {
log.error("解包文件失败: {}", fileName, e);
throw new RuntimeException("解包文件失败: " + e.getMessage(), e);
}
}
/**
* 创建 .finish 文件到发送目录,告诉对方处理完成
*
* @param zipFileName ZIP 文件名(例如:123_456.zip)
*/
private void createFinishFile(String zipFileName) {
try {
// 获取发送目录
String transferSendDir = fileProperties.getTransferSendDir();
Path sendPath = Paths.get(transferSendDir);
// 确保发送目录存在
if (!Files.exists(sendPath)) {
Files.createDirectories(sendPath);
}
// 创建 .finish 文件
Path finishFile = sendPath.resolve(zipFileName + ".finish");
Files.createFile(finishFile);
log.info("创建完成标记文件到发送目录: {}", finishFile);
} catch (Exception e) {
log.error("创建完成标记文件失败: {}", zipFileName, e);
}
}
/**
* 处理 .finish 文件(发送方接收到对方的完成标记)
*
* @param finishFile .finish 文件路径(例如:123_456.zip.finish)
*/
private void processFinishFile(Path finishFile) {
String fileName = finishFile.getFileName().toString();
log.info("处理完成标记文件: {}", fileName);
try {
// 1. 解析文件名,获取 userFileId
// 文件名格式:123_456.zip.finish
String zipFileName = fileName.replace(".finish", "");
String[] parts = zipFileName.replace(".zip", "").split("_");
if (parts.length != 2) {
log.warn("无法解析完成标记文件名,跳过: {}", fileName);
return;
}
Long folderId = Long.parseLong(parts[0]);
Long userId = Long.parseLong(parts[1]);
log.info("解析完成标记文件: folderId={}, userId={}", folderId, userId);
// 2. 更新文件夹及其所有子文件状态为正常(modifyStatus 会自动更新所有子文件)
UserFileModifyStatusDTO statusDTO = UserFileModifyStatusDTO.builder()
.id(folderId)
.oldStatus(FileStatusEnum.TRANSFERRING.getStatus())
.newStatus(FileStatusEnum.NORMAL.getStatus())
.build();
userFileService.modifyStatus(statusDTO);
log.info("更新文件夹及所有子文件状态为正常: folderId={}", folderId);
// 3. 删除 .finish 文件
Files.delete(finishFile);
log.info("处理完成,已删除完成标记文件: {}", fileName);
} catch (Exception e) {
log.error("处理完成标记文件失败: {}", fileName, e);
}
}
}

View File

@@ -0,0 +1,151 @@
package com.senmesh.sfile.service.impl;
import com.senmesh.common.util.JsonUtils;
import com.senmesh.common.util.UserContextHolder;
import com.senmesh.component.file.config.FileProperties;
import com.senmesh.component.file.service.FileService;
import com.senmesh.sfile.constant.FileLocationEnum;
import com.senmesh.sfile.constant.FileStatusEnum;
import com.senmesh.sfile.constant.RootTypeEnum;
import com.senmesh.sfile.dto.ExpandUserFileCreateDTO;
import com.senmesh.sfile.dto.ExpandUserFileDTO;
import com.senmesh.sfile.dto.TransferMetadataDTO;
import com.senmesh.sfile.dto.TransferUserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.service.TransferUserFileService;
import com.senmesh.sfile.service.UserFileService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* 传输用户文件ServiceImpl
*/
@Service
@Slf4j
public class TransferUserFileServiceImpl implements TransferUserFileService {
@Autowired
private UserFileService userFileService;
@Autowired
private FileProperties fileProperties;
@Autowired
private FileService fileService;
@Override
@Transactional
public Long create(TransferUserFileCreateDTO createDTO) {
Long userId = UserContextHolder.getExistLoginUserId();
// 根据 FileProperties#location 的类型决定是内穿外还是外传内
String location = fileProperties.getLocation();
String rootType;
if (FileLocationEnum.INTERNAL.getLocation().equals(location)) {
// 内网 -> 内穿外
rootType = RootTypeEnum.INTERNAL_TO_EXTERNAL.getType();
} else {
// 外网 -> 外传内
rootType = RootTypeEnum.EXTERNAL_TO_INTERNAL.getType();
}
// TODO 扫描文件审核
// 调用UserFileService的重载create方法
ExpandUserFileCreateDTO expandCreateDTO = ExpandUserFileCreateDTO.builder()
.userFileId(createDTO.getUserFileId())
.userId(userId)
.rootType(rootType)
.fileStatus(FileStatusEnum.TRANSFERRING.getStatus())
.build();
ExpandUserFileDTO expandUserFileDTO = userFileService.create(expandCreateDTO);
// 打包传输文件
try {
packTransferFiles(expandUserFileDTO);
} catch (Exception e) {
log.error("打包传输文件失败", e);
throw new RuntimeException("打包传输文件失败: " + e.getMessage());
}
return expandUserFileDTO.getFolder().getId();
}
/**
* 打包传输文件
* 将文件夹和文件打包成压缩包,并添加transferMetadata.json文件信息
*
* @param expandUserFileDTO 扩展用户文件DTO
*/
private void packTransferFiles(ExpandUserFileDTO expandUserFileDTO) throws IOException {
UserFileDTO folder = expandUserFileDTO.getFolder();
List<UserFileDTO> files = expandUserFileDTO.getFiles();
// 1. 创建传输发送目录
String transferSendDir = fileProperties.getTransferSendDir();
Path transferPath = Paths.get(transferSendDir);
if (!Files.exists(transferPath)) {
Files.createDirectories(transferPath);
}
// 2. 创建最终传输包
String finalFileName = folder.getUserId() + "_" + folder.getId() + ".zip";
Path finalFilePath = transferPath.resolve(finalFileName);
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(finalFilePath.toFile()))) {
// 2.1 添加文件夹中的文件
String folderName = folder.getFileName();
for (UserFileDTO file : files) {
String fileCode = file.getFileCode();
Long userFileId = file.getId();
// 创建 ZipEntry(使用 userFileId 命名)
String entryName = folderName + "/" + userFileId;
ZipEntry zipEntry = new ZipEntry(entryName);
zos.putNextEntry(zipEntry);
// 边读边写
try (InputStream inputStream = fileService.getFile(fileCode)) {
byte[] buffer = new byte[8192];
int len;
while ((len = inputStream.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
log.debug("添加文件: userFileId={}, 原文件名={}, 大小={} bytes",
userFileId, file.getFileName(), file.getFileSize());
}
// 2.2 添加元数据文件
TransferMetadataDTO metadata = TransferMetadataDTO.builder()
.expandUserFile(expandUserFileDTO)
.build();
String metadataJson = JsonUtils.toJsonString(metadata);
ZipEntry metadataEntry = new ZipEntry("transferMetadata.json");
zos.putNextEntry(metadataEntry);
zos.write(metadataJson.getBytes("UTF-8"));
zos.closeEntry();
}
log.info("传输文件打包完成: {}", finalFilePath);
}
}

View File

@@ -4,11 +4,14 @@ import com.senmesh.common.constant.CommonConstants;
import com.senmesh.common.exception.util.Assert;
import com.senmesh.common.util.ListUtil;
import com.senmesh.common.util.UserContextHolder;
import com.senmesh.component.file.service.FileService;
import com.senmesh.sfile.constant.FileStatusEnum;
import com.senmesh.sfile.constant.FileTypeEnum;
import com.senmesh.sfile.constant.RootTypeEnum;
import com.senmesh.sfile.convert.UserFileConvert;
import com.senmesh.sfile.dao.UserFileMapper;
import com.senmesh.sfile.dto.ExpandUserFileCreateDTO;
import com.senmesh.sfile.dto.ExpandUserFileDTO;
import com.senmesh.sfile.dto.UserFileCreateDTO;
import com.senmesh.sfile.dto.UserFileDTO;
import com.senmesh.sfile.dto.UserFileModifyDTO;
@@ -16,73 +19,84 @@ import com.senmesh.sfile.dto.UserFileModifyStatusDTO;
import com.senmesh.sfile.dto.UserFileQueryDTO;
import com.senmesh.sfile.entity.UserFile;
import com.senmesh.sfile.service.UserFileService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 用户文件ServiceImpl
*/
@Slf4j
@Service
public class UserFileServiceImpl implements UserFileService {
@Autowired
private UserFileMapper userFileMapper;
@Autowired
private FileService fileService;
@Override
@Transactional
public Long create(UserFileCreateDTO createDTO) {
public UserFileDTO create(UserFileCreateDTO createDTO) {
Long parentId = createDTO.getParentId();
String parentPath = createDTO.getParentPath();
Long userId = createDTO.getUserId();
String rootType = createDTO.getRootType();
// 1. 校验文件类型时fileCode不能为空
// 1. 校验 userId 和 rootType 不能为空
Assert.notNull(userId, "用户ID不能为空");
Assert.notBlank(rootType, "根类型不能为空");
// 2. 校验文件类型时fileCode不能为空
if (FileTypeEnum.isFile(createDTO.getFileType())) {
Assert.notBlank(createDTO.getFileCode(), "文件编码不能为空");
}
// 2. 校验父节点(如果不是根目录)
if (parentId != null && parentId != 0L) {
// 如果没有提供parentPath,需要查询父节点
if (StringUtils.isBlank(parentPath)) {
UserFileDTO parentFile = this.findOne(UserFileQueryDTO.builder()
.id(parentId)
.userId(userId)
.rootType(rootType)
.build());
Assert.isTrue(FileTypeEnum.isFolder(parentFile.getFileType()), "父节点必须是文件夹");
// 获取父路径用于后续拼接
parentPath = parentFile.getPath();
}
// 如果提供了parentPath,信任调用方,不做校验
// 3. 处理 parentPath(性能优化:如果提供了 parentPath,直接使用,避免查询)
if (StringUtils.isBlank(parentPath) && parentId != null && parentId != 0L) {
// 如果没有提供 parentPath,但提供了 parentId,需要查询父节点获取 parentPath
UserFileDTO parentFile = this.findOne(UserFileQueryDTO.builder()
.id(parentId)
.userId(userId)
.rootType(rootType)
.build());
Assert.isTrue(FileTypeEnum.isFolder(parentFile.getFileType()), "父节点必须是文件夹");
// 获取父路径用于后续拼接
parentPath = parentFile.getPath();
}
// 如果提供了 parentPath,直接信任调用方,不做校验
// 3. 插入数据获取ID
// 4. 插入数据获取ID
UserFile userFile = UserFileConvert.INSTANCE.toEntity(createDTO);
userFileMapper.saveEntity(userFile);
Long newId = userFile.getId();
// 4. 构建path
// 5. 构建path
String path = StringUtils.isNotBlank(parentPath)
? parentPath + CommonConstants.COMMA + newId
: String.valueOf(newId);
// 5. 更新path
// 6. 更新path
userFile.setPath(path);
userFileMapper.updateEntitySelective(userFile);
return newId;
// 7. 转换为DTO返回
return UserFileConvert.INSTANCE.toDto(userFile);
}
@Override
@Transactional
public Long create(ExpandUserFileCreateDTO expandCreateDTO) {
public ExpandUserFileDTO create(ExpandUserFileCreateDTO expandCreateDTO) {
Long userFileId = expandCreateDTO.getUserFileId();
Long userId = expandCreateDTO.getUserId();
String rootType = expandCreateDTO.getRootType();
@@ -94,26 +108,7 @@ public class UserFileServiceImpl implements UserFileService {
.rootType(RootTypeEnum.NORMAL.getType())
.build());
// 2. 创建文件夹,文件名 = 原文件名 + 当前时间
String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String folderName = normalFile.getFileName() + "_" + timestamp;
UserFileCreateDTO folderCreateDTO = UserFileCreateDTO.builder()
.userId(userId)
.rootType(rootType)
.fileType(FileTypeEnum.FOLDER.getType())
.fileName(folderName)
.parentId(0L)
.endTime(expandCreateDTO.getEndTime())
.accessCode(expandCreateDTO.getAccessCode())
.build();
Long folderId = this.create(folderCreateDTO);
String folderPath = String.valueOf(folderId);
// 3. 查询需要复制的文件列表
// 查询条件:path LIKE 'xxx%' AND fileType = '1'
// 如果是文件:会查到自己
// 如果是文件夹:会查到其下所有文件类型的子文件
UserFileQueryDTO queryDTO = UserFileQueryDTO.builder()
.userId(userId)
.rootType(RootTypeEnum.NORMAL.getType())
@@ -122,23 +117,98 @@ public class UserFileServiceImpl implements UserFileService {
.build();
queryDTO.disablePage();
List<UserFileDTO> filesToCopy = this.list(queryDTO);
Assert.notEmpty(filesToCopy, "未查询到需要处理的文件");
// 2. 创建文件夹,文件名 = 原文件名 + 当前时间
String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String folderName = normalFile.getFileName() + "_" + timestamp;
UserFileCreateDTO folderCreateDTO = UserFileCreateDTO.builder()
.userId(userId)
.rootType(rootType)
.fileStatus(expandCreateDTO.getFileStatus())
.fileType(FileTypeEnum.FOLDER.getType())
.fileName(folderName)
.parentId(0L)
.endTime(expandCreateDTO.getEndTime())
.accessCode(expandCreateDTO.getAccessCode())
.build();
UserFileDTO folderDTO = this.create(folderCreateDTO);
Long folderId = folderDTO.getId();
String folderPath = String.valueOf(folderId);
// 4. 在文件夹下创建文件对象
List<UserFileDTO> createdFiles = new java.util.ArrayList<>();
for (UserFileDTO fileDTO : filesToCopy) {
UserFileCreateDTO fileCreateDTO = UserFileCreateDTO.builder()
.userId(userId)
.rootType(rootType)
.fileStatus(expandCreateDTO.getFileStatus())
.fileType(FileTypeEnum.FILE.getType())
.fileName(fileDTO.getFileName())
.fileCode(fileDTO.getFileCode())
.parentId(folderId)
.parentPath(folderPath)
.build();
this.create(fileCreateDTO);
UserFileDTO createdFile = this.create(fileCreateDTO);
createdFiles.add(createdFile);
}
return folderId;
// 6. 返回结果
return ExpandUserFileDTO.builder()
.folder(folderDTO)
.files(createdFiles)
.build();
}
@Override
@Transactional
public ExpandUserFileDTO create(UserFileDTO folder, List<UserFileDTO> files) {
Long userId = folder.getUserId();
String rootType = folder.getRootType();
// 3. 创建文件夹
UserFileCreateDTO folderCreateDTO = UserFileCreateDTO.builder()
.userId(userId)
.rootType(rootType)
.fileType(FileTypeEnum.FOLDER.getType())
.fileName(folder.getFileName())
.parentId(0L)
.fileStatus(FileStatusEnum.NORMAL.getStatus())
.build();
UserFileDTO folderDTO = this.create(folderCreateDTO);
Long folderId = folderDTO.getId();
String folderPath = String.valueOf(folderId);
log.info("创建文件夹成功: folderId={}, 文件名={}", folderId, folder.getFileName());
// 4. 创建文件列表
List<UserFileDTO> createdFiles = new ArrayList<>();
for (UserFileDTO fileInfo : files) {
// 创建文件记录
UserFileCreateDTO fileCreateDTO = UserFileCreateDTO.builder()
.userId(userId)
.rootType(rootType)
.fileType(FileTypeEnum.FILE.getType())
.fileName(fileInfo.getFileName())
.fileCode(fileInfo.getFileCode())
.parentId(folderId)
.parentPath(folderPath)
.fileStatus(FileStatusEnum.NORMAL.getStatus())
.build();
UserFileDTO createdFile = this.create(fileCreateDTO);
createdFiles.add(createdFile);
}
// 5. 返回结果
return ExpandUserFileDTO.builder()
.folder(folderDTO)
.files(createdFiles)
.build();
}
@Override
@Transactional