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 文件系统至少要同时回答四个问题:
- 文件是否已经存在,能否安全复用(秒传)?
- 网络中断后,如何知道哪些分片已经成功(断点续传)?
- OSS 对象完成后,数据库何时才允许把文件标记为可用?
- 私有对象如何生成可过期的预览链接,而不是把临时 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 中的 Expires、OSSAccessKeyId 和 Signature 参数。调用方不需要、也不应该自己拼接签名参数。
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。它不包含 Expires、OSSAccessKeyId、Signature 参数,能否访问完全取决于 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 秒传服务核心逻辑
以下 FileAssetRepository 和 FileReferenceRepository 是项目适配接口,可用 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。签名响应建议包含 expiresAt 或 expiresInSeconds,前端在过期前刷新;公开 URL 不需要刷新,但修改 ACL 后必须重新验证。不要把完整签名 URL 写入永久字段或日志。
8.2 防盗链不能按目录假设
OSS Referer 防盗链规则是 Bucket 级能力,不能把"只保护 files/ 前缀"当作已支持功能。若只保护某个前缀:
- 相关对象设置为私有 ACL;
- 新上传对象默认私有;
- 通过短期签名 GET URL 或业务代理提供访问;
- 历史对象按前缀批量迁移 ACL,并独立验收;
- 对高敏感内容叠加业务鉴权、一次性票据或代理下载。
签名 URL 在过期前被复制仍然可用;有效期不是撤销机制。浏览器 Referer 可能为空,移动端 WebView 和脚本客户端也可能不发送预期 Referer,因此不能只依赖 Referer 作为授权。
9. 接口契约和错误码
| 接口 | 成功返回 | 关键错误 |
|---|---|---|
POST /api/files/upload |
fileId、mode=UPLOADED/INSTANT |
FILE_SIZE_INVALID、DIGEST_INVALID、OSS_ERROR |
POST /api/files/multipart/init |
sessionId、partSize、已上传分片 |
PART_COUNT_EXCEEDED、NO_PERMISSION |
POST /api/files/multipart/{id}/parts/{n} |
partNumber、etag |
SESSION_EXPIRED、PART_SIZE_INVALID |
GET /api/files/multipart/{id}/parts |
分片编号和 ETag | SESSION_NOT_FOUND |
POST /api/files/multipart/{id}/complete |
fileId |
PART_MISSING、OBJECT_VERIFY_FAILED |
DELETE /api/files/multipart/{id} |
HTTP 204 | NO_PERMISSION、OSS_ERROR |
GET /api/files/{id}/preview-url |
短期 URL 和过期秒数 | FILE_NOT_FOUND、NO_PERMISSION |
GET /api/files/{id}/public-url |
无签名、无有效期 URL | FILE_NOT_PUBLIC、NO_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. 结论
这套实现的关键不是把 putObject、uploadPart 和 generatePresignedUrl 拼在一起,而是让四个权威关系保持一致:OSS 保存对象内容,file_asset 保存稳定元数据,multipart_upload_session/part 保存恢复依据,file_reference 保存业务归属。普通上传解决低延迟小文件;分片上传解决大文件和弱网;秒传减少重复传输;短期签名 URL 解决私有预览。只有状态收敛、幂等、权限隔离和补偿清理同时具备,才称得上完整闭环。
正式落地前仍需补齐:目标文件类型与大小、组织/租户隔离规则、真实 Bucket/RAM Policy、前端直传还是后端中转、是否需要 CDN/审核/病毒扫描,以及真实 OSS 和浏览器端到端验收。
结论:已生成 Java 版阿里云 OSS 普通上传、秒传、分片上传、断点续传、取消清理和预览签名 URL 的全链路实现文档;示例代码可作为项目适配基线,生产可用性仍以隔离 OSS、数据库和权限验收为准。