feat(file-upload): 实现分片上传功能并添加 USB 控制接口

- 新增分片上传相关类和接口,包括 ChunkedUploadService、ChunkedUploadResult等
- 实现分片上传、合并、取消上传等功能
- 添加 USB 开启和关闭控制接口
- 新增 ImageTool 相关实体和接口,用于管理工具文件
master
chenhao 2025-09-01 16:46:33 +08:00
parent 70f6ade5db
commit 4b7772b38d
21 changed files with 1371 additions and 14 deletions

View File

@ -106,5 +106,25 @@ public class DeviceController {
return clientOperateService.terminalEnd(deviceReq);
}
@ApiOperation(value = "usb开启")
@PostMapping("/terminal/usb/enabled")
public Result<?> usbEnabled(@RequestBody DeviceReq deviceReq) {
if (Objects.isNull(deviceReq)) {
return Result.errorResult(BaseErrorCode.PARAMS_CHK_ERROR);
}
log.info("终端usb开启请求参数为{}", JSONUtil.toJsonStr(deviceReq));
return clientOperateService.usbEnabled(deviceReq);
}
@ApiOperation(value = "usb关闭")
@PostMapping("/terminal/usb/disabled")
public Result<?> usbDisabled(@RequestBody DeviceReq deviceReq) {
if (Objects.isNull(deviceReq)) {
return Result.errorResult(BaseErrorCode.PARAMS_CHK_ERROR);
}
log.info("终端usb关闭请求参数为{}", JSONUtil.toJsonStr(deviceReq));
return clientOperateService.usbDisabled(deviceReq);
}
}

View File

@ -0,0 +1,141 @@
package com.unisinsight.project.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.unisinsight.project.entity.dao.ImageTool;
import com.unisinsight.project.entity.req.ImageToolReq;
import com.unisinsight.project.entity.res.ImageToolRes;
import com.unisinsight.project.service.ChunkedUploadService;
import com.unisinsight.project.service.ImageToolService;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import javax.annotation.Resource;
import java.io.Serializable;
import java.util.List;
import com.unisinsight.project.exception.Result;
import lombok.extern.slf4j.Slf4j;
import com.unisinsight.project.exception.BaseErrorCode;
import java.util.Objects;
import com.unisinsight.project.entity.req.DeleteIdReq;
import cn.hutool.json.JSONUtil;
import org.springframework.web.multipart.MultipartFile;
/**
* (ImageTool)
*
* @author ch
* @since 2025-08-29 14:58:39
*/
@RestController
@Api(tags = "工具管理接口")
@Slf4j
@RequestMapping("/api/nex/v1/image_tool")
public class ImageToolController {
/**
*
*/
@Resource
private ImageToolService service;
@Autowired
private ChunkedUploadService chunkedUploadService;
/**
*
*
* @param imageToolReq
* @return
*/
@PostMapping("/select/page")
@ApiOperation(value = "分页查询")
public Result selectPage(@RequestBody ImageToolReq imageToolReq) {
if (Objects.isNull(imageToolReq)) {
return Result.errorResult(BaseErrorCode.PARAMS_CHK_ERROR);
}
log.info("分页查询请求参数为:{}", JSONUtil.toJsonStr(imageToolReq));
return service.selectPage(imageToolReq);
}
@PostMapping("/add")
@ApiOperation(value = "上传ImageTool文件分片", notes = "上传单个ImageTool文件分片当所有分片上传完成后自动合并文件并保存到数据库")
@ApiImplicitParams({
@ApiImplicitParam(name = "chunk", value = "文件分片", required = true, dataType = "__File", paramType = "form"),
@ApiImplicitParam(name = "chunk_size", value = "文件分片大小", required = true, dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "chunk_md5", value = "文件分片md5", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "file_id", value = "文件唯一标识符", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "file_type", value = "文件类型", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "shard_index", value = "当前分片编号(从1开始)", required = true, dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "shard_total", value = "总分片数", required = true, dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "file_name", value = "原始文件名", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "file_size", value = "文件总大小", required = true, dataType = "long", paramType = "query"),
@ApiImplicitParam(name = "file_version", value = "文件版本", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "description", value = "描述", dataType = "String", paramType = "query")
})
public Result<?> uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("chunk_size") int chunkSize,
@RequestParam("chunk_md5") String chunkMd5,
@RequestParam("file_id") String fileId,
@RequestParam("file_type") String fileType,
@RequestParam("shard_index") int chunkNumber,
@RequestParam("shard_total") int totalChunks,
@RequestParam("file_name") String fileName,
@RequestParam("file_size") long totalSize,
@RequestParam("file_version") String fileVersion,
@RequestParam(value = "description", required = false) String description
) {
return service.uploadChunk(chunk, chunkSize, chunkMd5, fileId, chunkNumber, totalChunks,
fileName, totalSize,description);
}
@PostMapping("/cancel/upload")
@ApiOperation(value = "取消上传ImageTool文件")
public Result<?> cancelUpload(@RequestParam("file_id") String fileId) {
try {
chunkedUploadService.cancelUpload(fileId);
return Result.successResult();
} catch (Exception e) {
log.error("取消上传失败,fileId:{},error:{}", fileId, e.getMessage(), e);
return Result.errorResultMessage(BaseErrorCode.HTTP_ERROR_CODE_500, "取消上传失败: " + e.getMessage());
}
}
@ApiOperation(value = "修改")
@PostMapping("/update")
public Result<?> updateUser(@RequestBody ImageToolReq imageToolReq) {
if (Objects.isNull(imageToolReq)) {
return Result.errorResult(BaseErrorCode.PARAMS_CHK_ERROR);
}
log.info("修改请求参数为:{}", JSONUtil.toJsonStr(imageToolReq));
return service.update(imageToolReq);
}
@ApiOperation(value = "查询")
@PostMapping("/query")
public Result<?> queryUser(@RequestBody ImageToolReq imageToolReq) {
if (Objects.isNull(imageToolReq)) {
return Result.errorResult(BaseErrorCode.PARAMS_CHK_ERROR);
}
log.info("查询请求参数为:{}", JSONUtil.toJsonStr(imageToolReq));
return service.query(imageToolReq);
}
@ApiOperation(value = "删除")
@PostMapping("/delete")
public Result<?> delete(@RequestBody DeleteIdReq deleteIdReq) {
if (Objects.isNull(deleteIdReq)) {
return Result.errorResult(BaseErrorCode.PARAMS_CHK_ERROR);
}
log.info("删除请求参数为:{}", JSONUtil.toJsonStr(deleteIdReq));
return service.delete(deleteIdReq);
}
}

View File

@ -0,0 +1,117 @@
package com.unisinsight.project.entity.dao;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* (ImageTool)
*
* @author ch
* @since 2025-08-29 16:10:59
*/
@TableName(value = "image_tool")
@Data
@ApiModel("")
public class ImageTool extends Model<ImageTool> {
/**
* ${column.comment}
**/
@TableId(value = "id", type = IdType.AUTO)
@ApiModelProperty("${column.comment}")
private Integer id;
/**
*
**/
@TableField(value = "file_name")
@ApiModelProperty("文件名称")
private String fileName;
/**
*
**/
@TableField(value = "file_type")
@ApiModelProperty("文件类型")
private String fileType;
/**
*
**/
@TableField(value = "file_size")
@ApiModelProperty("文件大小")
private Long fileSize;
/**
*
**/
@TableField(value = "file_version")
@ApiModelProperty("文件版本")
private String fileVersion;
/**
*
**/
@TableField(value = "create_time", fill = FieldFill.INSERT)
@ApiModelProperty("创建时间")
private String createTime;
/**
*
**/
@TableField(value = "create_user")
@ApiModelProperty("创建人")
private String createUser;
/**
*
**/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
@ApiModelProperty("更新时间")
private String updateTime;
/**
*
**/
@TableField(value = "update_user")
@ApiModelProperty("更新人")
private String updateUser;
/**
*
**/
@TableField(value = "upload_time")
@ApiModelProperty("上传时间")
private String uploadTime;
/**
*
**/
@TableField(value = "store_path")
@ApiModelProperty("存储路径")
private String storePath;
/**
*
**/
@TableField(value = "description")
@ApiModelProperty("备注")
private String description;
}

View File

@ -0,0 +1,35 @@
package com.unisinsight.project.entity.enums;
import lombok.Getter;
/**
* @author : ch
* @version : 1.0
* @ClassName : GrpcTypeEnum
* @Description :
* @DATE : Created in 9:20 2025/9/1
* <pre> Copyright: Copyright(c) 2025 </pre>
* <pre> Company : </pre>
* Modification History:
* Date Author Version Discription
* --------------------------------------------------------------------------
* 2025/09/01 ch 1.0 Why & What is modified: <> *
*/
@Getter
public enum GrpcTypeEnum {
SHUTDOWN("1", "关机"),
IMAGE_UPDATE("2", "镜像列表更新"),
USB_ENABLED("3", "USB开启"),
USB_DISABLED("4", "USB关闭"),
;
private final String type;
private final String desc;
GrpcTypeEnum(String type, String desc) {
this.type = type;
this.desc = desc;
}
}

View File

@ -37,8 +37,8 @@ public class ImageCreateReq {
private Integer diskSize;
@JsonProperty("disk_format")
private String diskFormat;
@JsonProperty("storage_pool")
private String storagePool;
@JsonProperty("storage_pool_name")
private String storagePoolName;
@JsonProperty("network_name")
private String networkName;
@JsonProperty("iso_path")

View File

@ -0,0 +1,105 @@
package com.unisinsight.project.entity.req;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* (ImageTool)
*
* @author ch
* @since 2025-08-29 16:10:59
*/
@Data
@ApiModel("")
public class ImageToolReq {
/**
* ${column.comment}
**/
@JsonProperty("id")
@ApiModelProperty("${column.comment}")
private Integer id;
/**
*
**/
@JsonProperty("file_name")
@ApiModelProperty("文件名称")
private String fileName;
/**
*
**/
@JsonProperty("file_type")
@ApiModelProperty("文件类型")
private String fileType;
/**
*
**/
@JsonProperty("file_size")
@ApiModelProperty("文件大小")
private Long fileSize;
/**
*
**/
@JsonProperty("file_version")
@ApiModelProperty("文件版本")
private String fileVersion;
/**
*
**/
@JsonProperty("create_time")
@ApiModelProperty("创建时间")
private String createTime;
/**
*
**/
@JsonProperty("create_user")
@ApiModelProperty("创建人")
private String createUser;
/**
*
**/
@JsonProperty("update_time")
@ApiModelProperty("更新时间")
private String updateTime;
/**
*
**/
@JsonProperty("update_user")
@ApiModelProperty("更新人")
private String updateUser;
/**
*
**/
@JsonProperty("upload_time")
@ApiModelProperty("上传时间")
private String uploadTime;
/**
*
**/
@JsonProperty("store_path")
@ApiModelProperty("存储路径")
private String storePath;
/**
*
**/
@JsonProperty("description")
@ApiModelProperty("备注")
private String description;
/**
*
*/
@ApiModelProperty(value = "查询页", notes = "分页查询时再传")
@JsonProperty("page_num")
private Integer pageNum;
/**
*
*/
@ApiModelProperty(value = "每页数量", notes = "分页查询时再传")
@JsonProperty("page_size")
private Integer pageSize;
}

View File

@ -51,6 +51,21 @@ public class ImageVirtualMachinesReq {
@JsonProperty("storage_path")
@ApiModelProperty("镜像存储路径")
private String storagePath;
@JsonProperty("storage_pool_name")
@ApiModelProperty("存储卷名称")
private String storagePoolName;
/**
*
**/
@JsonProperty("image_tool_name")
@ApiModelProperty("驱动名称")
private String imageToolName;
/**
*
**/

View File

@ -0,0 +1,94 @@
package com.unisinsight.project.entity.res;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* (ImageTool)
*
* @author ch
* @since 2025-08-29 16:10:59
*/
@Data
@ApiModel("")
public class ImageToolRes implements Serializable {
/**
* ${column.comment}
**/
@JsonProperty("id")
@ApiModelProperty("${column.comment}")
private Integer id;
/**
*
**/
@JsonProperty("file_name")
@ApiModelProperty("文件名称")
private String fileName;
/**
*
**/
@JsonProperty("file_type")
@ApiModelProperty("文件类型")
private String fileType;
/**
*
**/
@JsonProperty("file_size")
@ApiModelProperty("文件大小")
private Long fileSize;
/**
*
**/
@JsonProperty("file_version")
@ApiModelProperty("文件版本")
private String fileVersion;
/**
*
**/
@JsonProperty("create_time")
@ApiModelProperty("创建时间")
private String createTime;
/**
*
**/
@JsonProperty("create_user")
@ApiModelProperty("创建人")
private String createUser;
/**
*
**/
@JsonProperty("update_time")
@ApiModelProperty("更新时间")
private String updateTime;
/**
*
**/
@JsonProperty("update_user")
@ApiModelProperty("更新人")
private String updateUser;
/**
*
**/
@JsonProperty("upload_time")
@ApiModelProperty("上传时间")
private String uploadTime;
/**
*
**/
@JsonProperty("store_path")
@ApiModelProperty("存储路径")
private String storePath;
/**
*
**/
@JsonProperty("description")
@ApiModelProperty("备注")
private String description;
}

View File

@ -0,0 +1,18 @@
package com.unisinsight.project.mapper;
import java.util.List;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import com.unisinsight.project.entity.dao.ImageTool;
/**
* (ImageTool)访
*
* @author ch
* @since 2025-08-29 15:24:09
*/
public interface ImageToolMapper extends BaseMapper<ImageTool> {
}

View File

@ -0,0 +1,22 @@
package com.unisinsight.project.service;
import lombok.Data;
/**
*
*/
@Data
public class ChunkedUploadCompletion {
private String fileId;
private String fileName;
private String filePath;
private long fileSize;
private String md5; // 文件整体MD5如果需要的话
public ChunkedUploadCompletion(String fileId, String fileName, String filePath, long fileSize) {
this.fileId = fileId;
this.fileName = fileName;
this.filePath = filePath;
this.fileSize = fileSize;
}
}

View File

@ -0,0 +1,60 @@
package com.unisinsight.project.service;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
*
*/
@Data
public class ChunkedUploadResult {
private boolean success;
private String status;
private String message;
private String filePath;
private Integer uploadedChunks;
private Integer totalChunks;
private Map<String, Object> additionalData = new HashMap<>();
public static ChunkedUploadResult success(String status, String message) {
ChunkedUploadResult result = new ChunkedUploadResult();
result.setSuccess(true);
result.setStatus(status);
result.setMessage(message);
return result;
}
public static ChunkedUploadResult success(String status, String message, String filePath) {
ChunkedUploadResult result = new ChunkedUploadResult();
result.setSuccess(true);
result.setStatus(status);
result.setMessage(message);
result.setFilePath(filePath);
return result;
}
public static ChunkedUploadResult uploading(String message, int uploadedChunks, int totalChunks) {
ChunkedUploadResult result = new ChunkedUploadResult();
result.setSuccess(true);
result.setStatus("uploading");
result.setMessage(message);
result.setUploadedChunks(uploadedChunks);
result.setTotalChunks(totalChunks);
return result;
}
public static ChunkedUploadResult error(String message) {
ChunkedUploadResult result = new ChunkedUploadResult();
result.setSuccess(false);
result.setStatus("error");
result.setMessage(message);
return result;
}
public ChunkedUploadResult addData(String key, Object value) {
this.additionalData.put(key, value);
return this;
}
}

View File

@ -0,0 +1,106 @@
package com.unisinsight.project.service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.function.Consumer;
/**
*
*/
public interface ChunkedUploadService {
/**
*
*
* @param chunk
* @param chunkSize
* @param chunkMd5 MD5
* @param fileId
* @param chunkNumber (1)
* @param totalChunks
* @param fileName
* @param totalSize
* @param onComplete
* @return
* @throws IOException IO
*/
ChunkedUploadResult uploadChunk(
MultipartFile chunk,
int chunkSize,
String chunkMd5,
String fileId,
int chunkNumber,
int totalChunks,
String fileName,
long totalSize,
String fileSavePath,
Consumer<ChunkedUploadCompletion> onComplete
) throws IOException;
/**
*
*
* @param chunk
* @param chunkSize
* @param chunkMd5 MD5
* @param fileId
* @param chunkNumber (1)
* @param totalChunks
* @param fileName
* @param totalSize
* @return
* @throws IOException IO
*/
ChunkedUploadResult uploadChunk(
MultipartFile chunk,
int chunkSize,
String chunkMd5,
String fileId,
int chunkNumber,
int totalChunks,
String fileName,
long totalSize
) throws IOException;
/**
*
*
* @param fileId
*/
void cancelUpload(String fileId);
/**
*
*
* @param fileId
* @return
*/
ChunkedUploadStatus getUploadStatus(String fileId);
/**
*
*
* @param fileId
* @param outputPath
* @param totalChunks
* @throws IOException IO
*/
void mergeChunks(String fileId, String outputPath, int totalChunks) throws IOException;
/**
*
*
* @param fileId
* @throws IOException IO
*/
void cleanupTempFiles(String fileId) throws IOException;
/**
*
*
* @param fileName
* @throws IOException IO
*/
void cleanUploadFile(String fileName) throws IOException;
}

View File

@ -0,0 +1,52 @@
package com.unisinsight.project.service;
import lombok.Data;
/**
*
*/
@Data
public class ChunkedUploadStatus {
private boolean success;
private String status;
private String message;
private String filePath;
private Integer uploadedChunks;
private Integer totalChunks;
private Double progress;
public static ChunkedUploadStatus completed(String filePath) {
ChunkedUploadStatus status = new ChunkedUploadStatus();
status.setSuccess(true);
status.setStatus("completed");
status.setMessage("文件上传已完成");
status.setFilePath(filePath);
return status;
}
public static ChunkedUploadStatus uploading(int uploadedChunks, int totalChunks) {
ChunkedUploadStatus status = new ChunkedUploadStatus();
status.setSuccess(true);
status.setStatus("uploading");
status.setUploadedChunks(uploadedChunks);
status.setTotalChunks(totalChunks);
status.setProgress((double) uploadedChunks / totalChunks);
return status;
}
public static ChunkedUploadStatus notFound() {
ChunkedUploadStatus status = new ChunkedUploadStatus();
status.setSuccess(true);
status.setStatus("not_found");
status.setMessage("文件上传信息不存在");
return status;
}
public static ChunkedUploadStatus error(String message) {
ChunkedUploadStatus status = new ChunkedUploadStatus();
status.setSuccess(false);
status.setStatus("error");
status.setMessage(message);
return status;
}
}

View File

@ -22,4 +22,8 @@ public interface ClientOperateService {
Result<?> terminalStart(DeviceReq deviceReq);
Result<?> terminalEnd(DeviceReq deviceReq);
Result<?> usbEnabled(DeviceReq deviceReq);
Result<?> usbDisabled(DeviceReq deviceReq);
}

View File

@ -0,0 +1,32 @@
package com.unisinsight.project.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.unisinsight.project.entity.dao.ImageTool;
import com.unisinsight.project.entity.req.DeleteIdReq;
import com.unisinsight.project.entity.req.ImageToolReq;
import com.unisinsight.project.entity.res.ImageToolRes;
import com.unisinsight.project.entity.res.PageResult;
import com.unisinsight.project.exception.Result;
import org.springframework.web.multipart.MultipartFile;
/**
* (ImageTool)
*
* @author ch
* @since 2025-08-29 14:58:39
*/
public interface ImageToolService extends IService<ImageTool> {
Result<PageResult<ImageToolRes>> selectPage(ImageToolReq imageToolReq);
Result<?> insert(ImageToolReq imageToolReq);
Result<?> update(ImageToolReq imageToolReq);
Result<?> query(ImageToolReq imageToolReq);
Result<?> delete(DeleteIdReq deleteIdReq);
Result<?> uploadChunk(MultipartFile chunk, int chunkSize, String chunkMd5, String fileId, int chunkNumber, int totalChunks, String fileName, long totalSize, String description);
}

View File

@ -0,0 +1,262 @@
package com.unisinsight.project.service.impl;
import com.unisinsight.project.service.*;
import com.unisinsight.project.util.DigestUtil;
import com.unisinsight.project.util.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
/**
*
*/
@Service
@Slf4j
public class ChunkedUploadServiceImpl implements ChunkedUploadService {
// 临时目录,用于存储上传的分片
@Value("${file.upload.temp-dir:${java.io.tmpdir}/chunked-uploads}")
private String tempDir;
// 最终文件存储目录
@Value("${file.upload.dir:${user.home}/uploads}")
private String uploadDir;
// 存储每个文件的分片信息
private final ConcurrentMap<String, FileUploadInfo> fileUploadMap = new ConcurrentHashMap<>();
@Override
public ChunkedUploadResult uploadChunk(
MultipartFile chunk,
int chunkSize,
String chunkMd5,
String fileId,
int chunkNumber,
int totalChunks,
String fileName,
long totalSize,
String fileSavePath,
Consumer<ChunkedUploadCompletion> onComplete
) throws IOException {
try {
// MD5校验
String md5 = DigestUtil.encryptMd5(chunk.getBytes());
if (!chunkMd5.equals(md5)) {
log.info("分片文件md5校验失败,chunkMd5:{},md5:{}", chunkMd5, md5);
throw new RuntimeException("分片文件md5校验失败");
}
// 创建临时目录
Path fileTempDir = Paths.get(tempDir, fileId);
if (!Files.exists(fileTempDir)) {
Files.createDirectories(fileTempDir);
}
// 保存分片文件
String chunkFileName = String.format("%05d.part", chunkNumber);
log.info("保存分片文件: {}", chunkFileName);
Path chunkFilePath = fileTempDir.resolve(chunkFileName);
chunk.transferTo(chunkFilePath);
// 更新文件上传信息
FileUploadInfo uploadInfo = fileUploadMap.computeIfAbsent(fileId,
id -> new FileUploadInfo(id, fileName, totalChunks, totalSize));
uploadInfo.addUploadedChunk(chunkNumber);
// 检查是否所有分片都已上传
if (uploadInfo.isUploadComplete()) {
// 合并文件
Path finalDir = Paths.get(StringUtil.isNotEmpty(fileSavePath) ? fileSavePath : uploadDir);
if (!Files.exists(finalDir)) {
Files.createDirectories(finalDir);
}
Path finalFilePath = finalDir.resolve(fileName);
log.info("合并所有分片文件: {}", finalFilePath);
mergeChunks(fileId, finalFilePath.toString(), totalChunks);
// 清理临时文件
log.info("清理临时文件: {}", fileId);
cleanupTempFiles(fileId);
// 从上传映射中移除
fileUploadMap.remove(fileId);
// 调用完成回调
if (onComplete != null) {
ChunkedUploadCompletion completion = new ChunkedUploadCompletion(
fileId, fileName, finalFilePath.toString(), totalSize);
onComplete.accept(completion);
}
return ChunkedUploadResult.success("completed", "文件上传并合并完成", finalFilePath.toString());
} else {
return ChunkedUploadResult.uploading("分片上传成功", uploadInfo.getUploadedChunks().size(), totalChunks);
}
} catch (Exception e) {
log.error("上传失败,fileId:{},error:{}", fileId, e.getMessage(), e);
try {
cleanupTempFiles(fileId);
cleanUploadFile(fileName);
} catch (IOException ex) {
log.error("清理临时文件失败,fileId:{},error:{}", fileId, ex.getMessage(), ex);
}
throw e;
}
}
@Override
public ChunkedUploadResult uploadChunk(
MultipartFile chunk,
int chunkSize,
String chunkMd5,
String fileId,
int chunkNumber,
int totalChunks,
String fileName,
long totalSize
) throws IOException {
return uploadChunk(chunk, chunkSize, chunkMd5, fileId, chunkNumber, totalChunks, fileName, totalSize, null, null);
}
@Override
public void cancelUpload(String fileId) {
if (fileId == null || fileId.isEmpty()) {
return;
}
log.info("取消上传,清理临时文件: {}", fileId);
try {
// 从上传映射中移除
fileUploadMap.remove(fileId);
cleanupTempFiles(fileId);
} catch (IOException ex) {
log.error("清理临时文件失败,fileId:{},error:{}", fileId, ex.getMessage(), ex);
}
}
@Override
public ChunkedUploadStatus getUploadStatus(String fileId) {
FileUploadInfo uploadInfo = fileUploadMap.get(fileId);
if (uploadInfo == null) {
// 检查文件是否已经完成上传并合并
try {
Path finalFilePath = Paths.get(uploadDir, fileId);
if (Files.exists(finalFilePath)) {
return ChunkedUploadStatus.completed(finalFilePath.toString());
} else {
return ChunkedUploadStatus.notFound();
}
} catch (Exception e) {
return ChunkedUploadStatus.error("查询状态失败: " + e.getMessage());
}
} else {
return ChunkedUploadStatus.uploading(uploadInfo.getUploadedChunks().size(), uploadInfo.getTotalChunks());
}
}
@Override
public void mergeChunks(String fileId, String outputPath, int totalChunks) throws IOException {
Path finalPath = Paths.get(outputPath);
Path tempOutputPath = Paths.get(outputPath + ".tmp");
try {
// 先写入临时文件
try (OutputStream outputStream = Files.newOutputStream(tempOutputPath)) {
Path fileTempDir = Paths.get(tempDir, fileId);
// 按顺序合并分片
for (int i = 1; i <= totalChunks; i++) {
String chunkFileName = String.format("%05d.part", i);
Path chunkPath = fileTempDir.resolve(chunkFileName);
if (!Files.exists(chunkPath)) {
throw new IOException("缺少分片文件: " + chunkFileName);
}
// 将分片内容追加到输出文件
Files.copy(chunkPath, outputStream);
}
}
// 原子性地替换目标文件
Files.move(tempOutputPath, finalPath, StandardCopyOption.REPLACE_EXISTING);
} finally {
// 确保临时文件被删除
if (Files.exists(tempOutputPath)) {
Files.delete(tempOutputPath);
}
}
}
@Override
public void cleanupTempFiles(String fileId) throws IOException {
Path fileTempDir = Paths.get(tempDir, fileId);
if (Files.exists(fileTempDir)) {
// 递归删除临时目录及其内容
Files.walk(fileTempDir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
}
}
@Override
public void cleanUploadFile(String fileName) throws IOException {
Path filePath = Paths.get(uploadDir, fileName);
if (Files.exists(filePath)) {
// 删除文件
Files.delete(filePath);
log.info("已删除文件: {}", filePath);
} else {
log.warn("文件不存在,无需删除: {}", filePath);
}
}
/**
*
*/
private static class FileUploadInfo {
private final String fileId;
private final String fileName;
private final int totalChunks;
private final long totalSize;
private final Set<Integer> uploadedChunks;
public FileUploadInfo(String fileId, String fileName, int totalChunks, long totalSize) {
this.fileId = fileId;
this.fileName = fileName;
this.totalChunks = totalChunks;
this.totalSize = totalSize;
this.uploadedChunks = ConcurrentHashMap.newKeySet();
}
public void addUploadedChunk(int chunkNumber) {
uploadedChunks.add(chunkNumber);
}
public boolean isUploadComplete() {
return uploadedChunks.size() == totalChunks;
}
public Set<Integer> getUploadedChunks() {
return uploadedChunks;
}
public int getTotalChunks() {
return totalChunks;
}
}
}

View File

@ -1,6 +1,7 @@
package com.unisinsight.project.service.impl;
import com.unisinsight.project.entity.enums.GrpcTypeEnum;
import com.unisinsight.project.entity.req.DeviceReq;
import com.unisinsight.project.exception.Result;
import com.unisinsight.project.grpc.generate.NotificationMessage;
@ -35,7 +36,22 @@ public class ClientOperateServiceImpl implements ClientOperateService {
@Override
public Result<?> terminalEnd(DeviceReq deviceReq) {
//todo 待客户端确认消息内容后完善
return notificationService.sendNotification(deviceReq.getDeviceId(), NotificationMessage.newBuilder().setContent("终端关机").build());
return notificationService.sendNotification(deviceReq.getDeviceId(), NotificationMessage.newBuilder()
.setType(GrpcTypeEnum.SHUTDOWN.getType())
.setContent(GrpcTypeEnum.SHUTDOWN.getDesc()).build());
}
@Override
public Result<?> usbEnabled(DeviceReq deviceReq) {
return notificationService.sendNotification(deviceReq.getDeviceId(), NotificationMessage.newBuilder()
.setType(GrpcTypeEnum.USB_ENABLED.getType())
.setContent(GrpcTypeEnum.USB_ENABLED.getDesc()).build());
}
@Override
public Result<?> usbDisabled(DeviceReq deviceReq) {
return notificationService.sendNotification(deviceReq.getDeviceId(), NotificationMessage.newBuilder()
.setType(GrpcTypeEnum.USB_DISABLED.getType())
.setContent(GrpcTypeEnum.USB_DISABLED.getDesc()).build());
}
}

View File

@ -0,0 +1,216 @@
package com.unisinsight.project.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.unisinsight.project.entity.dao.ImageTool;
import com.unisinsight.project.entity.req.DeleteIdReq;
import com.unisinsight.project.entity.req.ImageToolReq;
import com.unisinsight.project.entity.res.ImageToolRes;
import com.unisinsight.project.entity.res.PageResult;
import com.unisinsight.project.exception.BaseErrorCode;
import com.unisinsight.project.exception.Result;
import com.unisinsight.project.mapper.ImageToolMapper;
import com.unisinsight.project.service.ChunkedUploadCompletion;
import com.unisinsight.project.service.ChunkedUploadResult;
import com.unisinsight.project.service.ChunkedUploadService;
import com.unisinsight.project.service.ImageToolService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
@Slf4j
/**
* (ImageTool)
*
* @author ch
* @since 2025-08-29 14:58:39
*/
@Service("imageToolService")
public class ImageToolServiceImpl extends ServiceImpl<ImageToolMapper, ImageTool> implements ImageToolService {
@Resource
private ImageToolMapper mapper;
@Resource
private ChunkedUploadService chunkedUploadService;
@Value("${file.upload.tool.dir:${user.home}/uploads/tool}")
private String uploadDir;
@Override
public Result<PageResult<ImageToolRes>> selectPage(ImageToolReq imageToolReq) {
Page<ImageTool> page = new Page<>(imageToolReq.getPageNum(), imageToolReq.getPageSize());
LambdaQueryWrapper<ImageTool> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByAsc(ImageTool::getId);
Page<ImageTool> imageToolPage = mapper.selectPage(page, queryWrapper);
log.info("分页查询返回:{}", JSONUtil.toJsonStr(imageToolPage));
if (CollectionUtil.isEmpty(imageToolPage.getRecords())) {
log.info("分页查询返回为空");
return Result.successResult();
} else {
PageResult<ImageToolRes> convert = PageResult.convertIPage(imageToolPage, ImageToolRes.class);
List<ImageToolRes> data = convert.getData();
convert.setData(data);
return Result.successResult(convert);
}
}
@Override
public Result<?> insert(ImageToolReq imageToolReq) {
ImageTool imageTool = BeanUtil.copyProperties(imageToolReq, ImageTool.class);
imageTool.setCreateUser("admin");
imageTool.setUpdateUser("admin");
int insert = mapper.insert(imageTool);
log.info("新增insert:{}", insert);
if (insert == 1) {
return Result.successResult();
} else {
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500);
}
}
@Override
public Result<?> update(ImageToolReq imageToolReq) {
ImageTool imageTool = BeanUtil.copyProperties(imageToolReq, ImageTool.class);
imageTool.setUpdateUser("admin");
int updated = mapper.updateById(imageTool);
log.info("修改updated:{}", updated);
if (updated == 1) {
return Result.successResult();
} else {
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500);
}
}
@Override
public Result<?> query(ImageToolReq imageToolReq) {
ImageTool imageTool = mapper.selectById(imageToolReq.getId());
if (ObjectUtils.isEmpty(imageTool)) {
log.info("查询返回为空");
return Result.successResult();
}
ImageToolRes res = BeanUtil.copyProperties(imageTool, ImageToolRes.class);
log.info("查询返回:{}", JSONUtil.toJsonStr(res));
return Result.successResult(res);
}
@Override
public Result<?> delete(DeleteIdReq deleteIdReq) {
int deleted = mapper.deleteById(deleteIdReq.getId());
log.info("删除insert:{}", deleted);
if (deleted == 1) {
return Result.successResult();
} else {
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500);
}
}
@Override
public Result<?> uploadChunk(MultipartFile chunk, int chunkSize, String chunkMd5, String fileId, int chunkNumber, int totalChunks, String fileName, long totalSize, String description) {
LambdaQueryWrapper<ImageTool> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ImageTool::getFileName, fileName);
ImageTool exists = mapper.selectOne(queryWrapper);
if (exists!=null){
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500,"文件名重复");
}
Map<String, Object> response = new HashMap<>();
try {
ChunkedUploadResult result = chunkedUploadService.uploadChunk(
chunk, chunkSize, chunkMd5, fileId, chunkNumber, totalChunks,
fileName, totalSize, uploadDir, completion -> {
try {
// 文件上传完成,保存到数据库
ImageTool imageTool = new ImageTool();
imageTool.setFileName(completion.getFileName());
imageTool.setFileType(getFileType(completion.getFileName()));
imageTool.setFileSize(completion.getFileSize());
// imageTool.setFileVersion(fileVersion);
imageTool.setStorePath(completion.getFilePath());
imageTool.setUploadTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
imageTool.setCreateTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
imageTool.setCreateUser("admin");
imageTool.setUpdateTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
imageTool.setUpdateUser("admin");
if (description != null && !description.isEmpty()) {
imageTool.setDescription(description);
}
boolean save = this.save(imageTool);
log.info("ImageTool新增结果: {}", save);
if (!save) {
// 如果保存数据库失败,删除已上传的文件
try {
chunkedUploadService.cleanUploadFile(completion.getFileName());
} catch (Exception e) {
log.error("清理上传文件失败,fileName:{},error:{}", completion.getFileName(), e.getMessage(), e);
}
}
} catch (Exception e) {
log.error("保存ImageTool失败,fileId:{},error:{}", completion.getFileId(), e.getMessage(), e);
}
}
);
if (result.isSuccess()) {
if ("completed".equals(result.getStatus())) {
response.put("status", "completed");
response.put("message", "文件上传并合并完成");
response.put("filePath", result.getFilePath());
} else {
response.put("status", "uploading");
response.put("message", "分片上传成功");
response.put("uploadedChunks", result.getUploadedChunks());
response.put("totalChunks", totalChunks);
}
}
} catch (IOException e) {
response.put("success", false);
response.put("status", "error");
response.put("message", "上传失败");
log.info("上次失败清理临时文件: {},error:{}", fileId, e.getMessage(), e);
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500);
}
response.put("success", true);
return Result.successResult(response);
}
/**
*
*
* @param fileName
* @return
*/
private String getFileType(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return "";
}
int lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
return fileName.substring(lastDotIndex + 1).toLowerCase();
}
return "";
}
}

View File

@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.unisinsight.project.entity.dao.Image;
import com.unisinsight.project.entity.dao.ImageTool;
import com.unisinsight.project.entity.dao.ImageVirtualMachines;
import com.unisinsight.project.entity.req.DeleteIdReq;
import com.unisinsight.project.entity.req.ImageCreateReq;
@ -22,6 +23,7 @@ import com.unisinsight.project.exception.Result;
import com.unisinsight.project.mapper.ImageVirtualMachinesMapper;
import com.unisinsight.project.properties.ImageConfigProperties;
import com.unisinsight.project.service.ImageService;
import com.unisinsight.project.service.ImageToolService;
import com.unisinsight.project.service.ImageVirtualMachinesService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
@ -54,7 +56,8 @@ public class ImageVirtualMachinesServiceImpl extends ServiceImpl<ImageVirtualMac
private RestTemplate restTemplate;
@Autowired
private ImageConfigProperties imageConfigProperties;
@Resource
private ImageToolService imageToolService;
@Override
@ -124,11 +127,26 @@ public class ImageVirtualMachinesServiceImpl extends ServiceImpl<ImageVirtualMac
@Override
public Result<?> insert(ImageVirtualMachinesReq imageVirtualMachinesReq) {
QueryWrapper<ImageVirtualMachines> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(ImageVirtualMachines::getImageName, imageVirtualMachinesReq.getImageName());
ImageVirtualMachines selectOne = machinesMapper.selectOne(queryWrapper);
if (ObjectUtils.isNotEmpty(selectOne)) {
return Result.errorResultMessage(BaseErrorCode.HTTP_ERROR_CODE_500, "名称重复");
}
// 调用镜像生成服务
LambdaQueryWrapper<Image> imageLambdaQueryWrapper = new LambdaQueryWrapper<>();
imageLambdaQueryWrapper.in(Image::getId, imageVirtualMachinesReq.getImageSystemId());
Image systemImage = imageService.getOne(imageLambdaQueryWrapper);
//查询驱动信息
LambdaQueryWrapper<ImageTool> imageToolLambdaQueryWrapper = new LambdaQueryWrapper<>();
imageToolLambdaQueryWrapper.eq(ImageTool::getFileName, imageVirtualMachinesReq.getImageName());
ImageTool imageTool = imageToolService.getOne(imageToolLambdaQueryWrapper);
ImageCreateReq createReq = ImageCreateReq.builder()
.name(imageVirtualMachinesReq.getImageName())
.osType(imageVirtualMachinesReq.getOsVersion())
@ -137,7 +155,7 @@ public class ImageVirtualMachinesServiceImpl extends ServiceImpl<ImageVirtualMac
.memory(imageVirtualMachinesReq.getMemoryTotal())
.diskSize(imageVirtualMachinesReq.getSystemTotal())
//存储池
// .storagePool(imageVirtualMachinesReq.getStoragePath())
.storagePoolName(imageVirtualMachinesReq.getStoragePoolName())
.networkName(imageVirtualMachinesReq.getNetworkModule())
.isoPath(imageVirtualMachinesReq.getStoragePath())
//驱动
@ -154,12 +172,7 @@ public class ImageVirtualMachinesServiceImpl extends ServiceImpl<ImageVirtualMac
}
ImageVirtualMachines imageVirtualMachines = BeanUtil.copyProperties(imageVirtualMachinesReq, ImageVirtualMachines.class);
QueryWrapper<ImageVirtualMachines> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(ImageVirtualMachines::getImageName, imageVirtualMachines.getImageName());
ImageVirtualMachines selectOne = machinesMapper.selectOne(queryWrapper);
if (ObjectUtils.isNotEmpty(selectOne)) {
return new Result<>("200", "名称重复");
}
imageVirtualMachines.setCreateUser("admin");
imageVirtualMachines.setUpdateUser("admin");
int insert = machinesMapper.insert(imageVirtualMachines);
@ -167,7 +180,7 @@ public class ImageVirtualMachinesServiceImpl extends ServiceImpl<ImageVirtualMac
if (insert == 1) {
return Result.successResult();
} else {
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500);
return Result.errorResult(BaseErrorCode.HTTP_ERROR_CODE_500,"新增失败");
}
}

View File

@ -6,8 +6,10 @@ file:
temp-dir: /var/lib/vdi/tmp/chunked-uploads
dir: /var/lib/vdi/test
bt-url: http://10.100.51.86:8114
tool:
dir: /vms/tool/iso
image:
base-url: http://10.100.51.118:5173
base-url: http://10.100.51.178:5173
status-url: /api/v1/vm/batch-status
create-url: /api/v1/vm/create
delete-url: /api/v1/vm/delete

View File

@ -0,0 +1,27 @@
<?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.unisinsight.project.mapper.ImageToolMapper">
<resultMap type="com.unisinsight.project.entity.dao.ImageTool" id="ImageToolMap">
<result property="id" column="id" jdbcType="INTEGER"/>
<result property="fileName" column="file_name" jdbcType="VARCHAR"/>
<result property="fileType" column="file_type" jdbcType="VARCHAR"/>
<result property="fileSize" column="file_size" jdbcType="INTEGER"/>
<result property="fileVersion" column="file_version" jdbcType="VARCHAR"/>
<result property="createTime" column="create_time" jdbcType="VARCHAR"/>
<result property="createUser" column="create_user" jdbcType="VARCHAR"/>
<result property="updateTime" column="update_time" jdbcType="VARCHAR"/>
<result property="updateUser" column="update_user" jdbcType="VARCHAR"/>
<result property="uploadTime" column="upload_time" jdbcType="VARCHAR"/>
<result property="storePath" column="store_path" jdbcType="VARCHAR"/>
<result property="description" column="description" jdbcType="VARCHAR"/>
</resultMap>
<sql id="Base_Column_List">
id, file_name, file_type, file_size, file_version, create_time, create_user, update_time, update_user, upload_time, store_path, description
</sql>
</mapper>