成功响应
{
"success": true,
"data": {},
"msg": "请求成功"
}失败响应
{
"success": false,
"msg": "上传任务不存在",
"code": "UPLOAD_TASK_NOT_FOUND"
}当前已约定的错误码,如下所示:
UPLOAD_TASK_NOT_FOUND
UPLOAD_PATH_INVALID
UPLOAD_FILE_EXISTS
UPLOAD_MISSING_CHUNKS
UPLOAD_CHUNK_TOO_LARGE
用于任务列表展示
{
"id": "upload-id",
"status": "Transferring",
"name": "demo.zip",
"speed": 1048576,
"progress": 50,
"remainTime": 12000,
"phase": "uploading",
"errorMessage": "",
"uploadedSize": 8388608,
"totalSize": 16777216,
"chunkSize": 8388608,
"totalChunks": 2,
"targetPath": "samples/demo.zip",
"canResume": true
}用于创建接口和详情接口
{
"id": "upload-id",
"uploadId": "upload-id",
"status": "Transferring",
"name": "demo.zip",
"phase": "uploading",
"speed": 0,
"progress": 0,
"remainTime": -1,
"uploadedSize": 0,
"uploadedChunks": [],
"totalSize": 16777216,
"chunkSize": 8388608,
"totalChunks": 2,
"targetPath": "samples/demo.zip",
"canResume": true,
"fileHash": "optional-sha256-or-md5"
}{
"uploadId": "upload-id",
"needUpload": true,
"session": {
"uploadId": "upload-id",
"chunkSize": 8388608,
"uploadedChunks": [],
"phase": "uploading"
}
}“preparing”
“hashing”
“uploading”
“merging”
“failed”
方法:“POST /api/v1/upload/create”
“Content-Type”:“application/json”
请求体
{
"fileName": "demo.zip",
"fileSize": 16777216,
"targetPath": "samples",
"fileHash": "optional-sha256-or-md5",
"overwrite": false
}字段说明
“fileName”:文件名,只能是文件名本身,不能带路径
“fileSize”:文件总字节数
“targetPath”:相对存储根目录的目标目录
“ileHash”:可选,不传则不做秒传和同文件会话复用
“overwrite”:可选,默认 “false”
返回重点
“needUpload = false”表示可以直接视为完成,无需继续上传分片
“session.chunkSize” 为服务端要求的分片大小,当前默认 “8MB”
方法:“POST /api/v1/upload/:id/chunk”
“Content-Type”:“multipart/form-data”
表单字段
“chunk”:二进制分片内容
“chunkIndex”:从 “0” 开始
“chunkHash”:可选,支持 “md5” 或 “sha256”
返回值
返回当前上传会话快照
已存在分片会直接返回最新快照,不会重复计数
方法:“POST /api/v1/upload/:id/complete”
“Content-Type”:“application/json”
请求体
{
"expectedHash": "optional-sha256-or-md5"
}说明
1)对外 API 必须显式调用本接口;
2)如果仍缺少分片,会返回 “UPLOAD_MISSING_CHUNKS”;
3)合并完成后目标文件先落到 “目标文件.uploading”,成功后再原子切换到正式文件。
方法:“GET /api/v1/upload/:id/info”
返回值
返回 “UploadTaskDetail”
可用于断点恢复时获取 “uploadedChunks”
方法:“POST /api/v1/upload/:id/cancel”
返回值
true说明
1)会删除该任务的分片临时目录;
2)会删除未完成的 “.uploading” 文件;
3)已经完成的正式文件不会被删除。
1)调用 “create”;
2) 读取 “data.session.chunkSize”;
3)按 “chunkSize”切分文件;
4) 循环调用 “chunk”;
5)调用 “complete”;
6)需要恢复时调用 “info”,根据 “uploadedChunks”继续上传。
curl --request POST \
--url "http://127.0.0.1:3000/api/v1/upload/create?key=YOUR_KEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN" \
--header "Content-Type: application/json" \
--data '{
"fileName": "demo.zip",
"fileSize": 16777216,
"targetPath": "samples",
"fileHash": "YOUR_SHA256",
"overwrite": false
}'curl --request POST \
--url "http://127.0.0.1:3000/api/v1/upload/UPLOAD_ID/chunk?key=YOUR_KEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN" \
--form "chunkIndex=0" \
--form "chunk=@./chunk-0.part"curl --request POST \
--url "http://127.0.0.1:3000/api/v1/upload/UPLOAD_ID/complete?key=YOUR_KEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN" \
--header "Content-Type: application/json" \
--data '{
"expectedHash": "YOUR_SHA256"
}'curl --request GET \
--url "http://127.0.0.1:3000/api/v1/upload/UPLOAD_ID/info?key=YOUR_KEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN"curl --request POST \
--url "http://127.0.0.1:3000/api/v1/upload/UPLOAD_ID/cancel?key=YOUR_KEY×tamp=YOUR_TIMESTAMP&sign=YOUR_SIGN"import { createHash } from "crypto"
import { readFile } from "fs/promises"
import path from "path"
/** 接口基础地址 */
const baseUrl = "http://192.168.31.104:42231/api/v1"
/** 接口访问 key */
const key = "54fd75dd-df0c-4e8d-8279-3fe91cf8280a"
/** 接口签名密钥 */
const secret = "1018cf2f336d5dc96f214f89dc998a46"
/** 接口访问令牌 */
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI7IkpXVCJ9.eyJrZXkiOiJjYjQyNjAyOC1hMDBlLTQ3MmQtYTI1Ny04YWJhMzNkYTJjM2UiLCJpYXQiOjE3MzYzMTQ2NjMsImV4cCI6MTczNjM1Nzg2M30.04IcPRiC1x1PDj_IdYfqosAA5JRkvw1VGezWRJJYqVE"
// const filePath = "C:/Users/admin/Desktop/data/模型/fbx/整体/整体.fbx"
/** 待上传文件路径 */
const filePath = "C:/Users/admin/Desktop/data/模型/fbx/整体/整体.fbx"
/** 上传会话数据 */
type UploadSession = {
uploadId: string
chunkSize: number
totalChunks: number
}
/** 创建上传响应数据 */
type UploadCreateData = {
uploadId: string
needUpload: boolean
session: UploadSession
}
/** 接口响应结构 */
type ApiResponse<T> = {
success?: boolean
msg?: string
code?: string
data: T
}
/** 构建签名地址 */
function buildSignedUrl(resourcePath: string) {
const timestamp = Date.now().toString()
const sign = createHash("md5")
.update(`${key}${secret}${timestamp}`)
.digest("hex")
return `${baseUrl}${resourcePath}?key=${key}×tamp=${timestamp}&sign=${sign}`
}
/** 构建认证请求头 */
function buildAuthHeaders(headers: Record<string, string> = {}) {
return {
Authorization: `Bearer ${token}`,
...headers,
}
}
/** 解析接口响应 */
async function parseJsonResponse<T>(response: Response) {
const contentType = response.headers.get("content-type") || ""
const responseText = await response.text()
if (!contentType.includes("application/json")) {
throw new Error(
`接口未返回 JSON,status=${response.status},content-type=${contentType || "unknown"},body=${responseText.slice(0, 200)}`
)
}
const result = JSON.parse(responseText) as ApiResponse<T>
if (!response.ok || result.success === false) {
throw new Error(
`接口请求失败,status=${response.status},code=${result.code || "unknown"},msg=${result.msg || "unknown"}`
)
}
return result
}
/**
* 上传文件到服务端
* @param filePath 文件路径
* @param fileName 文件名称
* @param targetPath 目标目录
*/
async function uploadFile(filePath: string, fileName: string, targetPath?: string) {
const fileBuffer = await readFile(filePath)
const fileHash = createHash("sha256").update(fileBuffer).digest("hex")
// 创建上传会话
const createResponse = await fetch(buildSignedUrl("/upload/create"), {
method: "POST",
headers: buildAuthHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify({
fileName,
fileSize: fileBuffer.length,
targetPath,
fileHash,
}),
})
const createResult = await parseJsonResponse<UploadCreateData>(createResponse)
const session = createResult.data.session
if (!createResult.data.needUpload) {
return createResult.data
}
// 逐片上传文件内容
for (let chunkIndex = 0; chunkIndex < session.totalChunks; chunkIndex++) {
const start = chunkIndex * session.chunkSize
const end = Math.min(start + session.chunkSize, fileBuffer.length)
const chunk = fileBuffer.subarray(start, end)
const formData = new FormData()
formData.append("chunkIndex", String(chunkIndex))
formData.append("chunk", new Blob([chunk]), `${fileName}.part`)
const chunkResponse = await fetch(buildSignedUrl(`/upload/${session.uploadId}/chunk`), {
method: "POST",
headers: buildAuthHeaders(),
body: formData,
})
await parseJsonResponse<UploadSession>(chunkResponse)
}
// 通知服务端合并分片
const completeResponse = await fetch(
buildSignedUrl(`/upload/${session.uploadId}/complete`),
{
method: "POST",
headers: buildAuthHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify({
expectedHash: fileHash,
}),
}
)
return await parseJsonResponse<UploadSession>(completeResponse)
}
// 文件分片上传
uploadFile(filePath, path.basename(filePath))以下示例基于 “JDK 11+”、“OkHttp” 和 “Jackson”。
<dependencies>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
</dependencies>import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
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.security.MessageDigest;
import java.time.Duration;
import java.util.List;
public class UploadApiJavaExample {
/** JSON 媒体类型 */
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
/** 接口基础地址 */
private static final String BASE_URL = "http://127.0.0.1:3000/api/v1";
/** 接口访问 key */
private static final String KEY = "YOUR_KEY";
/** 接口签名密钥 */
private static final String SECRET = "YOUR_SECRET";
/** 接口访问令牌 */
private static final String TOKEN = "YOUR_TOKEN";
/** JSON 解析器 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** HTTP 客户端 */
private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
.callTimeout(Duration.ofMinutes(10))
.build();
public static void main(String[] args) throws Exception {
/** 待上传文件路径 */
Path filePath = Paths.get("C:/data/demo.zip");
/** 目标目录 */
String targetPath = "samples";
UploadTaskDetail result = uploadFile(filePath, targetPath);
System.out.println("上传完成,uploadId=" + result.uploadId + ",phase=" + result.phase);
}
/**
* 上传文件到服务端
* @param filePath 文件路径
* @param targetPath 目标目录
* @return 上传完成后的任务快照
*/
public static UploadTaskDetail uploadFile(Path filePath, String targetPath) throws Exception {
/** 文件名称 */
String fileName = filePath.getFileName().toString();
/** 文件总大小 */
long fileSize = Files.size(filePath);
/** 文件摘要 */
String fileHash = sha256(filePath);
// 先创建上传会话
ApiResponse<UploadCreateResult> createResponse =
createUploadSession(fileName, fileSize, targetPath, fileHash);
if (createResponse.data == null || createResponse.data.session == null) {
throw new IOException("创建上传会话失败,返回数据为空");
}
if (!Boolean.TRUE.equals(createResponse.data.needUpload)) {
System.out.println("服务端判定无需上传分片");
UploadTaskDetail skippedResult = new UploadTaskDetail();
skippedResult.uploadId = createResponse.data.uploadId;
skippedResult.phase = createResponse.data.session.phase;
return skippedResult;
}
/** 服务端返回的分片大小 */
long chunkSize = createResponse.data.session.chunkSize;
/** 上传会话 ID */
String uploadId = createResponse.data.session.uploadId;
/** 分片总数 */
int totalChunks = (int) ((fileSize + chunkSize - 1) / chunkSize);
// 按顺序读取并上传每个分片
try (InputStream inputStream = Files.newInputStream(filePath)) {
for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
int currentChunkSize = (int) Math.min(chunkSize, fileSize - (long) chunkIndex * chunkSize);
byte[] chunkBytes = inputStream.readNBytes(currentChunkSize);
if (chunkBytes.length != currentChunkSize) {
throw new IOException("读取文件分片失败,chunkIndex=" + chunkIndex);
}
uploadChunk(uploadId, fileName, chunkIndex, chunkBytes);
}
}
// 所有分片上传完成后显式通知服务端合并
return completeUpload(uploadId, fileHash);
}
/** 创建上传会话 */
private static ApiResponse<UploadCreateResult> createUploadSession(
String fileName,
long fileSize,
String targetPath,
String fileHash
) throws Exception {
CreateUploadRequest requestBody = new CreateUploadRequest();
requestBody.fileName = fileName;
requestBody.fileSize = fileSize;
requestBody.targetPath = targetPath;
requestBody.fileHash = fileHash;
requestBody.overwrite = false;
Request request = new Request.Builder()
.url(buildSignedUrl("/upload/create"))
.header("Authorization", "Bearer " + TOKEN)
.post(RequestBody.create(OBJECT_MAPPER.writeValueAsBytes(requestBody), JSON))
.build();
return executeJson(request, new TypeReference<ApiResponse<UploadCreateResult>>() {});
}
/** 上传单个分片 */
private static void uploadChunk(
String uploadId,
String fileName,
int chunkIndex,
byte[] chunkBytes
) throws Exception {
MultipartBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("chunkIndex", String.valueOf(chunkIndex))
.addFormDataPart(
"chunk",
fileName + ".part",
RequestBody.create(chunkBytes, MediaType.get("application/octet-stream"))
)
.build();
Request request = new Request.Builder()
.url(buildSignedUrl("/upload/" + uploadId + "/chunk"))
.header("Authorization", "Bearer " + TOKEN)
.post(requestBody)
.build();
executeJson(request, new TypeReference<ApiResponse<UploadTaskDetail>>() {});
}
/** 完成上传并触发合并 */
private static UploadTaskDetail completeUpload(String uploadId, String expectedHash) throws Exception {
CompleteUploadRequest requestBody = new CompleteUploadRequest();
requestBody.expectedHash = expectedHash;
Request request = new Request.Builder()
.url(buildSignedUrl("/upload/" + uploadId + "/complete"))
.header("Authorization", "Bearer " + TOKEN)
.post(RequestBody.create(OBJECT_MAPPER.writeValueAsBytes(requestBody), JSON))
.build();
ApiResponse<UploadTaskDetail> response =
executeJson(request, new TypeReference<ApiResponse<UploadTaskDetail>>() {});
if (response.data == null) {
throw new IOException("完成上传失败,返回数据为空");
}
return response.data;
}
/** 执行请求并解析 JSON 响应 */
private static <T> ApiResponse<T> executeJson(
Request request,
TypeReference<ApiResponse<T>> typeReference
) throws Exception {
try (Response response = HTTP_CLIENT.newCall(request).execute()) {
String contentType = response.header("Content-Type", "");
String responseText = response.body() == null ? "" : response.body().string();
if (!contentType.contains("application/json")) {
String preview = responseText.length() > 200
? responseText.substring(0, 200)
: responseText;
throw new IOException(
"接口未返回 JSON,status=" + response.code()
+ ",content-type=" + (contentType.isEmpty() ? "unknown" : contentType)
+ ",body=" + preview
);
}
ApiResponse<T> result = OBJECT_MAPPER.readValue(responseText, typeReference);
if (!response.isSuccessful() || Boolean.FALSE.equals(result.success)) {
throw new IOException(
"接口请求失败,status=" + response.code()
+ ",code=" + (result.code == null ? "unknown" : result.code)
+ ",msg=" + (result.msg == null ? "unknown" : result.msg)
);
}
return result;
}
}
/** 构建带签名的请求地址 */
private static HttpUrl buildSignedUrl(String resourcePath) throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis());
String sign = md5(KEY + SECRET + timestamp);
return HttpUrl.get(BASE_URL + resourcePath)
.newBuilder()
.addQueryParameter("key", KEY)
.addQueryParameter("timestamp", timestamp)
.addQueryParameter("sign", sign)
.build();
}
/** 计算文件 SHA256 */
private static String sha256(Path filePath) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = Files.newInputStream(filePath)) {
byte[] buffer = new byte[8192];
int readSize;
while ((readSize = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, readSize);
}
}
return toHex(digest.digest());
}
/** 计算字符串 MD5 */
private static String md5(String value) throws Exception {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
return toHex(bytes);
}
/** 转十六进制字符串 */
private static String toHex(byte[] bytes) {
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte currentByte : bytes) {
builder.append(String.format("%02x", currentByte));
}
return builder.toString();
}
/** 创建上传请求体 */
public static class CreateUploadRequest {
public String fileName;
public long fileSize;
public String targetPath;
public String fileHash;
public boolean overwrite;
}
/** 完成上传请求体 */
public static class CompleteUploadRequest {
public String expectedHash;
}
/** 接口响应结构 */
public static class ApiResponse<T> {
public Boolean success;
public String msg;
public String code;
public T data;
}
/** 创建上传响应数据 */
public static class UploadCreateResult {
public String uploadId;
public Boolean needUpload;
public UploadSession session;
}
/** 上传会话数据 */
public static class UploadSession {
public String uploadId;
public long chunkSize;
public List<Integer> uploadedChunks;
public String phase;
}
/** 上传任务详情 */
public static class UploadTaskDetail {
public String uploadId;
public String status;
public String phase;
public Integer progress;
public Long uploadedSize;
public Long totalSize;
public Integer totalChunks;
public List<Integer> uploadedChunks;
}
}