feat:代码结构

master
汤全昆 2025-08-05 17:22:16 +08:00
parent 9a64e26caf
commit ddc23832b7
7 changed files with 870 additions and 0 deletions

14
nex-be/Dockerfile 100644
View File

@ -0,0 +1,14 @@
# 使用官方OpenJDK作为基础镜像
FROM openjdk:11-jre-slim
# 设置工作目录
WORKDIR /app
# 复制项目的jar文件到容器中
COPY target/*.jar app.jar
# 暴露应用端口
EXPOSE 8080
# 启动应用
ENTRYPOINT ["java", "-jar", "app.jar"]

63
nex-be/pom.xml 100644
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>chunked-upload-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>nex-be</name>
<description>开箱即用管理端</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Configuration Processor (可选) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.dampcake</groupId>
<artifactId>bencode</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,15 @@
package com.unisinsight.project;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
*
*/
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@ -0,0 +1,67 @@
package com.unisinsight.project.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import springfox.bean.validators.configuration.BeanValidatorPluginsConfiguration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration
@EnableSwagger2
@Import(BeanValidatorPluginsConfiguration.class)
@EnableWebMvc
public class Knife4jConfig implements WebMvcConfigurer {
@Bean
public Docket defaultApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.groupName("默认分组")
.select()
.apis(RequestHandlerSelectors.basePackage("org.hz.controller")) // 修改为你的controller包路径
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("文件分片上传服务API")
.description("# 大文件分片上传接口文档\n\n"
+ "## 功能说明\n"
+ "1. 支持大文件分片上传\n"
+ "2. 支持断点续传\n"
+ "3. 自动合并完整文件\n\n"
+ "## 使用流程\n"
+ "1. 前端将大文件分片\n"
+ "2. 依次上传各分片\n"
+ "3. 系统自动合并文件")
.termsOfServiceUrl("http://localhost:8080/")
.contact(new Contact("API开发者", "https://example.com", "developer@example.com"))
.version("1.0.0")
.build();
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 配置Swagger UI和WebJars资源的访问路径使得这些静态资源可以通过特定的URL路径在Web应用中被访问
registry.addResourceHandler("doc.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("swagger-ui.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
}

View File

@ -0,0 +1,264 @@
package com.unisinsight.project.controller;
import io.swagger.annotations.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
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.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
*
*/
@RestController
@RequestMapping("/api/files")
@Api(tags = "文件分片上传接口")
public class FileChunkController {
// 临时目录,用于存储上传的分片
@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 Map<String, FileUploadInfo> fileUploadMap = new ConcurrentHashMap<>();
/**
*
*
* @param chunk
* @param fileId
* @param chunkNumber (1)
* @param totalChunks
* @param fileName
* @param totalSize
* @return
*/
@PostMapping("/upload-chunk")
@ApiOperation(value = "上传文件分片", notes = "上传单个文件分片,当所有分片上传完成后自动合并文件")
@ApiImplicitParams({
@ApiImplicitParam(name = "chunk", value = "文件分片", required = true, dataType = "__File", paramType = "form"),
@ApiImplicitParam(name = "fileId", value = "文件唯一标识符", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "chunkNumber", value = "当前分片编号(从1开始)", required = true, dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "totalChunks", value = "总分片数", required = true, dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "fileName", value = "原始文件名", required = true, dataType = "String", paramType = "query"),
@ApiImplicitParam(name = "totalSize", value = "文件总大小", required = true, dataType = "long", paramType = "query")
})
@ApiResponses({
@ApiResponse(code = 200, message = "上传成功"),
@ApiResponse(code = 500, message = "服务器内部错误")
})
public ResponseEntity<Map<String, Object>> uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("fileId") String fileId,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("fileName") String fileName,
@RequestParam("totalSize") long totalSize) {
Map<String, Object> response = new HashMap<>();
try {
// 创建临时目录
Path fileTempDir = Paths.get(tempDir, fileId);
if (!Files.exists(fileTempDir)) {
Files.createDirectories(fileTempDir);
}
// 保存分片文件
String chunkFileName = String.format("%05d.part", chunkNumber);
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(uploadDir);
if (!Files.exists(finalDir)) {
Files.createDirectories(finalDir);
}
Path finalFilePath = finalDir.resolve(fileName);
mergeChunks(fileId, finalFilePath, totalChunks);
// 清理临时文件
cleanupTempFiles(fileId);
// 从上传映射中移除
fileUploadMap.remove(fileId);
response.put("status", "completed");
response.put("message", "文件上传并合并完成");
response.put("filePath", finalFilePath.toString());
} else {
response.put("status", "uploading");
response.put("message", "分片上传成功");
response.put("uploadedChunks", uploadInfo.getUploadedChunks().size());
response.put("totalChunks", totalChunks);
}
response.put("success", true);
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "上传失败: " + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
*
*
* @param fileId
* @return
*/
@GetMapping("/upload-status/{fileId}")
@ApiOperation("查询文件上传状态")
public ResponseEntity<Map<String, Object>> getUploadStatus(@PathVariable String fileId) {
Map<String, Object> response = new HashMap<>();
FileUploadInfo uploadInfo = fileUploadMap.get(fileId);
if (uploadInfo == null) {
// 检查文件是否已经完成上传并合并
try {
Path finalFilePath = Paths.get(uploadDir, fileId);
if (Files.exists(finalFilePath)) {
response.put("status", "completed");
response.put("message", "文件上传已完成");
response.put("filePath", finalFilePath.toString());
} else {
response.put("status", "not_found");
response.put("message", "文件上传信息不存在");
}
} catch (Exception e) {
response.put("status", "error");
response.put("message", "查询状态失败: " + e.getMessage());
}
} else {
response.put("status", "uploading");
response.put("uploadedChunks", uploadInfo.getUploadedChunks().size());
response.put("totalChunks", uploadInfo.getTotalChunks());
response.put("progress", (double) uploadInfo.getUploadedChunks().size() / uploadInfo.getTotalChunks());
}
response.put("success", true);
return ResponseEntity.ok(response);
}
/**
*
*
* @param fileId
* @param outputPath
* @param totalChunks
* @throws IOException IO
*/
private void mergeChunks(String fileId, Path outputPath, int totalChunks) throws IOException {
try (OutputStream outputStream = Files.newOutputStream(outputPath)) {
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);
}
}
}
/**
*
*
* @param fileId
* @throws IOException IO
*/
private 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);
}
}
/**
*
*/
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 String getFileId() {
return fileId;
}
public String getFileName() {
return fileName;
}
public int getTotalChunks() {
return totalChunks;
}
public long getTotalSize() {
return totalSize;
}
public Set<Integer> getUploadedChunks() {
return uploadedChunks;
}
}
@GetMapping("/test")
@ApiOperation("测试")
public ResponseEntity<String> getUploadStatus() {
return ResponseEntity.ok("ok");
}
}

View File

@ -0,0 +1,429 @@
package com.unisinsight.project.util;
import com.dampcake.bencode.BencodeInputStream;
import com.dampcake.bencode.BencodeOutputStream;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* BT
*/
public class BtTorrentUtil {
/**
* BT
*
* @param filePath
* @param trackerUrl Tracker
* @param outputFile
* @throws IOException IO
* @throws URISyntaxException URI
*/
public static void createTorrent(String filePath, String trackerUrl, String outputFile)
throws IOException, URISyntaxException {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
throw new FileNotFoundException("文件或目录不存在: " + filePath);
}
// 验证tracker URL格式
new URI(trackerUrl);
// 创建种子信息
TorrentInfo torrentInfo = new TorrentInfo();
torrentInfo.setAnnounce(trackerUrl);
torrentInfo.setCreationDate(new Date().getTime() / 1000);
torrentInfo.setCreatedBy("BtTorrentUtil v1.0");
// 处理文件信息
if (Files.isDirectory(path)) {
processDirectory(path, torrentInfo);
} else {
processFile(path, torrentInfo);
}
// 生成种子文件
writeTorrentFile(torrentInfo, outputFile);
}
/**
*
*/
private static void processFile(Path filePath, TorrentInfo torrentInfo) throws IOException {
FileInfo fileInfo = new FileInfo();
fileInfo.setLength(Files.size(filePath));
fileInfo.setPath(new ArrayList<>());
fileInfo.getPath().add(filePath.getFileName().toString());
List<FileInfo> files = new ArrayList<>();
files.add(fileInfo);
torrentInfo.setFiles(files);
torrentInfo.setName(filePath.getFileName().toString());
torrentInfo.setPieceLength(524288); // 512KB
// 计算文件的pieces
byte[] pieces = calculatePieces(filePath, torrentInfo.getPieceLength());
torrentInfo.setPieces(pieces);
}
/**
*
*/
private static void processDirectory(Path dirPath, TorrentInfo torrentInfo) throws IOException {
List<FileInfo> files = new ArrayList<>();
long totalSize = 0;
Files.walk(dirPath)
.filter(Files::isRegularFile)
.forEach(file -> {
try {
FileInfo fileInfo = new FileInfo();
fileInfo.setLength(Files.size(file));
List<String> pathList = new ArrayList<>();
// 获取相对路径
Path relativePath = dirPath.relativize(file);
for (Path part : relativePath) {
pathList.add(part.toString());
}
fileInfo.setPath(pathList);
files.add(fileInfo);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
torrentInfo.setFiles(files);
torrentInfo.setName(dirPath.getFileName().toString());
torrentInfo.setPieceLength(524288); // 512KB
// 计算所有文件的pieces简化实现
// 实际应用中需要按顺序读取所有文件数据来计算pieces
ByteArrayOutputStream baos = new ByteArrayOutputStream();
for (FileInfo file : files) {
Path filePath = dirPath;
for (String pathPart : file.getPath()) {
filePath = filePath.resolve(pathPart);
}
if (Files.exists(filePath)) {
Files.copy(filePath, baos);
}
}
byte[] pieces = calculatePiecesFromData(baos.toByteArray(), torrentInfo.getPieceLength());
torrentInfo.setPieces(pieces);
}
/**
* pieces
*/
private static byte[] calculatePieces(Path filePath, int pieceLength) throws IOException {
ByteArrayOutputStream pieces = new ByteArrayOutputStream();
byte[] buffer = new byte[pieceLength];
int bytesRead;
try (InputStream is = Files.newInputStream(filePath)) {
while ((bytesRead = is.read(buffer)) != -1) {
// 简化实现实际应使用SHA1哈希
// 这里只是示例实际应用中需要计算SHA1
byte[] hash = new byte[20]; // SHA1哈希长度为20字节
for (int i = 0; i < Math.min(20, bytesRead); i++) {
hash[i] = (byte) (buffer[i] % 256);
}
pieces.write(hash);
}
}
return pieces.toByteArray();
}
/**
* pieces
*/
private static byte[] calculatePiecesFromData(byte[] data, int pieceLength) throws IOException {
ByteArrayOutputStream pieces = new ByteArrayOutputStream();
for (int i = 0; i < data.length; i += pieceLength) {
int length = Math.min(pieceLength, data.length - i);
byte[] piece = new byte[length];
System.arraycopy(data, i, piece, 0, length);
// 简化实现实际应使用SHA1哈希
byte[] hash = new byte[20];
for (int j = 0; j < Math.min(20, length); j++) {
hash[j] = (byte) (piece[j] % 256);
}
pieces.write(hash);
}
return pieces.toByteArray();
}
/**
*
*/
private static void writeTorrentFile(TorrentInfo torrentInfo, String outputFile) throws IOException {
try (FileOutputStream fos = new FileOutputStream(outputFile);
BencodeOutputStream bos = new BencodeOutputStream(fos)) {
// 构建种子数据结构
java.util.Map<String, Object> torrentMap = new java.util.HashMap<>();
torrentMap.put("announce", torrentInfo.getAnnounce());
torrentMap.put("creation date", torrentInfo.getCreationDate());
torrentMap.put("created by", torrentInfo.getCreatedBy());
// info字典
java.util.Map<String, Object> infoMap = new java.util.HashMap<>();
infoMap.put("name", torrentInfo.getName());
infoMap.put("piece length", torrentInfo.getPieceLength());
infoMap.put("pieces", torrentInfo.getPieces());
if (torrentInfo.getFiles() != null && !torrentInfo.getFiles().isEmpty()) {
// 多文件模式
java.util.List<java.util.Map<String, Object>> fileList = new ArrayList<>();
for (FileInfo file : torrentInfo.getFiles()) {
java.util.Map<String, Object> fileMap = new java.util.HashMap<>();
fileMap.put("length", file.getLength());
fileMap.put("path", file.getPath());
fileList.add(fileMap);
}
infoMap.put("files", fileList);
} else {
// 单文件模式
infoMap.put("length", torrentInfo.getLength());
}
torrentMap.put("info", infoMap);
// 写入文件
bos.writeDictionary(torrentMap);
}
}
/**
*
*
* @param torrentFilePath
* @param downloadPath
* @throws IOException IO
*/
public static void downloadTorrent(String torrentFilePath, String downloadPath) throws IOException {
Path source = Paths.get(torrentFilePath);
Path target = Paths.get(downloadPath);
if (!Files.exists(source)) {
throw new FileNotFoundException("种子文件不存在: " + torrentFilePath);
}
Files.copy(source, target);
}
/**
*
*
* @param torrentFilePath
* @return
* @throws IOException IO
*/
public static TorrentInfo parseTorrent(String torrentFilePath) throws IOException {
try (FileInputStream fis = new FileInputStream(torrentFilePath);
BencodeInputStream bis = new BencodeInputStream(fis)) {
java.util.Map<String, Object> torrentMap = bis.readDictionary();
TorrentInfo torrentInfo = new TorrentInfo();
torrentInfo.setAnnounce((String) torrentMap.get("announce"));
Object creationDate = torrentMap.get("creation date");
if (creationDate instanceof Number) {
torrentInfo.setCreationDate(((Number) creationDate).longValue());
}
torrentInfo.setCreatedBy((String) torrentMap.get("created by"));
// 解析info部分
java.util.Map<String, Object> infoMap = (java.util.Map<String, Object>) torrentMap.get("info");
if (infoMap != null) {
torrentInfo.setName((String) infoMap.get("name"));
Object pieceLength = infoMap.get("piece length");
if (pieceLength instanceof Number) {
torrentInfo.setPieceLength(((Number) pieceLength).intValue());
}
torrentInfo.setPieces((byte[]) infoMap.get("pieces"));
Object length = infoMap.get("length");
if (length instanceof Number) {
torrentInfo.setLength(((Number) length).longValue());
}
// 处理文件列表
Object filesObj = infoMap.get("files");
if (filesObj instanceof java.util.List) {
java.util.List<java.util.Map<String, Object>> fileList =
(java.util.List<java.util.Map<String, Object>>) filesObj;
List<FileInfo> files = new ArrayList<>();
for (java.util.Map<String, Object> fileMap : fileList) {
FileInfo fileInfo = new FileInfo();
Object fileLength = fileMap.get("length");
if (fileLength instanceof Number) {
fileInfo.setLength(((Number) fileLength).longValue());
}
fileInfo.setPath((List<String>) fileMap.get("path"));
files.add(fileInfo);
}
torrentInfo.setFiles(files);
}
}
return torrentInfo;
}
}
/**
*
*/
public static class TorrentInfo {
private String announce;
private long creationDate;
private String createdBy;
private String name;
private long length;
private int pieceLength;
private byte[] pieces;
private List<FileInfo> files;
// Getters and Setters
public String getAnnounce() {
return announce;
}
public void setAnnounce(String announce) {
this.announce = announce;
}
public long getCreationDate() {
return creationDate;
}
public void setCreationDate(long creationDate) {
this.creationDate = creationDate;
}
public String getCreatedBy() {
return createdBy;
}
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
public int getPieceLength() {
return pieceLength;
}
public void setPieceLength(int pieceLength) {
this.pieceLength = pieceLength;
}
public byte[] getPieces() {
return pieces;
}
public void setPieces(byte[] pieces) {
this.pieces = pieces;
}
public List<FileInfo> getFiles() {
return files;
}
public void setFiles(List<FileInfo> files) {
this.files = files;
}
}
/**
*
*/
public static class FileInfo {
private long length;
private List<String> path;
// Getters and Setters
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
public List<String> getPath() {
return path;
}
public void setPath(List<String> path) {
this.path = path;
}
}
public static void main(String[] args) {
try {
// 创建种子文件
BtTorrentUtil.createTorrent(
"/path/to/your/file_or_directory",
"http://tracker.example.com:6969/announce",
"/path/to/output.torrent"
);
// 解析种子文件
BtTorrentUtil.TorrentInfo info = BtTorrentUtil.parseTorrent("/path/to/output.torrent");
System.out.println("种子名称: " + info.getName());
System.out.println("创建时间: " + new Date(info.getCreationDate() * 1000));
// 下载种子文件
BtTorrentUtil.downloadTorrent(
"/path/to/output.torrent",
"/path/to/download/location.torrent"
);
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,18 @@
# application.yml
server:
port: 8112
file:
upload:
temp-dir: /tmp/chunked-uploads
dir: /uploads
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
knife4j:
production: false
basic:
enable: false