[SpringBoot 对象存储实战]:预签名 URL 直传 OSS 全流程设计与实现

🔥你好我是fengxin_rou这是我的个人主页 fengxin_rou的主页

❄️欢迎查看我的专栏我的专栏

《Java后端学习》《JAVASE基础》《JUC并发》《redis》《JVM虚拟机》《MYSQL》《黑马点评》《rabbitmq》《JavaWeb+AI的talis学习系统》《苍穹外卖》

目录

前言

一、预签名直传:核心原理与优势

[1.1 传统上传 vs 预签名直传](#1.1 传统上传 vs 预签名直传)

[1.2 核心优势](#1.2 核心优势)

[1.3 整体流程](#1.3 整体流程)

二、请求实体设计:标准化上传参数

字段说明

[三、OSS 存储服务:核心工具类实现](#三、OSS 存储服务:核心工具类实现)

[3.1 头像上传(普通上传示例)](#3.1 头像上传(普通上传示例))

[3.2 生成公开访问 URL](#3.2 生成公开访问 URL)

[3.3 生成预签名 PUT URL(核心)](#3.3 生成预签名 PUT URL(核心))

[四、Controller 接口实现:权限校验与业务封装](#四、Controller 接口实现:权限校验与业务封装)

[4.1 权限校验关键点](#4.1 权限校验关键点)

[4.2 返回体结构](#4.2 返回体结构)

五、前端直传执行流程

前端请求示例

结语


前言

在内容社区、自媒体平台等场景中,图片、文件上传是高频需求。 传统后端中转上传会占用大量带宽与内存,高并发下极易成为性能瓶颈。 本文基于 SpringBoot + 阿里云 OSS,实现预签名 URL 客户端直传方案,彻底解放后端服务,兼顾安全、高效与易用。


一、预签名直传:核心原理与优势

预签名 URL 是对象存储提供的临时授权机制。 后端用密钥生成带时效、带权限的签名 URL,前端直接用此 URL 上传 / 下载文件,无需透传密钥。

1.1 传统上传 vs 预签名直传

  • 传统上传:客户端→后端→OSS,后端中转流量,压力大、速度慢。
  • 预签名直传:客户端请求签名→后端返回 URL→客户端直传 OSS→通知后端入库。

1.2 核心优势

  1. 服务无压力:后端不处理文件流,仅做授权与记录。
  2. 上传速度快:客户端直连 OSS,带宽不受后端限制。
  3. 安全可控:URL 有时效、有路径、有权限,防止越权与盗传。
  4. 成本更低:减少服务器出口带宽消耗。

1.3 整体流程

  1. 前端请求获取预签名上传 URL。
  2. 后端校验权限、生成路径、返回带签名 URL 与访问 URL。
  3. 前端直接 PUT 上传到 OSS。
  4. 上传完成后通知后端,保存文件 URL 到业务库。

二、请求实体设计:标准化上传参数

统一请求体,明确场景、文件标识、类型,便于权限校验与路径生成。

复制代码
/**
 * 预签名直传请求实体
 */
public record StoragePresignRequest(
    @NotBlank String scene,      // 上传场景:文章内容/图片
    @NotBlank String postId,     // 帖子ID(字符串避免精度丢失)
    @NotBlank String contentType,// 文件类型:image/jpeg、video/mp4
    String ext                   // 文件扩展名
) {}

字段说明

  • scene:区分业务场景,便于权限控制与目录隔离。
  • postId:关联业务 ID,用于校验归属、防止越权。
  • contentType:必须与前端上传时一致,否则 OSS 验签失败。
  • ext:扩展名,用于生成规范文件路径。

三、OSS 存储服务:核心工具类实现

封装 OSS 基础操作,包括普通上传、生成公开 URL、生成预签名 URL

3.1 头像上传(普通上传示例)

适用于小文件、后端可承接的简单上传场景。

复制代码
/**
 * 头像上传到 OSS
 */
public String uploadAvatar(long userId, MultipartFile file) {
    // 1.校验 OSS 配置
    ensureConfigured();
    // 2.提取文件扩展名
    String original = file.getOriginalFilename();
    String ext = "";
    if (original != null && original.contains(".")) {
        ext = original.substring(original.lastIndexOf("."));
    }
    // 3.生成唯一文件路径
    String objectKey = props.getFolder() + "/" + userId + "-" + System.currentTimeMillis() + ext;
    // 4.创建 OSS 客户端
    OSS client = new OSSClientBuilder().build(
        props.getEndpoint(),
        props.getAccessKeyId(),
        props.getAccessKeySecret()
    );
    try {
        // 5.流式上传
        PutObjectRequest request = new PutObjectRequest(
            props.getBucket(),
            objectKey,
            file.getInputStream()
        );
        client.putObject(request);
    } catch (IOException e) {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "文件读取失败");
    } finally {
        client.shutdown();
    }
    // 6.返回可访问 URL
    return publicUrl(objectKey);
}

3.2 生成公开访问 URL

拼接 CDN 域名,规范路径,避免双斜杠问题。

复制代码
/**
 * 生成公开访问 URL
 */
private String publicUrl(String objectKey) {
    if (props.getPublicDomain() != null && !props.getPublicDomain().isBlank()) {
        return props.getPublicDomain().replaceAll("/$", "") + "/" + objectKey;
    }
    return "https://" + props.getBucket() + "." + props.getEndpoint() + "/" + objectKey;
}

3.3 生成预签名 PUT URL(核心)

生成前端可直接 PUT 上传的临时 URL,必须指定 ContentType

复制代码
/**
 * 生成预签名 PUT 上传 URL
 */
public String generatePresignedPutUrl(
    String objectKey,
    String contentType,
    int expiresInSeconds
) {
    ensureConfigured();
    OSS client = new OSSClientBuilder().build(
        props.getEndpoint(),
        props.getAccessKeyId(),
        props.getAccessKeySecret()
    );
    try {
        // 设置过期时间
        Date expiration = new Date(System.currentTimeMillis() + expiresInSeconds * 1000L);
        GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
            props.getBucket(),
            objectKey,
            HttpMethod.PUT
        );
        request.setExpiration(expiration);
        // 必须设置 ContentType,否则前端上传会验签失败
        if (contentType != null && !contentType.isBlank()) {
            request.setContentType(contentType);
        }
        URL url = client.generatePresignedUrl(request);
        return url.toString();
    } finally {
        client.shutdown();
    }
}

四、Controller 接口实现:权限校验与业务封装

提供对外接口,完成用户认证、权限校验、路径生成、签名返回

复制代码
/**
 * 预签名直传接口
 */
@PostMapping("/presign")
public StoragePresignResponse presign(
    @Valid @RequestBody StoragePresignRequest request,
    @AuthenticationPrincipal Jwt jwt
) {
    // 1.获取当前登录用户ID
    long userId = jwtService.extractUserId(jwt);
    // 2.postId 字符串转数字,防止前端精度丢失
    long postId;
    try {
        postId = Long.parseLong(request.postId());
    } catch (NumberFormatException e) {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "postId 格式非法");
    }
    // 3.校验帖子归属,防止越权上传
    KnowPost post = knowPostMapper.findById(postId);
    if (post == null || !userId.equals(post.getCreatorId())) {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "无权限操作该草稿");
    }
    // 4.按场景生成文件存储路径
    String scene = request.scene();
    String ext = normalizeExt(request.ext(), request.contentType(), scene);
    String objectKey;
    if ("knowpost_content".equals(scene)) {
        objectKey = "posts/" + postId + "/content" + ext;
    } else if ("knowpost_image".equals(scene)) {
        String date = DateTimeFormatter.ofPattern("yyyyMMdd")
            .withZone(ZoneId.of("UTC")).format(Instant.now());
        String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
        objectKey = "posts/" + postId + "/images/" + date + "/" + rand + ext;
    } else {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的上传场景");
    }
    // 5.生成10分钟有效期的预签名 URL
    int expiresIn = 600;
    String uploadUrl = ossStorageService.generatePresignedPutUrl(
        objectKey,
        request.contentType(),
        expiresIn
    );
    Map<String, String> headers = Map.of("Content-Type", request.contentType());
    // 6.返回:上传URL、文件URL、请求头、过期时间
    return new StoragePresignResponse(objectKey, uploadUrl, headers, expiresIn);
}

4.1 权限校验关键点

  • 必须校验 postId 对应用户是否为当前登录用户
  • 无校验会导致越权覆盖、恶意上传、数据污染。
  • 这是系统安全的核心防线

4.2 返回体结构

复制代码
{
  "objectKey": "posts/123/images/20260524/xxxx.jpg",
  "uploadUrl": "https://xxx.oss-cn-beijing.aliyuncs.com/...?X-Oss-Signature=xxx",
  "headers": { "Content-Type": "image/jpeg" },
  "expireSeconds": 600
}

五、前端直传执行流程

  1. 调用 /api/v1/storage/presign 获取上传 URL。
  2. 使用 PUT 方法,携带指定 Content-Type 上传文件。
  3. 上传成功后,将 fileUrl 提交给后端业务接口保存。

前端请求示例

复制代码
// 获取预签名
const { uploadUrl, headers } = await api.post("/storage/presign", {
  scene: "knowpost_image",
  postId: "123",
  contentType: "image/jpeg",
  ext: "jpg"
});
// 直传 OSS
await axios.put(uploadUrl, file, { headers });

结语

本文完整实现 SpringBoot + OSS 预签名 URL 直传方案,覆盖请求设计、服务封装、权限校验、接口开放全流程。 该方案大幅降低服务负载,提升上传速度与系统吞吐量,适合文章、图片、视频等大文件上传场景。

实际使用建议:

  1. 预签名有效期控制在 5--10 分钟
  2. 按业务场景做目录隔离。
  3. 接入 CDN 加速文件访问。
  4. 增加文件大小、类型、数量限流防护。
相关推荐
高级c12 小时前
10分钟上手昇腾 NPU 算子开发入门与实战
java·jvm·spring
路远_612 小时前
Java 后端开发者如何理解大模型应用架构
java·架构·大模型·agent
彦为君12 小时前
Spring定时任务开发指南(动态实现)
java·开发语言·后端·python·spring·wpf
架构谨制@涛哥12 小时前
本体从入门到实战-03.为什么AI需要一个本体层?
人工智能·架构·软件工程·软件构建
轻刀快马12 小时前
从底层 CPU 架构看透现代分布式与并发编程
分布式·架构·cpu
高级c12 小时前
MindIE 推理引擎架构解析
深度学习·算法·架构·cann
yeflx13 小时前
点云场景树架构-详细设计
架构
一个数据大开发13 小时前
大模型时代的数据中台架构演进:从数据仓库到认知引擎
数据仓库·架构
小许同学记录成长13 小时前
QGC整体架构与代码目录解析
架构·无人机