这篇是给 Spring Boot 初同学看的。我把对接阿里云 OSS 的整个过程拆开揉碎讲,包括原理、为什么这么做、key 怎么读取、那些文档里不写但你会踩的坑。对着这篇一步步来,不用再去翻别的资料。

背景:我手上有个 IM 项目(FriendChat),之前用的 MinIO(自己搭的对象存储),后来换成阿里云 OSS。这篇就以这次迁移为例,讲清楚怎么对接。


一、先说为啥要折腾这个

我一开始用 MinIO,自己 Docker 跑一个,挺美的——免费、兼容 S3 API、本地开发够用。但真上生产就几个问题:

  1. 得自己运维:MinIO 挂了文件全没,数据备份得自己搞,磁盘满了得自己扩
  2. 带宽是自己的:所有图片走应用服务器,用户一多服务器带宽就爆
  3. 没有 CDN 加速:外地用户访问慢

对象存储这玩意儿,云厂商已经做得又便宜又稳,真没必要自己搭。阿里云 OSS、腾讯云 COS、七牛云 Kodo,挑一个就行。我选 OSS 没别的原因,就是阿里云用的多、文档全。

所以这篇就是:Spring Boot 后端怎么把文件传到 OSS,怎么让前端能访问。


二、先把概念搞懂,别上来就写代码

很多人对接 OSS 一脸懵,是因为这几个概念没分清。我用大白话讲一遍。

2.1 Bucket(桶)

Bucket 就是装文件的容器。你可以理解成”一个顶层文件夹”。

  • 名字全局唯一:整个阿里云所有用户里,你起的 Bucket 名不能和别人撞。比如 friendchat 这种通用名早被占了,得起 friendchat-wange-2026 这种。
  • 一个 Bucket 下可以放无数个文件,目录结构自己定。
  • 权限策略是 Bucket 级别设的:私有(private)、公共读(public-read)、公共读写(public-read-write)。强烈建议私有,后面讲为啥。

2.2 Object(对象)

Object 就是一个文件。每个 Object 有:

  • key:文件的路径,比如 image/2026/08/02/uuid.jpg。注意,OSS 没有真正的目录概念,这个 key 就是一整个字符串,斜杠只是看着像目录。你按 image/2026/08/02/ 这样组织纯粹是为了管理方便。
  • data:文件内容(二进制)。
  • metadata:元数据,比如 Content-Type、文件大小。

2.3 Endpoint(接入点)

Endpoint 就是你访问 OSS 的域名。每个地域(region)有自己的 Endpoint,比如:

  • 杭州:oss-cn-hangzhou.aliyuncs.com
  • 上海:oss-cn-shanghai.aliyuncs.com
  • 北京:oss-cn-beijing.aliyuncs.com

重点:Endpoint 分外网和内网两种。

  • 外网 Endpoint(oss-cn-hangzhou.aliyuncs.com):走公网,谁都能访问,但要付公网流量费。
  • 内网 Endpoint(oss-cn-hangzhou-internal.aliyuncs.com):只有阿里云同地域的 ECS 能访问,免费!

如果你的后端部署在阿里云杭州的 ECS 上,一定要用内网 Endpoint,能省一大笔流量费。本地开发用外网。

2.4 Region(地域)

Region 是 Bucket 所在的物理机房位置。创建 Bucket 时选,选了就改不了。Bucket一旦创建,Region 就定了,所以一开始就想好:用户主要在哪 → 就选哪的 Region,延迟低。

2.5 AccessKey(访问密钥)

AccessKey 是你调用 OSS API 的身份凭证,分两个:

  • AccessKeyId:类似账号,可以明文传
  • AccessKeySecret:类似密码,绝对不能泄露

签名算法用 AccessKeySecret 算签名,服务端验证。这个后面讲签名 URL 时会详细说。

安全提醒:绝对不要用主账号的 AccessKey! 万一泄露,你整个阿里云账号就裸奔了。一定要用 RAM 子账号,只给 OSS 的权限。后面讲怎么做。


三、开通 OSS:三步走

写代码前,先在阿里云控制台把东西准备好。

第 1 步:开通 OSS 服务

登录阿里云控制台,搜”对象存储 OSS”,进去点开通。开通不花钱,按用量计费。

第 2 步:创建 Bucket

点”创建 Bucket”,填几个东西:

  • Bucket 名称:全局唯一,比如 friendchat-wange
  • 地域:选离你近的,比如华东1(杭州)
  • 存储类型:选”标准存储”(频繁访问的文件用这个)
  • 读写权限:选”私有”(重要!别选公共读)

创建完,你能看到一个 Bucket 的域名,类似 friendchat-wange.oss-cn-hangzhou.aliyuncs.com。记住这个,待会配置用。

为啥选私有?公共读意味着任何人拿到 URL 就能访问你的文件。IM 里的图片、文件是用户隐私,你不能保证 URL 不被泄露。私有 Bucket 下,文件 URL 必须带签名才能访问,签名还能设过期时间,泄露了也就那一段时间有效。安全得多。

第 3 步:创建 RAM 子账号拿 AccessKey

千万别用主账号 AccessKey! 正确做法:

  1. 控制台搜”RAM 访问控制”,进去
  2. 左边”用户”→”创建用户”
  3. 起个名字,比如 friendchat-oss,勾选”编程访问”(OpenAPI 调用),不要勾控制台访问(不需要登录控制台)
  4. 创建完会显示 AccessKeyId 和 AccessKeySecret,这一页只显示一次,务必复制保存! 关了就再也看不到 Secret 了
  5. 给这个用户授权:权限管理里加 AliyunOSSFullAccess(或者更细粒度,只给某个 Bucket 的读写权限)

拿到 AccessKeyId 和 AccessKeySecret,待会写进配置文件。


四、开始写代码

准备工作做完了,开始写后端。我用的是 Spring Boot 2.7.3 + Java 8。

4.1 加依赖

打开 pom.xml,加阿里云 OSS 的 SDK:

1
2
3
4
5
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>

为啥用这个版本? 3.17.x 是写这篇时的稳定版,支持 Java 8。如果你用的是 Java 9+,可以用更新的 3.18.x。版本可以在 mvnrepository 查。

注意: 如果你项目里之前用 MinIO,记得把 MinIO 的依赖删掉:

1
2
3
4
5
6
<!-- 删掉这个 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.13</version>
</dependency>

4.2 写配置

打开 src/main/resources/application.yml,加 OSS 配置:

1
2
3
4
5
6
7
8
9
oss:
# 外网Endpoint,本地开发用;生产环境部署在阿里云ECS上换成内网Endpoint(省流量费)
# 内网Endpoint长这样:oss-cn-hangzhou-internal.aliyuncs.com
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
bucket-name: friendchat-wange
# 签名URL有效期(秒),默认1小时
signed-url-expire: 3600

几个要点讲清楚:

  1. endpoint 不要带 http:// 或 https://,SDK 会自己拼。你写 oss-cn-hangzhou.aliyuncs.com 就行。
  2. access-key-id 和 access-key-secret 别硬编码进代码,放 yml 里。生产环境更推荐用环境变量或配置中心(Nacos),别把 Secret 提交到 Git!后面会讲怎么用环境变量。
  3. bucket-name 写你创建的 Bucket 名,不带地域后缀。
  4. signed-url-expire 是签名 URL 的有效期,3600 秒 = 1 小时。这个值看业务,IM 场景 1 小时够用(用户聊完天就关了)。如果是长会话可以设长点。

配置项名字的映射规则:yml 里我写的 access-key-id(kebab-case 短横线),Java 里字段叫 accessKeyId(camelCase 驼峰)。Spring Boot 的 @ConfigurationProperties 支持松散绑定,会自动把 access-key-id 映射到 accessKeyId。所以你写 access-key-id、accessKeyId、access_key_id 都行,但推荐 yml 用短横线,Java 用驼峰。

4.3 写配置类 OssConfig

新建 src/main/java/top/wange/config/OssConfig.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package top.wange.config;

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

import javax.annotation.PreDestroy;

/**
* 阿里云OSS配置类
* 读取application.yml中的oss配置,创建OSSClient Bean
* OSSClient是线程安全的,整个应用共用一个实例即可,由Spring管理生命周期
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "oss")
public class OssConfig {

/** 访问域名,例如 oss-cn-hangzhou.aliyuncs.com */
private String endpoint;

/** AccessKey ID,阿里云子账号的访问密钥ID */
private String accessKeyId;

/** AccessKey Secret,阿里云子账号的访问密钥,务必保密 */
private String accessKeySecret;

/** Bucket名称,全局唯一 */
private String bucketName;

/** 签名URL有效期(秒) */
private long signedUrlExpire = 3600;

/** OSSClient实例,由Spring管理,销毁时关闭 */
private OSS ossClient;

/**
* 创建OSSClient实例
* OSSClient内部自带连接池和HTTP客户端,创建一次反复用,不要每次上传都new一个
* @return OSS 客户端
*/
@Bean
public OSS ossClient() {
this.ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
return this.ossClient;
}

/**
* 容器销毁时关闭OSSClient,释放连接池资源
*/
@PreDestroy
public void destroy() {
if (ossClient != null) {
ossClient.shutdown();
}
}
}

这段代码每一行都讲清楚,因为初学者最容易在这里迷糊。

@Configuration 是啥

告诉 Spring:这个类是一个配置类,里面有 @Bean 方法。Spring 启动时会扫描这类,把 @Bean 方法的返回值注册成 Bean,放进 IoC 容器。

@ConfigurationProperties(prefix = "oss") 是啥

告诉 Spring:把 yml 里 oss 这个前缀下的配置项,按名字映射到这个类的字段上。流程是:

  1. Spring 启动,看到 @ConfigurationProperties(prefix = "oss")
  2. 去 yml 里找 oss 下的所有 key
  3. 对每个 key,用反射找类里同名的 setter 方法,调用它注入值
  4. @Data(Lombok)帮你生成了 setter,所以字段能被注入
1
2
3
4
5
6
7
8
9
10
11
flowchart LR
A[yml: oss.access-key-id] -->|松散绑定| B[accessKeyId 字段]
A2[yml: oss.endpoint] -->|同名映射| C[endpoint 字段]
A3[yml: oss.bucket-name] -->|松散绑定| D[bucketName 字段]

style A fill:#fbbf24,color:#000
style A2 fill:#fbbf24,color:#000
style A3 fill:#fbbf24,color:#000
style B fill:#60a5fa,color:#fff
style C fill:#60a5fa,color:#fff
style D fill:#60a5fa,color:#fff

@Bean 是啥

@Bean 注解的方法,返回值会被注册为 Spring Bean。方法名 ossClient 就是 Bean 的名字。之后任何地方 @Autowired private OSS ossClient; 就能拿到这个实例。

1
2
3
4
5
6
7
8
9
10
11
flowchart LR
A[Spring 启动] --> B[扫描 @Configuration 类]
B --> C[发现 @Bean 方法 ossClient]
C --> D[调用方法创建 OSS 实例]
D --> E[放进 IoC 容器]
E --> F[其他组件 @Autowired 拿到同一个实例]

style A fill:#10b981,color:#fff
style C fill:#f59e0b,color:#fff
style E fill:#8b5cf6,color:#fff
style F fill:#60a5fa,color:#fff

为啥 OSSClient 要做成单例 Bean

这是新手最容易犯的错。 OSSClient 不是个普通对象,它内部有:

  • HTTP 连接池
  • HTTP 客户端
  • 线程池

创建一个 OSSClient 开销很大(要建连接池)。如果你每次上传都 new OSSClientBuilder().build(...),性能会差到爆炸,而且连接会泄漏。

好消息是 OSSClient 是线程安全的,多线程并发用一个实例没问题。所以整个应用共用一个,做成单例 Bean 最合适。

@PreDestroy 是啥

Bean 销毁前调用的回调方法。应用关闭时,Spring 会调 destroy(),关闭 OSSClient 的连接池,释放资源。不加这个的话,应用关闭时连接池里的连接不会被释放,虽然 JVM 退出会清,但不规范。


4.4 写上传服务

先定义接口 FileService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package top.wange.service;

import org.springframework.web.multipart.MultipartFile;
import top.wange.entity.MediaFile;

/**
* 文件上传服务接口
* 对接阿里云OSS,支持图片、文件、语音、视频上传
*/
public interface FileService {
/**
* 上传文件到OSS
* @param file 文件
* @param userId 用户ID
* @param fileType 文件类型(IMAGE/FILE/VOICE/VIDEO)
* @return 文件信息(filePath字段为OSS中的objectName)
*/
MediaFile uploadFile(MultipartFile file, Integer userId, String fileType);

/**
* 根据objectName生成临时签名URL
* 私有Bucket的文件不能直接访问,必须用签名URL,URL带过期时间
* @param objectName OSS对象路径,例如 image/2026/08/02/uuid.jpg
* @return 可临时访问的签名URL
*/
String generateSignedUrl(String objectName);
}

再写实现 FileServiceImpl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
package top.wange.service.impl;

import com.aliyun.oss.OSS;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import top.wange.config.OssConfig;
import top.wange.entity.MediaFile;
import top.wange.mapper.MediaFileMapper;
import top.wange.service.FileService;

import java.io.InputStream;
import java.net.URL;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class FileServiceImpl implements FileService {

@Autowired
private OSS ossClient;

@Autowired
private OssConfig ossConfig;

@Autowired
private MediaFileMapper mediaFileMapper;

// 文件大小限制
private static final long IMAGE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
private static final long FILE_MAX_SIZE = 20 * 1024 * 1024; // 20MB
private static final long VOICE_MAX_SIZE = 10 * 1024 * 1024; // 10MB
private static final long VIDEO_MAX_SIZE = 50 * 1024 * 1024; // 50MB

// 允许的扩展名白名单
private static final List<String> ALLOWED_IMAGE_TYPES = Arrays.asList("jpg", "jpeg", "png", "gif", "webp");
private static final List<String> ALLOWED_FILE_TYPES = Arrays.asList("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "zip", "rar", "txt");
private static final List<String> ALLOWED_VOICE_TYPES = Arrays.asList("mp3", "wav", "aac", "ogg", "flac");
private static final List<String> ALLOWED_VIDEO_TYPES = Arrays.asList("mp4", "avi", "mov", "wmv", "mkv", "flv");

@Override
public MediaFile uploadFile(MultipartFile file, Integer userId, String fileType) {
// 1. 校验
if (file == null || file.isEmpty()) {
throw new RuntimeException("文件不能为空");
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || !originalFilename.contains(".")) {
throw new RuntimeException("无效的文件名");
}
String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
validateFileType(extension, fileType);
validateFileSize(file.getSize(), fileType);

// 2. 生成objectName
String objectName = buildObjectName(fileType, extension);

// 3. 上传到OSS
try (InputStream inputStream = file.getInputStream()) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());

PutObjectRequest putRequest = new PutObjectRequest(
ossConfig.getBucketName(), objectName, inputStream, metadata);
ossClient.putObject(putRequest);
} catch (Exception e) {
log.error("文件上传到OSS失败: objectName={}", objectName, e);
throw new RuntimeException("文件上传失败: " + e.getMessage());
}

// 4. 存数据库(只存objectName,不存URL,因为URL会过期)
MediaFile mediaFile = new MediaFile();
mediaFile.setUserId(userId);
mediaFile.setFileName(originalFilename);
mediaFile.setFilePath(objectName); // 注意:存的是objectName,不是URL
mediaFile.setFileSize(file.getSize());
mediaFile.setFileType(fileType.toUpperCase());
mediaFile.setUploadTime(LocalDateTime.now());
mediaFileMapper.insert(mediaFile);

log.info("文件上传OSS成功: userId={}, fileType={}, objectName={}", userId, fileType, objectName);
return mediaFile;
}

@Override
public String generateSignedUrl(String objectName) {
if (objectName == null || objectName.isEmpty()) {
return null;
}
Date expiration = new Date(System.currentTimeMillis() + ossConfig.getSignedUrlExpire() * 1000L);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
ossConfig.getBucketName(), objectName, com.aliyun.oss.HttpMethod.GET);
request.setExpiration(expiration);
URL url = ossClient.generatePresignedUrl(request);
return url.toString();
}

/**
* 生成OSS对象的存储路径: fileType/yyyy/MM/dd/uuid.ext
*/
private String buildObjectName(String fileType, String extension) {
String uniqueFileName = UUID.randomUUID().toString() + "." + extension;
LocalDate now = LocalDate.now();
String datePath = String.format("%d/%02d/%02d", now.getYear(), now.getMonthValue(), now.getDayOfMonth());
return fileType.toLowerCase() + "/" + datePath + "/" + uniqueFileName;
}

// validateFileType 和 validateFileSize 省略,就是白名单校验
}

这里有几个关键点必须讲:

objectName 怎么设计

objectName 就是文件在 OSS 里的路径。我用的格式是 fileType/yyyy/MM/dd/uuid.ext,比如 image/2026/08/02/550e8400-e29b-41d4-a716-446655440000.jpg。

为啥这么设计:

  • 按类型分目录(image/、file/、voice/、video/):方便统计、管理。比如想看图片占多少空间,直接看 image/ 前缀。
  • 按日期分目录(2026/08/02/):虽然 OSS 单目录不限文件数,但按日期分方便排查问题(比如”8月2号上传的文件出问题了”)。
  • 用 UUID 命名:避免同名文件冲突,也防止用户上传的文件名带特殊字符引发问题。不要用原始文件名存,中文、空格、特殊字符都是坑。

数据库存什么:objectName,不是 URL

这是个重要决策。我数据库 media_files 表的 file_path 字段存的是 objectName(相对路径),不是完整的 URL。

为啥?因为私有 Bucket 的 URL 是签名 URL,会过期。如果你存了完整 URL,1 小时后 URL 失效,数据库里这条记录就废了。存 objectName 的话,需要时用 generateSignedUrl(objectName) 现场生成一个新 URL,永远有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
flowchart TD
A[上传文件] --> B[OSS存文件]
B --> C[数据库存 objectName]
C --> D[需要访问时]
D --> E[读 objectName]
E --> F[generateSignedUrl 生成签名URL]
F --> G[返回给前端]

style A fill:#10b981,color:#fff
style B fill:#f59e0b,color:#fff
style C fill:#60a5fa,color:#fff
style F fill:#8b5cf6,color:#fff
style G fill:#ef4444,color:#fff

ObjectMetadata 干啥用

1
2
3
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
  • setContentType:设文件的 MIME 类型,比如 image/jpeg。这个很重要,浏览器访问文件时根据这个判断怎么处理。如果 Content-Type 是 application/octet-stream(默认),浏览器会直接下载,而不是预览图片。
  • setContentLength:设文件大小。OSS 上传时需要知道传多少字节,设上能避免一些边界问题。

try-with-resources 关闭流

1
2
3
try (InputStream inputStream = file.getInputStream()) {
...
}

MultipartFile.getInputStream() 拿到的流用完必须关。try-with-resources 语法会在 try 块结束时自动关闭流,比手动 finally 关干净。Java 7+ 都支持。


4.5 签名 URL 的原理(重点)

这块是私有 Bucket 对接的核心,讲透。

为啥需要签名 URL

私有 Bucket 的文件,你直接用 https://bucket.oss-cn-hangzhou.aliyuncs.com/image/xxx.jpg 访问,OSS 返回 403 Forbidden。因为 OSS 不知道你是谁,默认拒绝。

要让 OSS 认你,有两种方式:

  1. 请求头带签名:每次请求用 AccessKeySecret 算签名放 Header 里。适合后端直接调 OSS API。
  2. URL 带签名:把签名拼到 URL 的查询参数里,前端拿到 URL 直接 GET 就能访问。适合给前端用。

我们这里是给前端返回文件 URL,所以用方式2:签名 URL。

签名 URL 长啥样

1
2
3
4
https://friendchat-wange.oss-cn-hangzhou.aliyuncs.com/image/2026/08/02/uuid.jpg
?OSSAccessKeyId=LTAI4Gxxx
&Expires=1722567600
&Signature=ab3kjsd9f8%2Bxxx%3D

三个查询参数:

  • OSSAccessKeyId:你的 AccessKeyId,告诉 OSS 你是谁
  • Expires:过期时间(Unix 时间戳,秒),过了这个时间 URL 失效
  • Signature:签名,用 AccessKeySecret 算出来的

签名怎么算的

签名算法是 HMAC-SHA1,大致流程:

1
2
3
4
5
6
7
8
9
10
flowchart LR
A[构造待签名字符串] --> B[用 AccessKeySecret 做 HMAC-SHA1]
B --> C[Base64 编码]
C --> D[URL 编码]
D --> E[得到 Signature]

style A fill:#f59e0b,color:#fff
style B fill:#ef4444,color:#fff
style C fill:#8b5cf6,color:#fff
style E fill:#10b981,color:#fff

待签名字符串长这样(几个换行符拼接):

1
GET\n\n\n1722567600\n/friendchat-wange/image/2026/08/02/uuid.jpg

分别是:HTTP 方法、Content-MD5、Content-Type、Expires、资源路径。

重点理解:签名 URL 的安全性在于——Signature 是用 AccessKeySecret 算的,但 AccessKeySecret 从不出现在 URL 里。OSS 服务端收到请求,用它存的你的 AccessKeySecret,按同样算法算一遍签名,和你传的 Signature 对比,一致就放行。所以即使签名 URL 泄露,别人也推不出你的 AccessKeySecret,顶多在过期前能访问这个文件。

而且:签名 URL 一旦生成,后端就不参与访问了。前端直接 GET 这个 URL,OSS 直接返回文件,不经过你的后端。这就是对象存储省带宽的原理。

generatePresignedUrl 方法

1
2
3
4
5
Date expiration = new Date(System.currentTimeMillis() + ossConfig.getSignedUrlExpire() * 1000L);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
ossConfig.getBucketName(), objectName, com.aliyun.oss.HttpMethod.GET);
request.setExpiration(expiration);
URL url = ossClient.generatePresignedUrl(request);
  • HttpMethod.GET:生成的是 GET 请求的 URL(下载/访问用)。如果要前端直传(PUT),就用 HttpMethod.PUT。
  • setExpiration:设过期时间。
  • 返回的 URL 对象 toString() 就是完整的签名 URL。

注意: ossConfig.getSignedUrlExpire() * 1000L 这里有个细节——必须乘 1000L 而不是 1000。因为 signedUrlExpire 是 long,乘 1000L 还是 long;如果乘 1000(int),在 long * int 时会自动提升,没问题,但养成加 L 的习惯,避免 int 溢出坑。


4.6 写 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package top.wange.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import top.wange.common.Result;
import top.wange.entity.MediaFile;
import top.wange.mapper.MediaFileMapper;
import top.wange.service.FileService;
import top.wange.util.JwtUtil;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/upload")
public class FileController {

@Autowired
private FileService fileService;

@Autowired
private JwtUtil jwtUtil;

@Autowired
private MediaFileMapper mediaFileMapper;

/**
* 上传文件
*/
@PostMapping
public Result<Map<String, Object>> uploadFile(
@RequestHeader("Authorization") String token,
@RequestParam("file") MultipartFile file,
@RequestParam("fileType") String fileType) {

if (!fileType.matches("^(IMAGE|FILE|VOICE|VIDEO)$")) {
return Result.error("文件类型必须是 IMAGE/FILE/VOICE/VIDEO 之一");
}

String actualToken = token.replace("Bearer ", "");
Integer userId = jwtUtil.getUserIdFromToken(actualToken);

MediaFile mediaFile = fileService.uploadFile(file, userId, fileType);
// 上传完,生成签名URL返回给前端
String fileUrl = fileService.generateSignedUrl(mediaFile.getFilePath());

Map<String, Object> result = new HashMap<>();
result.put("id", mediaFile.getId());
result.put("fileName", mediaFile.getFileName());
result.put("fileSize", mediaFile.getFileSize());
result.put("fileType", mediaFile.getFileType());
result.put("uploadTime", mediaFile.getUploadTime());
result.put("url", fileUrl);
return Result.success(result);
}

/**
* 根据文件ID获取签名URL
* 历史消息里的文件URL过期后,前端用这个接口换新的
*/
@GetMapping("/url/{id}")
public Result<Map<String, String>> getSignedUrl(@PathVariable Integer id) {
MediaFile mediaFile = mediaFileMapper.selectById(id);
if (mediaFile == null) {
return Result.error("文件不存在");
}
String url = fileService.generateSignedUrl(mediaFile.getFilePath());
Map<String, String> result = new HashMap<>();
result.put("url", url);
return Result.success(result);
}
}

注意那个 /upload/url/{id} 接口,这是私有 Bucket 方案必备的。因为签名 URL 会过期,前端拿到一个图片 URL,1 小时后显示不出来,得有个接口重新换。前端检测到图片加载失败,就调这个接口拿新 URL。


五、私有 Bucket 的”坑”:头像 URL 会过期

这块是我迁移时踩的坑,文档里不写,但你不处理就完蛋。

问题

IM 项目里到处都有头像:

  • 用户自己的头像
  • 好友列表里每个好友的头像
  • 群头像
  • 会话列表里对方的头像

之前用 MinIO(公共读),头像 URL 存数据库,直接能访问,简单粗暴。换成私有 Bucket 后,数据库里存的如果是签名 URL,1 小时后所有头像全裂。

解决方案

数据库存 objectName,返回给前端前转签名 URL。

用户表 avatar_url 字段存的是 objectName(比如 image/2026/08/02/uuid.jpg),不是完整 URL。每次查询用户信息返回前,把 objectName 转成签名 URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 把User的avatarUrl从objectName转换成签名URL
* 兼容旧数据:如果avatarUrl以http开头(MinIO时代的完整URL),原样返回
*/
private void fillAvatarUrl(User user) {
if (user == null || user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) {
return;
}
String avatarUrl = user.getAvatarUrl();
if (avatarUrl.startsWith("http")) {
return; // 旧数据,已经是完整URL,不动
}
user.setAvatarUrl(fileService.generateSignedUrl(avatarUrl));
}

哪些地方要改

我项目里凡是返回头像给前端的地方都要改:

文件 改动点
UserServiceImpl getUserById、getUserByUsername、getOnlineUsers、searchUsers、login、updateUserProfile 返回前调 fillAvatarUrl
FriendServiceImpl convertToFriendInfo、convertToPendingRequestInfo 里头像转签名 URL
MessageServiceImpl 会话列表里群头像、对方头像、好友头像转签名 URL
UserInfo(Controller) 上传头像时数据库存 objectName,返回前端用签名 URL

有个统一的小工具方法,在每个 Service 里加一个:

1
2
3
4
5
6
private String toSignedUrl(String url) {
if (url == null || url.isEmpty() || url.startsWith("http")) {
return url; // null、空、或已经是完整URL的,原样返回
}
return fileService.generateSignedUrl(url);
}

url.startsWith("http") 这个判断是兼容旧数据——MinIO 时代存的完整 URL(http 开头)原样返回,新数据(objectName)才转换。这样迁移时不用清洗历史数据。

上传头像时存什么

1
2
3
4
5
6
7
8
// UserInfo.java 的上传头像接口
MediaFile mediaFile = fileService.uploadFile(file, userId, "IMAGE");

// 数据库存 objectName(不是签名URL!)
userService.updateUserProfile(userId, null, null, mediaFile.getFilePath(), null);

// 返回给前端用签名URL(立即可用)
String avatarUrl = fileService.generateSignedUrl(mediaFile.getFilePath());

数据库存 objectName,前端拿签名 URL。这个区别一定要记住。


六、AccessKey 安全:别作死

这块必须单独说,太多人栽在这。

不要做的事

  1. 不要用主账号 AccessKey。主账号 AK 泄露 = 你整个阿里云账号被接管,人家能开机器、能删数据库、能扣你钱。
  2. 不要把 AccessKeySecret 提交到 Git。即使是私有仓库也不行,扫描器会扫 GitHub 上的 AK。
  3. 不要把 AK 写死在代码里。改一下要重新编译,多蠢。

正确做法

1. 用 RAM 子账号

前面开通那步已经讲了,建个 RAM 用户,只给 OSS 权限。这样即使泄露,也只能动 OSS,损失可控。

更细粒度:用 RAM 自定义策略,只允许操作某个 Bucket,不允许删除。这样更安全。

2. 用环境变量

application.yml 里用环境变量占位:

1
2
3
4
5
6
oss:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: ${OSS_ACCESS_KEY_ID}
access-key-secret: ${OSS_ACCESS_KEY_SECRET}
bucket-name: friendchat-wange
signed-url-expire: 3600

${OSS_ACCESS_KEY_ID} 的意思是:从环境变量 OSS_ACCESS_KEY_ID 读值。环境变量没设时,启动会报错(或者你可以给默认值 ${OSS_ACCESS_KEY_ID:默认值},但别这么干)。

部署时,环境变量在服务器上配(export OSS_ACCESS_KEY_ID=xxx),或者 Docker 用 -e,或者 K8s 用 Secret。

本地开发,可以在 IDEA 的运行配置里加 Environment Variables,或者在系统环境变量里加。

3. 开发环境用单独的子账号

给开发环境单独建个 RAM 子账号,只给测试 Bucket 的权限。生产环境另一个子账号,只有运维知道 Secret。这样开发人员拿不到生产 AK。


七、怎么测试

用 Postman 测上传

  1. 启动 Spring Boot 应用
  2. Postman 发 POST 请求 http://localhost:10086/api/upload
  3. Headers 加 Authorization: Bearer <你的token>
  4. Body 选 form-data,加两个:
    • file:类型选 File,选个图片
    • fileType:填 IMAGE
  5. 发请求

成功的话返回:

1
2
3
4
5
6
7
8
9
10
11
{
"code": 200,
"data": {
"id": 1,
"fileName": "test.jpg",
"fileSize": 102400,
"fileType": "IMAGE",
"uploadTime": "2026-08-02T10:00:00",
"url": "https://friendchat-wange.oss-cn-hangzhou.aliyuncs.com/image/2026/08/02/uuid.jpg?OSSAccessKeyId=...&Expires=...&Signature=..."
}
}

把 url 复制到浏览器,能打开图片就成功了。

用 curl 测

1
2
3
4
curl -X POST http://localhost:10086/api/upload \
-H "Authorization: Bearer your_token" \
-F "file=@/path/to/test.jpg" \
-F "fileType=IMAGE"

验证签名 URL 过期

把 signed-url-expire 改成 60(60秒),重启,上传个文件拿到 URL,等 1 分钟再访问,应该返回 403 Forbidden(或者 404,看 OSS 版本)。说明过期机制生效。


八、常见报错和解决

1. com.aliyun.oss.OSSException: The Access Key Id you provided does not exist

原因:AccessKeyId 写错了,或者 RAM 用户被删了。
解决:去 RAM 控制台确认 AccessKeyId 对不对。

2. OSSException: SignatureDoesNotMatch

原因:AccessKeySecret 写错了。签名算出来和服务端不一致。
解决:重新去 RAM 复制 Secret。注意 Secret 复制时别多空格。

3. OSSException: AccessDenied. You have no right to access

原因:RAM 子账号没给 OSS 权限,或者 Bucket 读写策略限制。
解决:

  • 给 RAM 用户加 AliyunOSSFullAccess 权限
  • 或者 Bucket 没创建好,去控制台确认 Bucket 名对不对

4. java.net.UnknownHostException: oss-cn-hangzhou.aliyuncs.com

原因:endpoint 写错了,或者网络不通。
解决:

  • 确认 endpoint 拼写对(oss-cn-hangzhou.aliyuncs.com,不带 http://)
  • 本地能不能 ping 通
  • 公司网络可能拦了

5. 上传成功但签名 URL 访问 403

原因:Bucket 是私有的,但签名 URL 算错了,或者 objectName 对不上。
解决:

  • 确认 generatePresignedUrl 传的 objectName 和 putObject 的一致
  • 确认 Bucket 名称对
  • 看 OSS 控制台,文件是不是真传上去了

6. 上传图片后浏览器直接下载,不预览

原因:Content-Type 设错了,或者没设(默认 application/octet-stream)。
解决:确保 metadata.setContentType(file.getContentType()) 这行有,而且前端传的文件 MultipartFile 带了正确的 Content-Type。

7. Maven 下载不下来 aliyun-sdk-oss

原因:阿里云 SDK 在 Maven 中央仓库,但国内访问慢。
解决:pom.xml 里加阿里云镜像:

1
2
3
4
5
6
<repositories>
<repository>
<id>aliyun</id>
<url>https://maven.aliyun.com/repository/public</url>
</repository>
</repositories>

或者配 settings.xml 镜像(更通用)。


九、省钱和优化小技巧

1. 用内网 Endpoint 省流量费

这个前面强调了,生产环境后端部署在阿里云 ECS 上,一定要用内网 Endpoint(oss-cn-hangzhou-internal.aliyuncs.com)。

OSS 流量费分两种:

  • 公网流量:贵,0.5 元/GB 左右
  • 内网流量:免费

后端走内网传文件到 OSS 不花钱。前端访问签名 URL 是走公网(OSS 直连前端),这部分钱省不了,但后端→OSS 这段省了。

2. 配合 CDN 加速

如果你的用户全国都有,OSS 单地域访问慢。可以在 OSS 前面挂个 CDN:

  • OSS 当源站
  • CDN 回源到 OSS
  • 用户访问 CDN 节点

CDN 流量比 OSS 公网流量便宜,而且全国节点加速。但这要改 URL 生成逻辑,签名方式也变(CDN 有自己的鉴权方式),这里不展开。

3. 生命周期规则

OSS 可以配生命周期规则,自动转储或删除旧文件。比如:

  • 30 天前的文件转低频访问存储(便宜)
  • 180 天前的转归档存储(更便宜)
  • 365 天前的自动删除

适合日志、临时文件。IM 聊天文件一般不删,可以不配。

4. 防盗链

OSS Bucket 可以配 Referer 白名单,只允许从你的域名访问。防止别人盗用你的图片链接。但和签名 URL 一起用要注意,签名 URL 是 GET 请求,Referer 防盗链能加一层保护。

5. 分片上传

大于 5MB 的文件,用分片上传(MultipartUpload)更稳:网络断了能续传,大文件不会因为超时失败。SDK 有 UploadFileRequest 封装好了。我项目里视频最大 50MB,普通上传够用,没做分片。如果你要传几百 M 的,一定要分片。


十、整个流程串起来

最后用一张图把整个流程串一遍,你脑子里有这个图就全懂了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sequenceDiagram
participant 前端
participant 后端
participant OSS
participant DB

Note over 前端,OSS: 上传文件
前端->>后端: POST /upload (带token+文件)
后端->>后端: 校验文件类型/大小
后端->>OSS: putObject(bucket, objectName, 文件流)
OSS-->>后端: 上传成功
后端->>DB: insert media_files (存objectName)
后端->>OSS: generatePresignedUrl(objectName)
OSS-->>后端: 返回签名URL
后端-->>前端: 返回文件信息+签名URL

Note over 前端,OSS: 前端访问文件
前端->>OSS: GET 签名URL (直接访问,不经过后端)
OSS-->>前端: 返回文件内容

Note over 前端,OSS: URL过期后重新获取
前端->>后端: GET /upload/url/{id}
后端->>DB: select media_files by id
后端->>OSS: generatePresignedUrl(objectName)
OSS-->>后端: 新的签名URL
后端-->>前端: 返回新URL

写在最后

对接 OSS 本身不难,难的是把整个链路想清楚:

  1. 存储:objectName 怎么设计,数据库存什么
  2. 访问:私有 Bucket 用签名 URL,URL 会过期怎么办
  3. 安全:AccessKey 怎么管,别用主账号
  4. 成本:内网 Endpoint 省钱,CDN 加速

这篇讲的是服务端直传(前端→后端→OSS),简单但占后端带宽。如果你的文件很大(比如几百 M 视频),建议改客户端直传:前端拿 STS 临时凭证直接传 OSS,后端只发凭证不传文件。那个方案更复杂,等你有需要再研究。

代码我项目里都跑通了,对着抄就行。有不清楚的地方多看几遍原理部分,理解了原理,换个云厂商(腾讯云 COS、七牛)也是一样的套路。

—— 完