Java 实现阿里云 OSS 文件上传链路:普通上传、秒传、分片与断点续传

Java 实现阿里云 OSS 文件上传链路:普通上传、秒传、分片与断点续传

文章目录

  • [Java 实现阿里云 OSS 文件上传链路:普通上传、秒传、分片与断点续传](#Java 实现阿里云 OSS 文件上传链路:普通上传、秒传、分片与断点续传)
    • [1. 先确定闭环:上传成功不等于对象写入成功](#1. 先确定闭环:上传成功不等于对象写入成功)
      • [1.1 本文解决的问题](#1.1 本文解决的问题)
      • [1.2 范围与非目标](#1.2 范围与非目标)
      • [1.3 证据边界](#1.3 证据边界)
    • [2. 架构与状态:四类数据必须分开](#2. 架构与状态:四类数据必须分开)
      • [2.1 文件资产状态](#2.1 文件资产状态)
      • [2.2 数据库表(MySQL)](#2.2 数据库表(MySQL))
    • [3. 核心配置与 OSS 客户端](#3. 核心配置与 OSS 客户端)
      • [3.1 Maven 依赖](#3.1 Maven 依赖)
      • [3.2 `application-local.yml`](#3.2 application-local.yml)
      • [3.3 配置绑定和客户端 Bean](#3.3 配置绑定和客户端 Bean)
    • [4. OSS 适配层:普通对象、分片和预览](#4. OSS 适配层:普通对象、分片和预览)
      • [4.1 代码契约](#4.1 代码契约)
      • [4.2 按有效期生成预览链接(可直接复制)](#4.2 按有效期生成预览链接(可直接复制))
      • [4.3 生成无有效期的公开链接(可直接复制)](#4.3 生成无有效期的公开链接(可直接复制))
    • [5. 秒传:摘要命中后创建引用,不复用旧签名 URL](#5. 秒传:摘要命中后创建引用,不复用旧签名 URL)
      • [5.1 摘要和对象键](#5.1 摘要和对象键)
      • [5.2 秒传服务核心逻辑](#5.2 秒传服务核心逻辑)
    • [6. 普通上传 API:小文件的一次性闭环](#6. 普通上传 API:小文件的一次性闭环)
    • [7. 分片上传与断点续传](#7. 分片上传与断点续传)
      • [7.1 初始化](#7.1 初始化)
      • [7.2 上传分片](#7.2 上传分片)
      • [7.3 查询已上传分片](#7.3 查询已上传分片)
      • [7.4 完成合并与二次校验](#7.4 完成合并与二次校验)
      • [7.5 取消与超时清理](#7.5 取消与超时清理)
    • [8. 预览链接与安全边界](#8. 预览链接与安全边界)
      • [8.1 只保存稳定标识](#8.1 只保存稳定标识)
      • [8.2 防盗链不能按目录假设](#8.2 防盗链不能按目录假设)
    • [9. 接口契约和错误码](#9. 接口契约和错误码)
    • [10. 测试与验证边界](#10. 测试与验证边界)
      • [10.1 必测用例](#10.1 必测用例)
      • [10.2 验证分层](#10.2 验证分层)
    • [11. 取舍、失败路径与上线前检查](#11. 取舍、失败路径与上线前检查)
      • [11.1 方案取舍](#11.1 方案取舍)
      • [11.2 失败路径](#11.2 失败路径)
      • [11.3 上线前检查清单](#11.3 上线前检查清单)
    • [12. 结论](#12. 结论)

内容摘要:本文给出一套基于 Java 17、Spring Boot 3 和阿里云 OSS Java SDK 3.17.4 的文件上传后端实现。重点不是把 OSS API 逐个罗列,而是建立"文件摘要---资产元数据---分片会话---业务引用"的一致性契约:小文件走普通上传,大文件走可恢复的 Multipart Upload,摘要命中时只创建引用不重复传输,预览通过短期签名 URL 完成。示例代码使用 xxx 脱敏配置,真实 OSS、数据库和权限环境仍需单独验收。

1. 先确定闭环:上传成功不等于对象写入成功

1.1 本文解决的问题

一个可维护的 OSS 文件系统至少要同时回答四个问题:

  1. 文件是否已经存在,能否安全复用(秒传)?
  2. 网络中断后,如何知道哪些分片已经成功(断点续传)?
  3. OSS 对象完成后,数据库何时才允许把文件标记为可用?
  4. 私有对象如何生成可过期的预览链接,而不是把临时 URL 当永久数据保存?

本文的主结论是:可靠性来自状态和幂等契约,而不是来自某一个上传方法。 OSS 是对象内容的权威,数据库是资产元数据和业务引用的权威;只有在 OSS 完成、大小校验通过、摘要校验通过后,file_asset.status 才能进入 AVAILABLE

1.2 范围与非目标

范围:普通上传、MD5 秒传、OSS Multipart Upload、断点恢复、取消/清理、私有对象预览签名 URL、Spring MVC API、MySQL 表结构和测试边界。

非目标:前端完整 UI、病毒扫描、内容审核、CDN 配置、视频转码。前端只需要遵循本文接口契约即可接入。

1.3 证据边界

结论 依据 当前边界
SDK 调用形态 仓库 ai-image-backend/pom.xml 锁定 aliyun-sdk-oss:3.17.4,现有 OssClient 已使用初始化、分片、完成和预签名 API 示例未在真实 OSS 上运行
对象私有化 + 签名 URL 阿里云 OSS API 模型和项目既有预签名调用 Referer 规则、RAM Policy 需在目标 Bucket 验收
秒传条件 本文设计契约 需要按真实租户权限模型补充集成测试

2. 架构与状态:四类数据必须分开

#mermaid-svg-lJVhzaIDE9EiCxm5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lJVhzaIDE9EiCxm5 .error-icon{fill:#552222;}#mermaid-svg-lJVhzaIDE9EiCxm5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lJVhzaIDE9EiCxm5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .marker.cross{stroke:#333333;}#mermaid-svg-lJVhzaIDE9EiCxm5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lJVhzaIDE9EiCxm5 p{margin:0;}#mermaid-svg-lJVhzaIDE9EiCxm5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .cluster-label text{fill:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .cluster-label span{color:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .cluster-label span p{background-color:transparent;}#mermaid-svg-lJVhzaIDE9EiCxm5 .label text,#mermaid-svg-lJVhzaIDE9EiCxm5 span{fill:#333;color:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .node rect,#mermaid-svg-lJVhzaIDE9EiCxm5 .node circle,#mermaid-svg-lJVhzaIDE9EiCxm5 .node ellipse,#mermaid-svg-lJVhzaIDE9EiCxm5 .node polygon,#mermaid-svg-lJVhzaIDE9EiCxm5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .rough-node .label text,#mermaid-svg-lJVhzaIDE9EiCxm5 .node .label text,#mermaid-svg-lJVhzaIDE9EiCxm5 .image-shape .label,#mermaid-svg-lJVhzaIDE9EiCxm5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lJVhzaIDE9EiCxm5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .rough-node .label,#mermaid-svg-lJVhzaIDE9EiCxm5 .node .label,#mermaid-svg-lJVhzaIDE9EiCxm5 .image-shape .label,#mermaid-svg-lJVhzaIDE9EiCxm5 .icon-shape .label{text-align:center;}#mermaid-svg-lJVhzaIDE9EiCxm5 .node.clickable{cursor:pointer;}#mermaid-svg-lJVhzaIDE9EiCxm5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .arrowheadPath{fill:#333333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lJVhzaIDE9EiCxm5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lJVhzaIDE9EiCxm5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lJVhzaIDE9EiCxm5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lJVhzaIDE9EiCxm5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .cluster text{fill:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 .cluster span{color:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lJVhzaIDE9EiCxm5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lJVhzaIDE9EiCxm5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lJVhzaIDE9EiCxm5 .icon-shape,#mermaid-svg-lJVhzaIDE9EiCxm5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lJVhzaIDE9EiCxm5 .icon-shape p,#mermaid-svg-lJVhzaIDE9EiCxm5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lJVhzaIDE9EiCxm5 .icon-shape .label rect,#mermaid-svg-lJVhzaIDE9EiCxm5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lJVhzaIDE9EiCxm5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lJVhzaIDE9EiCxm5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lJVhzaIDE9EiCxm5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 鉴权 API
普通对象/分片
短期签名 GET
AbortMultipartUpload
浏览器/客户端
Spring Boot API
MySQL
OSS Bucket 私有对象
预览 URL 服务
定时清理任务

读图要点:普通上传和分片上传都经过同一个资产服务收敛状态;预览只根据 fileId 动态签名,不把签名 URL 写回资产表;清理任务只处理超时的上传会话。

2.1 文件资产状态

text 复制代码
INIT → UPLOADING → VERIFYING → AVAILABLE
  │        │             │
  └──────→ FAILED ←──────┘
UPLOADING → CANCELLED
  • INIT:已完成参数预检,尚未产生可用对象。
  • UPLOADING:普通对象写入中,或 Multipart 会话存在。
  • VERIFYING:OSS 已完成,服务端正在校验大小、摘要和权限元数据。
  • AVAILABLE:对象存在且可以建立业务引用。
  • FAILED/CANCELLED:不可继续使用;Multipart 会话必须尝试中止。

状态不变量:只有 AVAILABLE 可以被秒传命中或生成预览 URL;重复完成请求在 AVAILABLE 状态下返回同一个 fileId,不得再次合并或创建重复资产。

2.2 数据库表(MySQL)

sql 复制代码
CREATE TABLE file_asset (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    digest CHAR(32) NOT NULL,
    size_bytes BIGINT NOT NULL,
    original_name VARCHAR(255) NOT NULL,
    mime_type VARCHAR(128) NOT NULL,
    object_key VARCHAR(512) NOT NULL,
    bucket_name VARCHAR(128) NOT NULL,
    status VARCHAR(24) NOT NULL,
    visibility VARCHAR(16) NOT NULL DEFAULT 'PRIVATE',
    created_by BIGINT NOT NULL,
    created_at DATETIME(3) NOT NULL,
    updated_at DATETIME(3) NOT NULL,
    UNIQUE KEY uk_asset_digest_size (digest, size_bytes),
    UNIQUE KEY uk_asset_object_key (bucket_name, object_key),
    KEY idx_asset_status (status)
);

CREATE TABLE file_reference (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    file_id BIGINT NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(64) NOT NULL,
    created_by BIGINT NOT NULL,
    created_at DATETIME(3) NOT NULL,
    UNIQUE KEY uk_reference (file_id, biz_type, biz_id),
    CONSTRAINT fk_reference_asset FOREIGN KEY (file_id) REFERENCES file_asset(id)
);

CREATE TABLE multipart_upload_session (
    id VARCHAR(64) PRIMARY KEY,
    upload_id VARCHAR(256) NOT NULL,
    file_id BIGINT NULL,
    object_key VARCHAR(512) NOT NULL,
    digest CHAR(32) NOT NULL,
    size_bytes BIGINT NOT NULL,
    original_name VARCHAR(255) NOT NULL,
    mime_type VARCHAR(128) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(64) NOT NULL,
    part_size_bytes INT NOT NULL,
    status VARCHAR(24) NOT NULL,
    expires_at DATETIME(3) NOT NULL,
    created_by BIGINT NOT NULL,
    created_at DATETIME(3) NOT NULL,
    updated_at DATETIME(3) NOT NULL,
    UNIQUE KEY uk_multipart_upload_id (upload_id)
);

CREATE TABLE multipart_upload_part (
    session_id VARCHAR(64) NOT NULL,
    part_number INT NOT NULL,
    etag VARCHAR(128) NOT NULL,
    size_bytes BIGINT NOT NULL,
    updated_at DATETIME(3) NOT NULL,
    PRIMARY KEY (session_id, part_number),
    CONSTRAINT fk_part_session FOREIGN KEY (session_id) REFERENCES multipart_upload_session(id)
);

UNIQUE(digest, size_bytes) 只保证全局资产去重;如果业务要求组织隔离,应把 org_id 加入唯一键和所有查询条件。不能先假定全局秒传符合权限模型。

3. 核心配置与 OSS 客户端

3.1 Maven 依赖

xml 复制代码
<dependency>
  <groupId>com.aliyun.oss</groupId>
  <artifactId>aliyun-sdk-oss</artifactId>
  <version>3.17.4</version>
</dependency>

3.2 application-local.yml

yaml 复制代码
app:
  oss:
    endpoint: https://xxx
    region: xxx
    bucket: xxx
    preview-domain: https://xxx
    access-key-id: xxx
    access-key-secret: xxx
    folder: files
    preview-expire-seconds: 300
    multipart:
      threshold-bytes: 104857600       # 100 MiB,达到后使用分片
      part-size-bytes: 10485760         # 10 MiB
      max-parts: 10000
      session-expire-minutes: 1440

示例保留文件配置,敏感值使用 xxx。生产环境应使用 RAM 最小权限和密钥托管,但不要把真实密钥写入仓库、日志或文档。

3.3 配置绑定和客户端 Bean

java 复制代码
package com.example.file.config;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import jakarta.annotation.PreDestroy;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/** OSS 连接和上传策略配置。 */
@Data
@Configuration
@ConfigurationProperties(prefix = "app.oss")
public class OssProperties {
  private String endpoint;
  private String region;
  private String bucket;
  private String previewDomain;
  private String accessKeyId;
  private String accessKeySecret;
  private String folder = "files";
  private long previewExpireSeconds = 300;
  private Multipart multipart = new Multipart();

  @Data
  public static class Multipart {
    private long thresholdBytes = 100L * 1024 * 1024;
    private int partSizeBytes = 10 * 1024 * 1024;
    private int maxParts = 10_000;
    private long sessionExpireMinutes = 24 * 60;
  }
}

@Configuration
class OssClientConfiguration {
  private final OssProperties properties;
  private OSS client;

  OssClientConfiguration(OssProperties properties) {
    this.properties = properties;
  }

  @Bean
  OSS ossClient() {
    client = new OSSClientBuilder().build(
        properties.getEndpoint(),
        properties.getAccessKeyId(),
        properties.getAccessKeySecret());
    return client;
  }

  @PreDestroy
  void shutdown() {
    if (client != null) {
      client.shutdown();
    }
  }
}

输入是配置文件,输出是线程安全的 OSS 客户端 Bean。@PreDestroy 不能省略,否则连接池可能在应用重启时泄漏。示例未展示真实超时和代理参数;高并发生产环境应按 SDK 版本补充连接池、连接超时和重试配置。

4. OSS 适配层:普通对象、分片和预览

适配层只负责 OSS 协议,不负责数据库状态和业务权限。这样做的原因是:OSS 异常可以统一映射,业务服务可以在"对象已完成但数据库失败"时执行补偿,而不是把事务逻辑埋进 SDK 调用。

java 复制代码
package com.example.file.oss;

import com.example.file.config.OssProperties;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS;
import com.aliyun.oss.model.AbortMultipartUploadRequest;
import com.aliyun.oss.model.CompleteMultipartUploadRequest;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.InitiateMultipartUploadRequest;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PartETag;
import com.aliyun.oss.model.UploadPartRequest;
import com.aliyun.oss.model.UploadPartResult;
import java.io.InputStream;
import java.net.URL;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.springframework.stereotype.Component;

/** 对阿里云 OSS SDK 的最小封装,屏蔽 Bucket 和 SDK 请求对象。 */
@Component
public class OssGateway {
  private final OSS client;
  private final OssProperties properties;

  public OssGateway(OSS client, OssProperties properties) {
    this.client = client;
    this.properties = properties;
  }

  /** 普通上传。调用方必须提供长度,避免 SDK 退化为不可控的流式行为。 */
  public void putObject(String objectKey, InputStream input, long contentLength, String contentType) {
    ObjectMetadata metadata = new ObjectMetadata();
    metadata.setContentLength(contentLength);
    metadata.setContentType(contentType);
    client.putObject(properties.getBucket(), objectKey, input, metadata);
  }

  /** 初始化 Multipart Upload,返回 OSS uploadId。 */
  public String initiateMultipart(String objectKey, String contentType) {
    InitiateMultipartUploadRequest request =
        new InitiateMultipartUploadRequest(properties.getBucket(), objectKey);
    ObjectMetadata metadata = new ObjectMetadata();
    metadata.setContentType(contentType);
    request.setObjectMetadata(metadata);
    return client.initiateMultipartUpload(request).getUploadId();
  }

  /** 上传单个分片,ETag 是完成合并时的权威凭证。 */
  public PartETag uploadPart(
      String objectKey, String uploadId, int partNumber, InputStream input, long partSize) {
    UploadPartRequest request = new UploadPartRequest();
    request.setBucketName(properties.getBucket());
    request.setKey(objectKey);
    request.setUploadId(uploadId);
    request.setPartNumber(partNumber);
    request.setPartSize(partSize);
    request.setInputStream(input);
    UploadPartResult result = client.uploadPart(request);
    return result.getPartETag();
  }

  /** 按 partNumber 升序完成合并。parts 必须来自服务端持久化的 ETag。 */
  public void completeMultipart(String objectKey, String uploadId, List<PartETag> parts) {
    // OSS SDK 3.17.4 可能在完成前排序该列表,不能传入 List.of()/Stream.toList() 的不可变结果。
    List<PartETag> mutableParts = new ArrayList<>(parts);
    client.completeMultipartUpload(
        new CompleteMultipartUploadRequest(
            properties.getBucket(), objectKey, uploadId, mutableParts));
  }

  /** 取消未完成会话,释放 OSS 临时分片。 */
  public void abortMultipart(String objectKey, String uploadId) {
    client.abortMultipartUpload(
        new AbortMultipartUploadRequest(properties.getBucket(), objectKey, uploadId));
  }

  public boolean exists(String objectKey) {
    return client.doesObjectExist(properties.getBucket(), objectKey);
  }

  public long objectSize(String objectKey) {
    return client.getObjectMetadata(properties.getBucket(), objectKey).getContentLength();
  }

  /** 为私有对象生成短期 GET URL;URL 不写入 file_asset。 */
  public String presignGet(String objectKey, Duration validity) {
    if (validity.isNegative() || validity.isZero()) {
      throw new IllegalArgumentException("签名有效期必须大于 0");
    }
    Date expiration = new Date(System.currentTimeMillis() + validity.toMillis());
    GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
        properties.getBucket(), objectKey, HttpMethod.GET);
    request.setExpiration(expiration);
    URL url = client.generatePresignedUrl(request);
    return url.toExternalForm();
  }

  /**
   * 生成无有效期的公开访问地址。
   *
   * <p>该方法只拼接配置好的 HTTPS 域名和对象键,不生成签名参数,也不会改变对象 ACL。
   * 只有 Bucket/Object 已明确允许匿名读取时才能调用。
   */
  public String publicUrl(String objectKey) {
    if (objectKey == null || objectKey.isBlank()) {
      throw new IllegalArgumentException("对象键不能为空");
    }
    if (properties.getPreviewDomain() == null || properties.getPreviewDomain().isBlank()) {
      throw new IllegalStateException("未配置公开访问域名");
    }
    String folder = properties.getFolder().replaceAll("^/+|/+$", "");
    if (!objectKey.startsWith(folder + "/")) {
      throw new IllegalArgumentException("对象键不在受管目录下");
    }
    java.net.URI domain = java.net.URI.create(properties.getPreviewDomain().trim());
    if (!"https".equalsIgnoreCase(domain.getScheme()) || domain.getHost() == null) {
      throw new IllegalArgumentException("公开访问域名必须是 HTTPS URL");
    }
    return properties.getPreviewDomain().trim().replaceAll("/+$", "") + "/" + objectKey;
  }
}

4.1 代码契约

项目 说明
输入 objectKey、流、长度、MIME、uploadId、partNumber、ETag
输出 OSS uploadId、PartETag、预览 URL或无返回值
不变量 Bucket 固定来自服务端配置;完成列表按编号排序;对象键不接受用户路径
失败 SDK 异常向上抛出,由业务层映射为 OSS_ERROR;取消失败必须记录告警并重试
证据 仓库现有 OssClient 已使用同组 SDK 方法;本段是独立示例实现

4.2 按有效期生成预览链接(可直接复制)

上面的 presignGet 就是签名链接生成方法:validity 是从当前服务器时间开始计算的相对有效期,SDK 最终把它转换为 URL 中的 ExpiresOSSAccessKeyIdSignature 参数。调用方不需要、也不应该自己拼接签名参数。

java 复制代码
/** 返回带过期时间的预览结果,便于前端提前刷新。 */
public PreviewUrlResult generatePreviewUrl(long fileId, long operatorId) {
  FileAsset asset = assetRepository.findAvailableById(fileId)
      .orElseThrow(() -> new FileUploadException("FILE_NOT_FOUND", "文件不存在"));
  permissionService.assertReadable(asset, operatorId);

  Duration validity = Duration.ofSeconds(properties.getPreviewExpireSeconds());
  Instant expiresAt = Instant.now().plus(validity);
  String url = ossGateway.presignGet(asset.objectKey(), validity);
  return new PreviewUrlResult(fileId, url, expiresAt);
}

public record PreviewUrlResult(long fileId, String url, Instant expiresAt) {}

如果业务需要不同有效期,可以把秒数作为受限参数传入,但必须在服务端设置上下限,不能允许客户端传入超长时间:

java 复制代码
public String generatePreviewUrl(String objectKey, long requestedSeconds) {
  long minSeconds = 30;
  long maxSeconds = 3600;
  long seconds = Math.max(minSeconds, Math.min(requestedSeconds, maxSeconds));
  return ossGateway.presignGet(objectKey, Duration.ofSeconds(seconds));
}

这段方法的输入是已通过权限校验的 fileId 和有效期配置,输出是临时 URL 及其绝对过期时间。服务器时钟明显漂移会导致"刚生成就过期"或提前失效,部署时应启用时间同步。签名 URL 泄露后,在过期前仍然有效,因此有效期不是主动撤销机制。

4.3 生成无有效期的公开链接(可直接复制)

无有效期链接不是"永不过期的签名链接",而是公开读对象的普通 URL。它不包含 ExpiresOSSAccessKeyIdSignature 参数,能否访问完全取决于 Bucket/Object ACL、Bucket Policy 和域名配置。

java 复制代码
/** 公开对象链接:不签名、不设置过期时间。 */
public String generatePublicUrl(long fileId, long operatorId) {
  FileAsset asset = assetRepository.findAvailableById(fileId)
      .orElseThrow(() -> new FileUploadException("FILE_NOT_FOUND", "文件不存在"));
  permissionService.assertReadable(asset, operatorId);
  if (!Visibility.PUBLIC.name().equals(asset.visibility())) {
    throw new FileUploadException("FILE_NOT_PUBLIC", "该文件不是公开对象");
  }
  return ossGateway.publicUrl(asset.objectKey());
}

Controller 可以把两种链接分别暴露,避免客户端误把公开 URL 当成私有签名 URL:

java 复制代码
@GetMapping("/{fileId}/public-url")
public PublicUrlResponse publicUrl(
    @PathVariable long fileId, Authentication authentication) {
  String url = filePresentationService.generatePublicUrl(
      fileId, currentUserId(authentication));
  return new PublicUrlResponse(fileId, url);
}

public record PublicUrlResponse(long fileId, String url) {}

两种方法必须按对象可见性选择:

方法 URL 参数 前提 适用场景
presignGet(objectKey, validity) 包含过期签名参数 对象私有 用户文件、临时预览、下载
publicUrl(objectKey) 无签名、无过期时间 对象允许匿名读 公开图片、公开静态资源

公开 URL 没有主动撤销能力。若对象后来改为私有,旧链接才会失效;在公开期间被搜索引擎、缓存或第三方保存的副本不由 OSS 链接本身控制。因此默认文件资产应使用 PRIVATE,只有明确的公开业务才允许调用 publicUrl

5. 秒传:摘要命中后创建引用,不复用旧签名 URL

5.1 摘要和对象键

客户端可以先计算 MD5,但服务端必须把客户端摘要当作候选值,而不是事实。普通上传完成后重新读取对象或使用受信校验结果确认大小;如果安全要求更高,使用 SHA-256 或上传后流式计算摘要。

java 复制代码
package com.example.file.service;

import com.example.file.config.OssProperties;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.UUID;
import org.springframework.stereotype.Component;

/** 生成不可由用户控制目录的 OSS 对象键。 */
@Component
public class ObjectKeyFactory {
  private final OssProperties properties;

  public ObjectKeyFactory(OssProperties properties) {
    this.properties = properties;
  }

  public String create(String digest, String originalName) {
    String extension = extensionOf(originalName);
    String shard = digest.substring(0, 2);
    return "%s/%s/%s/%s%s".formatted(
        trimSlash(properties.getFolder()), shard, digest, UUID.randomUUID(), extension);
  }

  private String extensionOf(String name) {
    int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
    String base = slash >= 0 ? name.substring(slash + 1) : name;
    int dot = base.lastIndexOf('.');
    if (dot <= 0 || dot == base.length() - 1) {
      return "";
    }
    String extension = base.substring(dot).toLowerCase();
    return extension.matches("\\.[a-z0-9]{1,10}") ? extension : "";
  }

  private String trimSlash(String value) {
    return value == null ? "files" : value.replaceAll("^/+|/+$", "");
  }
}

5.2 秒传服务核心逻辑

以下 FileAssetRepositoryFileReferenceRepository 是项目适配接口,可用 MyBatis、JPA 或 MyBatis-Plus 实现;文档不虚构具体 ORM 映射。

java 复制代码
@Service
public class FileAssetService {
  private final FileAssetRepository assetRepository;
  private final FileReferenceRepository referenceRepository;
  private final OssGateway ossGateway;
  private final ObjectKeyFactory keyFactory;

  public FileAssetService(
      FileAssetRepository assetRepository,
      FileReferenceRepository referenceRepository,
      OssGateway ossGateway,
      ObjectKeyFactory keyFactory) {
    this.assetRepository = assetRepository;
    this.referenceRepository = referenceRepository;
    this.ossGateway = ossGateway;
    this.keyFactory = keyFactory;
  }

  /**
   * 尝试秒传。命中条件是摘要、大小、权限、资产状态和 OSS 对象同时满足。
   */
  @Transactional
  public InstantUploadResult tryInstantUpload(InstantUploadCommand command, long operatorId) {
    FileAsset asset = assetRepository.findAvailableByDigestAndSize(
        command.digest(), command.sizeBytes(), command.orgId()).orElse(null);
    if (asset == null || !ossGateway.exists(asset.objectKey())) {
      return InstantUploadResult.miss();
    }
    referenceRepository.insertIfAbsent(
        new FileReference(asset.id(), command.bizType(), command.bizId(), operatorId));
    return InstantUploadResult.hit(asset.id());
  }

  /** 普通上传完成后的登记;对象校验失败时不创建可用资产。 */
  @Transactional
  public FileAsset registerUploadedObject(
      UploadMetadata metadata, long operatorId) {
    if (!ossGateway.exists(metadata.objectKey())
        || ossGateway.objectSize(metadata.objectKey()) != metadata.sizeBytes()) {
      throw new FileUploadException("OBJECT_VERIFY_FAILED", "OSS 对象不存在或大小不匹配");
    }
    FileAsset asset = assetRepository.insertAvailable(
        new FileAssetDraft(
            metadata.digest(), metadata.sizeBytes(), metadata.originalName(),
            metadata.mimeType(), metadata.objectKey(), operatorId));
    referenceRepository.insertIfAbsent(
        new FileReference(asset.id(), metadata.bizType(), metadata.bizId(), operatorId));
    return asset;
  }
}

秒传的关键不是"查到 MD5 就返回成功",而是:

  • 查询必须带组织/租户范围,避免越权复用;
  • status=AVAILABLE 和 OSS 对象存在是两个独立条件;
  • 返回 fileId,前端之后通过 fileId 获取新签名 URL;
  • 唯一索引和 insertIfAbsent 处理并发重复请求。

反例:如果历史数据只保存了一个已过期的签名 URL,没有 objectKey/fileId,不能把 URL 当作秒传资产;应先做对象键恢复或让本次上传重新落库。

6. 普通上传 API:小文件的一次性闭环

普通上传适合小于阈值的文件。它的优点是事务边界简单,代价是请求会经过业务服务,消耗应用带宽和连接;不要把它用于数百 MB 的长请求。

java 复制代码
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
  private final FileUploadApplicationService applicationService;

  public FileUploadController(FileUploadApplicationService applicationService) {
    this.applicationService = applicationService;
  }

  @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
  public UploadResponse upload(
      @RequestPart("file") MultipartFile file,
      @RequestParam String digest,
      @RequestParam String bizType,
      @RequestParam String bizId,
      Authentication authentication) throws IOException {
    return applicationService.uploadSmallFile(
        new SmallUploadCommand(
            file.getOriginalFilename(), file.getContentType(), file.getSize(),
            digest, bizType, bizId, file.getInputStream()),
        currentUserId(authentication));
  }

  private long currentUserId(Authentication authentication) {
    return Long.parseLong(authentication.getName());
  }
}

应用服务必须在调用 OSS 前校验:最大大小、允许的 MIME 白名单、原始文件名长度、摘要格式和业务权限。成功路径如下:

java 复制代码
@Service
public class FileUploadApplicationService {
  private final OssGateway ossGateway;
  private final ObjectKeyFactory keyFactory;
  private final FileAssetService assetService;
  private final OssProperties properties;

  @Transactional
  public UploadResponse uploadSmallFile(SmallUploadCommand command, long operatorId)
      throws IOException {
    validate(command);
    InstantUploadResult instant = assetService.tryInstantUpload(
        new InstantUploadCommand(command.digest(), command.sizeBytes(),
            command.bizType(), command.bizId(), currentOrgId()), operatorId);
    if (instant.hit()) {
      return UploadResponse.instant(instant.fileId());
    }

    String objectKey = keyFactory.create(command.digest(), command.originalName());
    try (InputStream input = command.input()) {
      ossGateway.putObject(objectKey, input, command.sizeBytes(), safeContentType(command.mimeType()));
    }
    FileAsset asset = assetService.registerUploadedObject(
        new UploadMetadata(
            command.digest(), command.sizeBytes(), command.originalName(),
            safeContentType(command.mimeType()), objectKey,
            command.bizType(), command.bizId()), operatorId);
    return UploadResponse.uploaded(asset.id());
  }

  private void validate(SmallUploadCommand command) {
    if (command.sizeBytes() <= 0 || command.sizeBytes() > properties.getMultipart().getThresholdBytes()) {
      throw new FileUploadException("FILE_SIZE_INVALID", "文件大小不符合普通上传范围");
    }
    if (!command.digest().matches("[0-9a-fA-F]{32}")) {
      throw new FileUploadException("DIGEST_INVALID", "MD5 格式错误");
    }
  }
}

注意:上例为最小示例,currentOrgId() 应接入项目的组织上下文;不能留成固定值。若数据库登记失败,必须通过补偿任务根据 objectKey 清理孤儿对象,不能假设数据库事务会回滚 OSS。

7. 分片上传与断点续传

7.1 初始化

初始化接口先尝试秒传;未命中时创建数据库会话和 OSS uploadId。会话 ID 是业务侧稳定标识,不能直接把 OSS uploadId 暴露为唯一业务主键。

java 复制代码
@PostMapping("/multipart/init")
public MultipartInitResponse init(
    @RequestBody MultipartInitRequest request, Authentication authentication) {
  return multipartService.init(request, currentUserId(authentication));
}

@Service
public class MultipartService {
  private final OssGateway ossGateway;
  private final MultipartSessionRepository sessionRepository;
  private final MultipartPartRepository partRepository;
  private final FileAssetService assetService;
  private final ObjectKeyFactory keyFactory;
  private final OssProperties properties;

  @Transactional
  public MultipartInitResponse init(MultipartInitRequest request, long operatorId) {
    validateMultipart(request);
    InstantUploadResult instant = assetService.tryInstantUpload(
        new InstantUploadCommand(request.digest(), request.sizeBytes(),
            request.bizType(), request.bizId(), request.orgId()), operatorId);
    if (instant.hit()) {
      return MultipartInitResponse.instant(instant.fileId());
    }

    String objectKey = keyFactory.create(request.digest(), request.originalName());
    String uploadId = ossGateway.initiateMultipart(objectKey, request.mimeType());
    String sessionId = UUID.randomUUID().toString();
    MultipartUploadSession session = new MultipartUploadSession(
        sessionId, uploadId, objectKey, request.digest(), request.sizeBytes(),
        request.originalName(), request.mimeType(), request.bizType(), request.bizId(),
        properties.getMultipart().getPartSizeBytes(), operatorId,
        Instant.now().plus(properties.getMultipart().getSessionExpireMinutes(), ChronoUnit.MINUTES));
    sessionRepository.insert(session);
    return MultipartInitResponse.created(
        sessionId, objectKey, session.partSizeBytes(), List.of());
  }

  private void validateMultipart(MultipartInitRequest request) {
    long partCount = (request.sizeBytes() + properties.getMultipart().getPartSizeBytes() - 1)
        / properties.getMultipart().getPartSizeBytes();
    if (partCount > properties.getMultipart().getMaxParts()) {
      throw new FileUploadException("PART_COUNT_EXCEEDED", "分片数量超过 OSS 限制");
    }
  }
}

7.2 上传分片

java 复制代码
@PostMapping(value = "/multipart/{sessionId}/parts/{partNumber}",
    consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public PartUploadResponse uploadPart(
    @PathVariable String sessionId,
    @PathVariable int partNumber,
    @RequestPart("part") MultipartFile part,
    Authentication authentication) throws IOException {
  return multipartService.uploadPart(
      sessionId, partNumber, part, currentUserId(authentication));
}

@Transactional
public PartUploadResponse uploadPart(
    String sessionId, int partNumber, MultipartFile part, long operatorId) throws IOException {
  MultipartUploadSession session = sessionRepository.lockOwned(sessionId, operatorId)
      .orElseThrow(() -> new FileUploadException("SESSION_NOT_FOUND", "上传会话不存在或无权限"));
  checkPartNumberAndSize(session, partNumber, part.getSize());

  try (InputStream input = part.getInputStream()) {
    PartETag etag = ossGateway.uploadPart(
        session.objectKey(), session.uploadId(), partNumber, input, part.getSize());
    partRepository.upsert(new MultipartPart(
        sessionId, partNumber, etag.getETag(), part.getSize(), Instant.now()));
    return new PartUploadResponse(partNumber, etag.getETag());
  }
}

重试规则:同一 sessionId + partNumber 重传时,以 OSS 返回的新 ETag 覆盖旧记录;完成时只信任数据库最新 ETag。不要让客户端直接提交任意 ETag 列表。

7.3 查询已上传分片

java 复制代码
@GetMapping("/multipart/{sessionId}/parts")
public List<PartUploadResponse> listParts(
    @PathVariable String sessionId, Authentication authentication) {
  sessionRepository.assertOwned(sessionId, currentUserId(authentication));
  return partRepository.findAll(sessionId).stream()
      .sorted(Comparator.comparingInt(MultipartPart::partNumber))
      .map(part -> new PartUploadResponse(part.partNumber(), part.etag()))
      .toList();
}

前端刷新页面后,使用 sessionId 重新查询该列表,只上传缺失分片。跨设备恢复需要把 sessionId 持久化到服务端业务记录,而不能只放浏览器 LocalStorage。

7.4 完成合并与二次校验

java 复制代码
@PostMapping("/multipart/{sessionId}/complete")
public UploadResponse complete(
    @PathVariable String sessionId, Authentication authentication) {
  return multipartService.complete(sessionId, currentUserId(authentication));
}

@Transactional
public UploadResponse complete(String sessionId, long operatorId) {
  MultipartUploadSession session = sessionRepository.lockOwned(sessionId, operatorId)
      .orElseThrow(() -> new FileUploadException("SESSION_NOT_FOUND", "上传会话不存在或无权限"));
  if (session.isExpired()) {
    throw new FileUploadException("SESSION_EXPIRED", "上传会话已过期");
  }
  if (session.isAvailable()) {
    return UploadResponse.uploaded(session.fileId());
  }

  List<MultipartPart> persistedParts = partRepository.findAll(sessionId).stream()
      .sorted(Comparator.comparingInt(MultipartPart::partNumber))
      .toList();
  assertAllPartsPresent(session, persistedParts);
  List<PartETag> etags = persistedParts.stream()
      .map(part -> new PartETag(part.partNumber(), part.etag()))
      .toList();
  ossGateway.completeMultipart(session.objectKey(), session.uploadId(), etags);

  if (!ossGateway.exists(session.objectKey())
      || ossGateway.objectSize(session.objectKey()) != session.sizeBytes()) {
    sessionRepository.markFailed(sessionId, "OBJECT_VERIFY_FAILED");
    throw new FileUploadException("OBJECT_VERIFY_FAILED", "合并后对象校验失败");
  }
  FileAsset asset = assetService.registerUploadedObject(
      new UploadMetadata(
          session.digest(), session.sizeBytes(), session.originalName(),
          session.mimeType(), session.objectKey(), session.bizType(), session.bizId()),
      operatorId);
  sessionRepository.markAvailable(sessionId, asset.id());
  return UploadResponse.uploaded(asset.id());
}

为什么必须二次校验:CompleteMultipartUpload 成功只说明 OSS 接受了分片列表,不等于业务元数据(大小、摘要、权限引用)已经一致。摘要的服务端最终校验可采用合并后流式计算,但不能为了示例把超大对象完整读入内存。注意 etags 虽由 Stream.toList() 创建为不可变列表,但进入 OssGateway 后被复制为可变 ArrayList;这是 SDK 3.17.4 可能排序列表时避免 UnsupportedOperationException 的必要适配。

7.5 取消与超时清理

java 复制代码
@DeleteMapping("/multipart/{sessionId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void cancel(@PathVariable String sessionId, Authentication authentication) {
  multipartService.cancel(sessionId, currentUserId(authentication));
}

@Transactional
public void cancel(String sessionId, long operatorId) {
  MultipartUploadSession session = sessionRepository.lockOwned(sessionId, operatorId)
      .orElseThrow(() -> new FileUploadException("SESSION_NOT_FOUND", "上传会话不存在或无权限"));
  if (!session.isAvailable() && !session.isCancelled()) {
    ossGateway.abortMultipart(session.objectKey(), session.uploadId());
    sessionRepository.markCancelled(sessionId);
  }
}

@Scheduled(fixedDelayString = "${app.oss.multipart.cleanup-delay-ms:3600000}")
public void cleanupExpiredSessions() {
  for (MultipartUploadSession session : sessionRepository.findExpiredUploading(Instant.now())) {
    try {
      ossGateway.abortMultipart(session.objectKey(), session.uploadId());
      sessionRepository.markCancelled(session.id());
    } catch (RuntimeException ex) {
      log.warn("OSS multipart cleanup failed, sessionId={}", session.id(), ex);
    }
  }
}

取消是幂等操作:已取消再次取消直接返回;已完成不能取消对象。清理任务必须分页执行并限制每轮数量,避免一次扫描锁住整张表。

8. 预览链接与安全边界

8.1 只保存稳定标识

java 复制代码
@GetMapping("/{fileId}/preview-url")
public PreviewUrlResponse previewUrl(
    @PathVariable long fileId, Authentication authentication) {
  FileAsset asset = assetRepository.findAvailableById(fileId)
      .orElseThrow(() -> new FileUploadException("FILE_NOT_FOUND", "文件不存在"));
  permissionService.assertReadable(asset, currentUserId(authentication));
  Duration validity = Duration.ofSeconds(properties.getPreviewExpireSeconds());
  String url = ossGateway.presignGet(asset.objectKey(), validity);
  return new PreviewUrlResponse(
      fileId, url, Instant.now().plus(validity), properties.getPreviewExpireSeconds());
}

数据库保存 fileId/objectKey,私有对象每次访问动态生成签名 URL;公开对象可以按需生成无有效期 publicUrl。签名响应建议包含 expiresAtexpiresInSeconds,前端在过期前刷新;公开 URL 不需要刷新,但修改 ACL 后必须重新验证。不要把完整签名 URL 写入永久字段或日志。

8.2 防盗链不能按目录假设

OSS Referer 防盗链规则是 Bucket 级能力,不能把"只保护 files/ 前缀"当作已支持功能。若只保护某个前缀:

  1. 相关对象设置为私有 ACL;
  2. 新上传对象默认私有;
  3. 通过短期签名 GET URL 或业务代理提供访问;
  4. 历史对象按前缀批量迁移 ACL,并独立验收;
  5. 对高敏感内容叠加业务鉴权、一次性票据或代理下载。

签名 URL 在过期前被复制仍然可用;有效期不是撤销机制。浏览器 Referer 可能为空,移动端 WebView 和脚本客户端也可能不发送预期 Referer,因此不能只依赖 Referer 作为授权。

9. 接口契约和错误码

接口 成功返回 关键错误
POST /api/files/upload fileIdmode=UPLOADED/INSTANT FILE_SIZE_INVALIDDIGEST_INVALIDOSS_ERROR
POST /api/files/multipart/init sessionIdpartSize、已上传分片 PART_COUNT_EXCEEDEDNO_PERMISSION
POST /api/files/multipart/{id}/parts/{n} partNumberetag SESSION_EXPIREDPART_SIZE_INVALID
GET /api/files/multipart/{id}/parts 分片编号和 ETag SESSION_NOT_FOUND
POST /api/files/multipart/{id}/complete fileId PART_MISSINGOBJECT_VERIFY_FAILED
DELETE /api/files/multipart/{id} HTTP 204 NO_PERMISSIONOSS_ERROR
GET /api/files/{id}/preview-url 短期 URL 和过期秒数 FILE_NOT_FOUNDNO_PERMISSION
GET /api/files/{id}/public-url 无签名、无有效期 URL FILE_NOT_PUBLICNO_PERMISSION

建议所有响应带 traceId;日志关联 OSS requestId,但不得输出 AccessKey、Secret、完整签名 URL。

10. 测试与验证边界

10.1 必测用例

java 复制代码
@Test
void instantUploadCreatesReferenceWithoutUploadingAgain() { /* verify putObject never called */ }

@Test
void multipartResumeUploadsOnlyMissingParts() { /* persisted part 1 is reused */ }

@Test
void completeRejectsMissingPart() { /* expect PART_MISSING */ }

@Test
void completePassesMutablePartListToOssSdk() { /* SDK sorting must not throw UnsupportedOperationException */ }

@Test
void duplicateCompleteReturnsExistingAsset() { /* idempotent */ }

@Test
void expiredPreviewUrlIsRejectedByOss() { /* requires isolated real OSS or contract test */ }

10.2 验证分层

层次 已证明什么 未证明什么
单元测试 状态判断、分片排序、幂等分支和错误码 SDK 网络、真实 ACL、签名时钟偏差
隔离集成环境 数据库唯一索引、事务和权限过滤 生产 Bucket 策略、跨地域网络
真实 OSS 验收 对象存在、ETag、合并、签名过期、Abort 高并发容量、灾备和成本曲线

推荐验收命令(按实际模块调整):

bash 复制代码
mvn -pl ai-image-backend -am -DskipTests=false test
git diff --check -- docs/Java实现阿里云OSS文件上传链路与断点续传.md

如果没有真实 OSS 凭证和隔离 Bucket,不得把 mock 测试写成"生产链路已打通"。

11. 取舍、失败路径与上线前检查

11.1 方案取舍

决策 选择 代价/不适用
小文件上传 后端中转 占用应用带宽,不适合大文件
大文件上传 Multipart Upload 需要会话、分片和清理状态
秒传摘要 MD5 + size 有碰撞理论风险;高安全场景用 SHA-256
预览方式 私有对象 + 5 分钟签名 URL 泄露后过期前仍可访问
业务权限 fileId 动态鉴权 每次预览多一次 API 调用

11.2 失败路径

  • OSS 上传成功、数据库写入失败:资产处于"孤儿对象"状态,补偿任务按 objectKey 和创建时间清理。
  • 客户端完成时漏传分片:服务端根据持久化分片列表返回 PART_MISSING,不能盲目调用 Complete。
  • 客户端伪造 ETag:服务端忽略请求体中的 ETag,只使用上传接口返回并持久化的值。
  • 同摘要不同租户误命中:检查查询条件、唯一索引和权限上下文;不能只修前端。
  • 签名 URL 403:检查服务器时钟、Bucket ACL、Endpoint/Region、URL 过期时间和对象是否存在。

11.3 上线前检查清单

  • AccessKey 仅使用 RAM 最小权限,示例和日志无真实密钥。
  • Bucket 默认私有,预览通过动态签名或业务代理。
  • 秒传查询带租户/组织范围,数据库唯一索引与业务规则一致。
  • Multipart 会话、分片记录和孤儿对象有清理机制。
  • 普通上传阈值、分片大小、并发数和会话过期时间可配置。
  • 已验证中文文件名、0 字节、边界大小、重复请求、断网恢复和过期 URL。
  • 真实 OSS 验收与单元测试结果分开记录。

12. 结论

这套实现的关键不是把 putObjectuploadPartgeneratePresignedUrl 拼在一起,而是让四个权威关系保持一致:OSS 保存对象内容,file_asset 保存稳定元数据,multipart_upload_session/part 保存恢复依据,file_reference 保存业务归属。普通上传解决低延迟小文件;分片上传解决大文件和弱网;秒传减少重复传输;短期签名 URL 解决私有预览。只有状态收敛、幂等、权限隔离和补偿清理同时具备,才称得上完整闭环。

正式落地前仍需补齐:目标文件类型与大小、组织/租户隔离规则、真实 Bucket/RAM Policy、前端直传还是后端中转、是否需要 CDN/审核/病毒扫描,以及真实 OSS 和浏览器端到端验收。

结论:已生成 Java 版阿里云 OSS 普通上传、秒传、分片上传、断点续传、取消清理和预览签名 URL 的全链路实现文档;示例代码可作为项目适配基线,生产可用性仍以隔离 OSS、数据库和权限验收为准。

相关推荐
—Miss. Z—1 小时前
计算机三级数据库技术—填空题
数据库·mysql
传奇开心果编程1 小时前
【Rust入门知识点学与练】第9课:Vec 动态数组
开发语言·学习·rust
卢锡荣1 小时前
单芯掌控多口互联|乐得瑞 LDR6020 PD3.1 多通道 Type‑C 控制 SOC 芯片
c语言·开发语言
2333!!!!!1 小时前
rocket新手一些常见问题
java·开发语言
yume_sibai1 小时前
02-Rust 所有权与借用深入解析(底层原理 + 借用检查器 + 生命周期 + 内部可变性)
开发语言·后端·rust
xxwxx__1 小时前
深入理解 C++ STL:stack、queue 与 deque 从使用到底层实现全解析
开发语言·c++·算法
Highcharts.js1 小时前
甘特图纵向任务、横向时间显示图表可视化开发|Highcharts甘特图示例
开发语言·前端·javascript·可视化·甘特图·数据可视化·highcharts
骇客野人1 小时前
Springboot 的 配置文件 application.yml 和 bootrap.yml 的由来和作用
java·spring boot·后端
wuminyu1 小时前
JVM锁膨胀与Futex源码解析
java·linux·c语言·jvm·c++