苍穹外卖跟练项目:阿里云 OSS 文件上传完整开发指南
技术栈 :Spring Boot 3.4.4 + 阿里云 OSS SDK 3.17.4 + Maven 多模块
前置依赖 :员工管理模块(JWT 认证、全局异常处理已就绪)
适用场景:菜品图片上传、套餐图片上传、店铺Logo等所有需要文件存储的场景
目录
- 一、业务背景与架构设计
- 二、接口总览
- [三、阿里云 OSS 准备工作](#三、阿里云 OSS 准备工作)
- 四、配置文件
- [五、核心工具类 AliOssUtil](#五、核心工具类 AliOssUtil)
- [六、Controller 层](#六、Controller 层)
- 七、完整数据流解析
- 八、接口测试示例
- 九、踩坑记录与注意事项
- 十、后续复用场景
一、业务背景与架构设计
为什么需要 OSS?
苍穹外卖中菜品图片、套餐图片等文件需要持久化存储。直接存服务器本地磁盘存在以下问题:
| 问题 | 说明 |
|---|---|
| 服务器磁盘空间有限 | 图片占用空间大,长期积累会撑爆 |
| 部署多台机器时文件不同步 | A 机上传了,B 机访问不到 |
| 无法直接被浏览器访问 | 需要额外写下载接口 |
| 备份/迁移麻烦 | 换服务器要搬全部文件 |
架构图
前端(Vue3/小程序)
│
│ POST /admin/common/upload (multipart/form-data)
│ file: 图片文件
▼
┌─────────────────────────────┐
│ CommonController │
│ 接收 MultipartFile │
│ → 调用 AliOssUtil.upload()│
└──────────┬──────────────────┘
│
│ byte[] + UUID文件名
▼
┌─────────────────────────────┐
│ AliOssUtil (工具类) │
│ 构建 OSSClient │
│ → ossClient.putObject() │
│ → 返回 URL 字符串 │
└──────────┬──────────────────┘
│
│ HTTPS URL
▼
┌─────────────────────────────┐
│ 阿里云 OSS │
│ Bucket: dddbucket │
│ Region: 北京 │
│ 文件公开可读 │
└─────────────────────────────┘
二、接口总览
| 序号 | 请求方式 | 接口路径 | 功能说明 | Content-Type |
|---|---|---|---|---|
| 1 | POST | /admin/common/upload |
通用文件上传(图片/文档等) | multipart/form-data |
⚠️ 注意:这是一个通用接口,所有需要上传文件的模块(菜品、套餐等)都复用这一个接口。
三、阿里云 OSS 准备工作
3.1 注册与开通
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 登录 阿里云官网 | 注册账号并完成实名认证 |
| 2 | 开通 OSS 服务 | 搜索"对象存储OSS",点击开通(按量付费,新用户有免费额度) |
| 3 | 创建 Bucket | 进入 OSS 控制台 → 创建 Bucket → 选择地域、设置权限 |
3.2 Bucket 关键设置
创建 Bucket 时注意以下配置:
| 配置项 | 建议值 | 原因 |
|---|---|---|
| Bucket 名称 | 全局唯一(如 dddbucket) |
用于拼接访问 URL |
| 地域 | 选择离用户近的区域(如北京) | 影响上传/下载速度 |
| 读写权限 | 公共读 | 上传后 URL 可直接在浏览器打开 |
| 版本控制 | 不开启 | 简单场景不需要 |
💡 为什么选"公共读"?
因为返回给前端的 URL 需要能直接被
<img src="...">访问。如果设为私有,每次访问都需要签名,增加复杂度。
3.3 获取 AccessKey
| 步骤 | 操作路径 |
|---|---|
| 1 | 进入 RAM 控制台 |
| 2 | 创建 AccessKey(或使用已有的) |
| 3 | 保存 AccessKey ID 和 AccessKey Secret |
⚠️ 安全建议 :不要使用主账号的 AccessKey!建议创建 RAM 子账号,仅授予
AliyunOSSFullAccess权限。
四、配置文件
application.yml(关键配置)
yaml
sky:
jwt:
admin-secret-key: itcast_sky_take_out_2026_secret_key_1234567890abcdef
admin-ttl: 7200000
admin-token-name: token
# ====== 阿里云 OSS 配置 ======
oss:
endpoint: oss-cn-beijing.aliyuncs.com # OSS 服务端点(含地域)
bucket-name: dddbucket # Bucket 名称(全局唯一)
access-key-id: LTAI5tAphPRow2yWbxBwvP9L # AccessKey ID
access-key-secret: 6YORxuksSDSwd9N64Z8yicOiObNemV # AccessKey Secret
配置项说明
| 配置项 | 示例值 | 用途 |
|---|---|---|
endpoint |
oss-cn-beijing.aliyuncs.com |
OSS 服务地址,决定数据存储在哪个区域 |
bucket-name |
dddbucket |
存储桶名称,会出现在访问 URL 中 |
access-key-id |
LTAI5t... |
身份认证 ID |
access-key-secret |
6YORx... |
身份认证密钥(⚠️ 不可泄露) |
Maven 依赖(pom.xml)
xml
<!-- 阿里云 OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<!-- Spring Boot Configuration Processor(支持 @ConfigurationProperties 提示)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
五、核心工具类 AliOssUtil
完整代码
java
package com.sky.utils;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
/**
* 阿里云 OSS 文件上传工具类
*
* 核心职责:
* 1. 封装阿里云 SDK 的客户端构建与连接管理
* 2. 提供 upload() 方法供所有 Controller 调用
* 3. 自动生成 UUID 文件名,防止文件名冲突和中文乱码
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Component
@ConfigurationProperties(prefix = "sky.oss") // ← 关键注解!将 yml 配置注入到字段
public class AliOssUtil {
private String endpoint; // OSS 端点
private String accessKeyId; // AccessKey ID
private String accessKeySecret; // AccessKey Secret
private String bucketName; // Bucket 名称
/**
* 文件上传
*
* @param bytes 文件字节数组(从 MultipartFile.getBytes() 获取)
* @param objectName 上传后的文件名(含路径前缀,如 "2024/06/01/uuid.png")
* @return 文件在 OSS 中的完整可访问 URL
*/
public String upload(byte[] bytes, String objectName) {
// ========== 第1步:构建 OSS 客户端 ==========
// 使用 Builder 模式创建客户端实例
OSS ossClient = new OSSClientBuilder()
.build(endpoint, accessKeyId, accessKeySecret);
try {
// ========== 第2步:执行上传 ==========
// 将字节数组包装成输入流,上传到指定 Bucket 的指定路径
InputStream inputStream = new ByteArrayInputStream(bytes);
ossClient.putObject(bucketName, objectName, inputStream);
// ========== 第3步:拼接待返回的访问 URL ==========
// 格式:https://{bucketName}.{endpoint}/{objectName}
return "https://" + bucketName + "." + endpoint + "/" + objectName;
} catch (OSSException oe) {
// OSS 服务端异常(如 Bucket 不存在、权限不足)
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message: " + oe.getErrorMessage());
System.out.println("Error Code: " + oe.getErrorCode());
System.out.println("Request ID: " + oe.getRequestId());
System.out.println("Host ID: " + oe.getHostId());
throw new RuntimeException("OSS 上传失败:" + oe.getErrorMessage());
} catch (ClientException ce) {
// 客户端异常(如网络不通、参数非法)
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message: " + ce.getMessage());
throw new RuntimeException("OSS 客户端异常:" + ce.getMessage());
} finally {
// ========== 第4步:关闭客户端(必须!)==========
// OSSClient 内部维护了 HTTP 连接池,不关闭会导致连接泄漏
if (ossClient != null) {
ossClient.shutdown();
}
}
}
}
关键注解解析
| 注解 | 作用 | 必要性 |
|---|---|---|
@Component |
将类注册为 Spring Bean,支持 @Autowired 注入 |
✅ 必须 |
@ConfigurationProperties(prefix = "sky.oss") |
将 YAML 中 sky.oss.* 的值自动绑定到同名字段 |
✅ 必须(漏掉会导致字段全为null) |
@Data |
Lombok 自动生成 getter/setter | ✅ 必须(配置绑定依赖 setter) |
@NoArgsConstructor / @AllArgsConstructor |
Lombok 生成构造器 | ✅ Spring 反射需要无参构造 |
URL 拼接规则详解
配置值:
bucketName = dddbucket
endpoint = oss-cn-beijing.aliyuncs.com
objectName = 6540ff10-7ae2-40c3-9c49-58fe10908706.xlsx
拼接结果:
https://dddbucket.oss-cn-beijing.aliyuncs.com/6540ff10-7ae2-40c3-9c49-58fe10908706.xlsx
├────────┤├─────────────────────────┤├──────────────────────────────┤
bucket名 . endpoint 文件路径
六、Controller 层
完整代码
java
package com.sky.controller.admin;
import com.sky.result.Result;
import com.sky.utils.AliOssUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.UUID;
/**
* 通用接口控制器
*
* 包含文件上传等通用功能,不隶属于特定业务模块。
* 所有 /admin/** 路径均受 JWT 拦截器保护。
*/
@RestController
@RequestMapping("/admin/common")
@Slf4j
@Api(tags = "通用接口")
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;
/**
* 文件上传
*
* @param file 上传的文件(表单字段名必须为 "file")
* @return OSS 文件访问 URL
*/
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file) {
log.info("文件上传:{}", file.getOriginalFilename());
try {
// 1. 校验文件是否为空
if (file == null || file.isEmpty()) {
return Result.error("上传文件不能为空");
}
// 2. 获取原始文件扩展名(如 .png / .jpg / .xlsx)
String originalFilename = file.getOriginalFilename(); // 例:"模块备份 9 (1).png"
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); // ".png"
// 3. 生成 UUID 新文件名(防止重名覆盖 & 解决中文乱码问题)
// 例:6540ff10-7ae2-40c3-9c49-58fe10908706.png
String objectName = UUID.randomUUID().toString() + extension;
// 4. 调用工具类上传到 OSS
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
// 5. 返回可访问的 URL 给前端
return Result.success(filePath);
} catch (Exception e) {
log.error("文件上传失败", e);
return Result.error("文件上传失败:" + e.getMessage());
}
}
}
请求与响应格式
请求:
http
POST http://localhost:8080/admin/common/upload
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
token: eyJhbGciOiJIUzI1NiJ9...
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="测试图片.png"
Content-Type: image/png
<二进制文件数据>
------WebKitFormBoundary--
成功响应:
json
{
"code": 1,
"msg": "success",
"data": "https://dddbucket.oss-cn-beijing.aliyuncs.com/6540ff10-7ae2-40c3-9c49-58fe10908706.png"
}
失败响应:
json
{
"code": 0,
"msg": "上传文件不能为空",
"data": null
}
七、完整数据流解析
从前端到 OSS 的完整链路
┌──────────┐ ┌──────────────────┐ ┌────────────────┐ ┌───────────┐
│ 前端页面 │ ──→ │ CommonController │ ──→ │ AliOssUtil │ ──→ │ 阿里云 OSS │
│ 选择文件 │ │ │ │ │ │ │
└──────────┘ └──────────────────┘ └────────────────┘ └───────────┘
步骤详情:
① 前端选择文件,构造 multipart/form-data 请求
↓
② Spring 接收 MultipartFile(字段名 "file")
↓
③ 获取原始文件名 → 截取后缀名(.png)
↓
④ 生成 UUID 作为新文件名 → 避免中文乱码和文件名冲突
↓
⑤ 调用 MultipartFile.getBytes() 转为字节数组
↓
⑥ AliOssUtil 构建 OSSClient(使用 yml 中的 AK/SK)
↓
⑦ 将字节数组写入 InputStream 并调用 putObject()
↓
⑧ 拼接 URL:https://{bucket}.{endpoint}/{uuid+ext}
↓
⑨ 通过 Result.success(url) 返回给前端
↓
⑩ 前端拿到 URL,存入数据库的 image 字段(后续菜品/套餐新增时)
UUID 重命名的作用
| 问题 | 不使用 UUID | 使用 UUID |
|---|---|---|
| 中文乱码 | ❌ "宫保鸡丁.png" 在 URL 中可能编码异常 | ✅ a1b2c3d4.png 纯英文 |
| 文件覆盖 | ❌ 两张图同名会互相覆盖 | ✅ 全球唯一,不会冲突 |
| 安全性 | ❌ 暴露原始文件名信息 | ✅ 无法猜测文件路径 |
八、接口测试示例
测试前置条件
- 项目已启动(端口 8080)
- 已通过登录接口获取有效 JWT Token
- 阿里云 OSS 已正确配置(endpoint、bucket、AK/SK)
- Header 中携带 token
8.1 使用 Postman / Apifox 测试
操作步骤:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 选择 POST 方法 | 请求方式 |
| 2 | 输入 URL | http://localhost:8080/admin/common/upload |
| 3 | 设置 Header | token: <你的JWT> |
| 4 | Body 选 form-data | 切换到表单模式 |
| 5 | 添加 Key=file,类型选 File |
字段名必须是 file |
| 6 | Value 处选择本地文件 | 点击上传任意图片 |
| 7 | 点击 Send 发送请求 | --- |
成功响应:
json
{
"code": 1,
"msg": "success",
"data": "https://dddbucket.oss-cn-beijing.aliyuncs.com/6540ff10-7ae2-40c3-9c49-58fe10908706.xlsx"
}
8.2 使用 Swagger UI 测试
访问 http://localhost:8080/doc.html( Knife4j),找到「通用接口」→ 「文件上传」,在线上传测试。
8.3 验证上传结果
在浏览器中直接访问返回的 URL,确认可以看到上传的文件。
九、踩坑记录与注意事项
🔴 P0 --- 必须解决
| # | 问题现象 | 错误信息 | 原因分析 | 解决方案 |
|---|---|---|---|---|
| 1 | MultipartFile 为 null | NullPointerException: Cannot invoke getOriginalFilename() because "file" is null` |
前端请求未使用 multipart/form-data 格式,或字段名不是 file |
① 前端确保用 FormData 上传;② 字段名必须匹配 @RequestParam("file") 或方法参数名 |
| 2 | AccessKey id should not be null | InvalidCredentialsException: Access key id should not be null or empty. |
AliOssUtil 缺少 @ConfigurationProperties(prefix = "sky.oss") 注解,导致 4 个配置字段全部为 null |
加上该注解 !光有 @Component 不够,它不会自动读取 yml 配置 |
🟡 P1 --- 功能影响
| # | 问题现象 | 原因 | 解决方案 |
|---|---|---|---|
| 3 | OSSClient 连接泄漏 | 高并发下频繁上传导致服务变慢甚至 OOM | 每次 build() 创建的新客户端必须在 finally 中调用 shutdown() |
| 4 | Bucket 权限问题 | 上传成功但浏览器访问 URL 返回 403 | Bucket 默认是私有的 |
| 5 | Endpoint 格式错误 | UnknownHostException |
endpoint 写成了 https://oss-cn-beijing.aliyuncs.com(带了协议头) |
🟢 P2 --- 规范建议
| # | 建议 | 说明 |
|---|---|---|
| 6 | 文件大小限制 | Spring Boot 默认限制单文件 1MB、总请求 10MB;可在 yml 中调大:spring.servlet.multipart.max-file-size=10MB |
| 7 | 文件类型校验 | 当前实现允许上传任何类型;生产环境应校验 MIME 类型(如只允许 image/*) |
| 8 | AccessKey 安全 | 绝对不要把 AK/SK 写死在代码里或提交到 Git!必须放在配置文件或环境变量中 |
| 9 | UUID + 原始扩展名组合 | 不要丢弃原始扩展名!浏览器根据扩展名判断如何渲染(.jpg vs .txt 表现完全不同) |
| 10 | OSSClient 是否应该做成单例? | 当前每次上传都 build 一个新客户端,性能略差但简单安全。高并发场景可用单例 + 连接池优化,初学阶段不建议过早优化 |
十、后续复用场景
本模块完成后,以下模块将直接复用 /admin/common/upload 接口:
| 模块 | 用途说明 | 复用方式 |
|---|---|---|
| 菜品管理 | 新增/编辑菜品时上传菜品图片 | 先调上传接口拿 URL → 再把 URL 存入 dish.image 字段 |
| 套餐管理 | 新增/编辑套餐时上传套餐图片 | 同上,URL 存入 setmeal.image 字段 |
| 店铺设置 | 店铺 Logo 等(如有) | 同理 |
典型前端交互流程(以新增菜品为例)
① 用户在表单中选择图片文件
↓
② 前端先调用 POST /admin/common/upload
↓
③ 后端返回 URL:https://dddbucket.oss-cn-beijing.aliyuncs.com/xxx.png
↓
④ 前端将 URL 填入表单的 image 字段
↓
⑤ 用户填写其他菜品信息(名称、价格、分类等),点击提交
↓
⑥ 前端调用 POST /admin/dish,JSON body 中包含 image: "https://..."
↓
⑦ 后端将完整数据写入 dish 数据库表
↓
⑧ 前端展示菜品列表时,<img :src="dish.image"> 直接从 OSS 加载图片
💡 核心思路:上传接口只负责"把文件放到 OSS 并返回 URL",具体这个 URL 怎么用、存到哪张表,由各个业务模块自己决定。
十一、涉及的新增文件清单
| 文件路径 | 所属模块 | 说明 |
|---|---|---|
AliOssUtil.java |
sky-common(utils包) | OSS 工具类,核心上传逻辑 |
CommonController.java sky-server(controller.admin包) |
通用控制器,文件上传入口 | |
application.yml |
sky-server(resources) | 新增 sky.oss.* 配置节 |
pom.xml |
sky-server 或父 pom | 新增 aliyun-sdk-oss 依赖 |
📌 本文基于苍穹外卖项目实战整理,紧接分类管理模块之后。
OSS 是独立的基础能力模块,不依赖任何业务表。掌握后将直接服务于菜品管理和套餐管理模块的开发。