GISBox

API环境如何上传文件?

1. 响应格式

成功响应

{
  "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

2. 数据模型

2.1 UploadTaskListItem

用于任务列表展示

{
  "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
}

2.2 UploadTaskDetail

用于创建接口和详情接口

{
  "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"
}

2.3 UploadCreateResult

{
  "uploadId": "upload-id",
  "needUpload": true,
  "session": {
    "uploadId": "upload-id",
    "chunkSize": 8388608,
    "uploadedChunks": [],
    "phase": "uploading"
  }
}

2.4 phase枚举

“preparing”

“hashing”

“uploading”

“merging”

“failed”

3. 接口说明

3.1 创建上传会话

方法:“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”

3.2 上传单个分片

方法:“POST /api/v1/upload/:id/chunk”

“Content-Type”“multipart/form-data”


表单字段

“chunk”:二进制分片内容

“chunkIndex”:从 “0” 开始

“chunkHash”:可选,支持 “md5” “sha256”


返回值

返回当前上传会话快照

已存在分片会直接返回最新快照,不会重复计数

3.3 显示完成上传

方法:“POST /api/v1/upload/:id/complete”

“Content-Type”“application/json”


请求体

{
  "expectedHash": "optional-sha256-or-md5"
}


说明

1)对外 API 必须显式调用本接口;

2)如果仍缺少分片,会返回 “UPLOAD_MISSING_CHUNKS”

3)合并完成后目标文件先落到 “目标文件.uploading”,成功后再原子切换到正式文件。

3.4 查询上传详情

方法:“GET /api/v1/upload/:id/info”


返回值

返回 “UploadTaskDetail”

可用于断点恢复时获取 “uploadedChunks”

3.5 取消上传任务

方法:“POST /api/v1/upload/:id/cancel”


返回值

true


说明

1)会删除该任务的分片临时目录;

2)会删除未完成的 “.uploading” 文件;

3)已经完成的正式文件不会被删除。

4. 典型上传流程

1)调用 “create”

2) 读取 “data.session.chunkSize”

3)按 “chunkSize”切分文件;

4) 循环调用 “chunk”

5)调用 “complete”

6)需要恢复时调用 “info”,根据 “uploadedChunks”继续上传。

4.1 curl上传示例

4.1.1 创建会话

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
  }'

4.1.2 上传第0个分片

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"

4.1.3 完成上传

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"
  }'

4.1.4 查询详情

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"

4.1.5 取消任务

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"

4.2 NodeJS上传示例

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))

4.3 Java上传示例

以下示例基于 “JDK 11+”“OkHttp” “Jackson”

4.3.1 Maven依赖

<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>

4.3.2 Java示例代码

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;
  }
}