# 苍穹外卖跟练项目:阿里云 OSS 文件上传完整开发指南

苍穹外卖跟练项目:阿里云 OSS 文件上传完整开发指南

技术栈 :Spring Boot 3.4.4 + 阿里云 OSS SDK 3.17.4 + Maven 多模块

前置依赖 :员工管理模块(JWT 认证、全局异常处理已就绪)

适用场景:菜品图片上传、套餐图片上传、店铺Logo等所有需要文件存储的场景


目录


一、业务背景与架构设计

为什么需要 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 IDAccessKey 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 纯英文
文件覆盖 ❌ 两张图同名会互相覆盖 ✅ 全球唯一,不会冲突
安全性 ❌ 暴露原始文件名信息 ✅ 无法猜测文件路径

八、接口测试示例

测试前置条件

  1. 项目已启动(端口 8080)
  2. 已通过登录接口获取有效 JWT Token
  3. 阿里云 OSS 已正确配置(endpoint、bucket、AK/SK)
  4. 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 是独立的基础能力模块,不依赖任何业务表。掌握后将直接服务于菜品管理和套餐管理模块的开发。

相关推荐
阿里云云原生1 小时前
AI Agent 进入生产深水区:如何破解 Token 成本黑洞与排障难题?
人工智能·阿里云·agent·云监控
阿里云云原生2 小时前
AI Agent 规模化生产“黑箱”难拆?阿里云发布全链路可观测方案,实现 Agent 行为透视
人工智能·阿里云·云计算
TG_yunshuguoji2 小时前
腾讯云代理商:腾讯云CloudBase数据库操作全解析
数据库·人工智能·云计算·腾讯云·cloudbase
互联科技报2 小时前
腾讯云代理行业深度拆解:避坑指南与合作选择
云计算·腾讯云
行业研究员3 小时前
2026 Agent Memory方案横评,腾讯云夺冠
云计算·腾讯云·agent记忆
dog2504 小时前
从扩张性看 AWS RNG 为何优于传统胖树
云计算·php·aws
落叶_Jim4 小时前
2026年阿里云腾讯云免费SSL证书限额20张不够用怎么办
阿里云·腾讯云·ssl
元直数字电路验证4 小时前
云计算实验笔记(二):PaaS 与容器化 —— 从 Docker 命令到 Kubernetes 全景图
笔记·云计算·paas
Database_Cool_4 小时前
PB 级海量数据需要实时分析,应该选择什么数仓产品?阿里云 AnalyticDB MySQL 是首选
数据库·数据仓库·mysql·阿里云