当前位置: 代码网 > it编程>编程语言>Java > Java实现阿里云OSS文件上传链路

Java实现阿里云OSS文件上传链路

2026年09月07日 Java 我要评论
内容摘要:本文给出一套基于 java 17、spring boot 3 和阿里云 oss java sdk 3.17.4 的文件上传后端实现。重点不是把 oss api 逐个罗列,而是建立&ldquo

内容摘要:本文给出一套基于 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. 架构与状态:四类数据必须分开

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

2.1 文件资产状态

init → uploading → verifying → available
  │        │             │
  └──────→ failed ←──────┘
uploading → cancelled
  • init:已完成参数预检,尚未产生可用对象。
  • uploading:普通对象写入中,或 multipart 会话存在。
  • verifying:oss 已完成,服务端正在校验大小、摘要和权限元数据。
  • available:对象存在且可以建立业务引用。
  • failed/cancelled:不可继续使用;multipart 会话必须尝试中止。

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

2.2 数据库表(mysql)

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

<dependency>
  <groupid>com.aliyun.oss</groupid>
  <artifactid>aliyun-sdk-oss</artifactid>
  <version>3.17.4</version>
</dependency>

3.2application-local.yml

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

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 调用。

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 参数。调用方不需要、也不应该自己拼接签名参数。

/** 返回带过期时间的预览结果,便于前端提前刷新。 */
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) {}

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

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 和域名配置。

/** 公开对象链接:不签名、不设置过期时间。 */
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:

@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 或上传后流式计算摘要。

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 映射。

@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 的长请求。

@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 白名单、原始文件名长度、摘要格式和业务权限。成功路径如下:

@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 暴露为唯一业务主键。

@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 上传分片

@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 查询已上传分片

@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 完成合并与二次校验

@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 取消与超时清理

@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 只保存稳定标识

@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/uploadfileidmode=uploaded/instantfile_size_invaliddigest_invalidoss_error
post /api/files/multipart/initsessionidpartsize、已上传分片part_count_exceededno_permission
post /api/files/multipart/{id}/parts/{n}partnumberetagsession_expiredpart_size_invalid
get /api/files/multipart/{id}/parts分片编号和 etagsession_not_found
post /api/files/multipart/{id}/completefileidpart_missingobject_verify_failed
delete /api/files/multipart/{id}http 204no_permissionoss_error
get /api/files/{id}/preview-url短期 url 和过期秒数file_not_foundno_permission
get /api/files/{id}/public-url无签名、无有效期 urlfile_not_publicno_permission

建议所有响应带 traceid;日志关联 oss requestid,但不得输出 accesskey、secret、完整签名 url。

10. 测试与验证边界

10.1 必测用例

@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高并发容量、灾备和成本曲线

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

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、数据库和权限验收为准。

以上就是java实现阿里云oss文件上传链路的详细内容,更多关于java阿里云oss文件上传链路的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com