Spring Boot 集成华为云 OBS 实现文件上传与预览功能
介绍基于 Spring Boot 和华为云 OBS 的文件管理方案。涵盖配置连接、限制上传类型、上传至私有桶、生成预签名 URL 及后端代理预览接口。提供完整的 Java 代码示例,包括 Controller、Service、Mapper 及配置文件,确保文件存储的安全性与可追溯性。

介绍基于 Spring Boot 和华为云 OBS 的文件管理方案。涵盖配置连接、限制上传类型、上传至私有桶、生成预签名 URL 及后端代理预览接口。提供完整的 Java 代码示例,包括 Controller、Service、Mapper 及配置文件,确保文件存储的安全性与可追溯性。

在现代 Web 应用中,文件上传与访问是常见需求。出于安全性考虑,我们通常将文件存储在私有桶(Private Bucket)中,禁止直接公开访问。此时,需要通过预签名 URL(Presigned URL)机制临时授权用户访问特定文件。
本文将演示如何:
com.hrm.rmhr
├── config
│ └── ObsConfig.java // OBS 配置类
├── controller
│ └── FileController.java // 文件上传、预览、删除接口
├── service
│ ├── ObsService.java // OBS 操作接口
│ └── impl/ObsServiceImpl.java // OBS 服务实现
├── entity
│ └── FileMetadata.java // 文件元数据实体
└── mapper
└── FileMetadataMapper.java // 数据库操作
<dependency>
<groupId>com.huaweicloud</groupId>
<artifactId>esdk-obs-java</artifactId>
<version>3.22.8</version>
</dependency>
file:
obs:
endpoint: https://obs.cn-north-4.myhuaweicloud.com # 替换为你所在区域的 Endpoint
ak: YOUR_ACCESS_KEY_ID # 华为云 AK(生产环境请用环境变量)
sk: YOUR_SECRET_ACCESS_KEY # 华为云 SK
bucket-name: your-bucket-name # OBS 桶名称
storage-root-directory: /files/ # 存储根路径,结尾带斜杠
/**
* OBS 对象存储配置
*/
@Configuration
@ConfigurationProperties(prefix = "file.obs")
@Data
public class ObsConfig {
/**
* OBS endpoint
*/
private String endpoint;
/**
* Access Key
*/
private String ak;
/**
* Secret Key
*/
private String sk;
/**
* Bucket 名称
*/
private String bucketName;
/**
* 存储根目录
*/
private String storageRootDirectory;
}
注意:安全提示:AK/SK 属于敏感信息,建议通过配置中心或环境变量注入,避免硬编码。推荐使用
${OBS_AK}和${OBS_SK}。
private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList("xls", "xlsx", "doc", "docx", "pdf", "jpg", "jpeg", "png", "gif", "bmp", "txt", "csv"));
MultipartFile 获取输入流resume/2025/12/xxx.pdf)上传成功后,立即生成一个 1 小时有效的预签名 URL,供前端直接预览或下载:
{
"code": 200,
"msg": "success",
"data": {
"url": "https://bucket.obs.cn-north-4.myhuaweicloud.com/files/resume/2025/12/xxxx?Expires=...&AccessKeyId=...&Signature=...",
"fileId": "123"
}
}
提供独立接口,支持按需生成不同有效期的访问链接:
@PostMapping("/generatePresignedUrl")
public Result<String> generatePresignedUrl(@RequestParam("fileId") Integer fileId, @RequestParam(value = "expirationSeconds", defaultValue = "3600") int expirationSeconds)
用途:前端在需要时动态获取新链接(如刷新过期链接),避免长期暴露 URL。
虽然前端可直接使用预签名 URL 访问文件,但存在以下问题:
因此,我们提供后端代理式预览接口:
@GetMapping("/preview/{fileId}")
public void previewFile(@PathVariable Integer fileId, HttpServletRequest request, HttpServletResponse response) throws IOException
从数据库读取 contentType(如 application/pdf),确保浏览器正确渲染。
使用 RFC 5987 标准支持中文文件名,兼容新旧浏览器:
private String encodeFileName(String fileName) {
String asciiName = fileName.replaceAll("[^\\x20-\\x7e]", "_");
String utf8Encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
return "inline; filename=" + asciiName + "; filename*=UTF-8''" + utf8Encoded;
}
inline表示在浏览器中预览(非强制下载)filename*支持 UTF-8 编码的文件名
通过 new URL(presignedUrl).openStream() 从 OBS 拉取文件,写入 HttpServletResponse 输出流,实现'透明代理'。
../../../etc/passwd)。storageRootDirectory 可包含 tenantCode。本文完整实现了基于华为云 OBS 的文件上传与安全预览方案,兼顾功能性、安全性与用户体验。核心亮点包括:
适用场景:HR 系统简历上传、OA 附件管理、医疗影像存储、教育资料分发等。
以下是完整、可直接运行的 Spring Boot 项目代码,包含 MySQL 表结构适配、MyBatis Mapper 接口 + XML、华为云 OBS 上传/预览/软删除、Controller 支持上传与预览、配置文件完整。
package org.cskj;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("org.cskj.mapper")
public class CskjApplication {
public static void main(String[] args) {
SpringApplication.run(CskjApplication.class, args);
}
}
package org.cskj.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "file.obs")
@Data
public class ObsConfig {
private String endpoint;
private String ak;
private String sk;
private String bucketName;
private String storageRootDirectory = "/files/";
}
package org.cskj.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FileMetadata {
private Long id;
private String originalFilename;
private String safeFilename;
private String bucketName;
private Long fileSize;
private String contentType;
private String category;
private LocalDateTime uploadTime;
private String uploader;
private Long tenantCode;
private Boolean deleted;
}
package org.cskj.mapper;
import org.apache.ibatis.annotations.Param;
import org.cskj.entity.FileMetadata;
public interface FileMetadataMapper {
int save(FileMetadata record);
FileMetadata selectById(@Param("id") Long id);
void deletedFileMetadata(@Param("id") Long id);
}
注意:id 类型为 Long,匹配 BIGINT
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.cskj.mapper.FileMetadataMapper">
<resultMap id="FileMetadataResultMap" type="org.cskj.entity.FileMetadata">
<id column="id" property="id"/>
<result column="original_filename" property="originalFilename"/>
<result column="safe_filename" property="safeFilename"/>
<result column="bucket_name" property="bucketName"/>
<result column="file_size" property="fileSize"/>
<result column="content_type" property="contentType"/>
<result column="category" =/>
INSERT INTO file_metadata (original_filename, safe_filename, bucket_name, file_size, content_type, category, upload_time, uploader, tenant_code, is_deleted)
VALUES (#{originalFilename}, #{safeFilename}, #{bucketName}, #{fileSize}, #{contentType}, #{category}, NOW(), #{uploader}, #{tenantCode}, 0)
SELECT id, original_filename, safe_filename, bucket_name, file_size, content_type, category, upload_time, uploader, tenant_code, is_deleted
FROM file_metadata WHERE id = #{id} AND is_deleted = 0
UPDATE file_metadata SET is_deleted = 1 WHERE id = #{id} AND is_deleted = 0
package org.cskj.service;
import org.cskj.entity.FileMetadata;
import org.springframework.web.multipart.MultipartFile;
public interface ObsService {
FileMetadata uploadFile(MultipartFile file, String category, String uploader, Long tenantCode) throws Exception;
FileMetadata getFileMetadata(Long fileId);
String generatePresignedUrl(Long fileId, int expirationSeconds) throws Exception;
boolean deleteFile(Long fileId);
}
package org.cskj.service.impl;
import com.obs.services.ObsClient;
import com.obs.services.model.HttpMethodEnum;
import com.obs.services.model.PutObjectResult;
import com.obs.services.model.TemporarySignatureRequest;
import com.obs.services.model.TemporarySignatureResponse;
import org.cskj.config.ObsConfig;
import org.cskj.entity.FileMetadata;
import org.cskj.mapper.FileMetadataMapper;
import org.cskj.service.ObsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import java.io.InputStream;
import java.time.LocalDate;
@Service
public class ObsServiceImpl implements ObsService {
@Autowired
private ObsConfig obsConfig;
@Autowired
private FileMetadataMapper fileMetadataMapper;
private ObsClient obsClient;
private static final Logger log = LoggerFactory.getLogger(ObsServiceImpl.class);
@PostConstruct
public void init() {
this.obsClient = new ObsClient(obsConfig.getAk(), obsConfig.getSk(), obsConfig.getEndpoint());
}
FileMetadata Exception {
file.getOriginalFilename();
(originalFilename == ) originalFilename = ;
buildSafeFilename(category, originalFilename);
obsConfig.getStorageRootDirectory() + safeFilename;
( file.getInputStream()) {
obsClient.putObject(obsConfig.getBucketName(), objectKey, in);
log.info(, objectKey, file.getSize(), result.getEtag());
}
();
metadata.setOriginalFilename(originalFilename);
metadata.setSafeFilename(safeFilename);
metadata.setBucketName(obsConfig.getBucketName());
metadata.setFileSize(file.getSize());
metadata.setContentType(file.getContentType());
metadata.setCategory(category);
metadata.setUploader(uploader);
metadata.setTenantCode(tenantCode);
metadata.setDeleted();
fileMetadataMapper.save(metadata);
metadata;
}
FileMetadata {
fileMetadataMapper.selectById(fileId);
}
String Exception {
getFileMetadata(fileId);
(meta == ) {
();
}
obsConfig.getStorageRootDirectory() + meta.getSafeFilename();
(HttpMethodEnum.GET, expirationSeconds);
request.setBucketName(meta.getBucketName());
request.setObjectKey(objectKey);
obsClient.createTemporarySignature(request);
response.getSignedUrl();
}
{
getFileMetadata(fileId);
(meta == ) ;
{
obsConfig.getStorageRootDirectory() + meta.getSafeFilename();
obsClient.deleteObject(obsConfig.getBucketName(), objectKey);
log.info(, objectKey);
} (Exception e) {
log.error(, e);
;
}
fileMetadataMapper.deletedFileMetadata(fileId);
;
}
String {
originalFilename.replaceAll(, );
String.valueOf(System.currentTimeMillis());
timestamp + + cleanName;
String.valueOf(LocalDate.now().getYear());
String.format(, LocalDate.now().getMonthValue());
(category != && !category.trim().isEmpty()) {
category + + year + + month + + filename;
} {
year + + month + + filename;
}
}
}
package org.cskj.controller;
import org.cskj.entity.FileMetadata;
import org.cskj.service.ObsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/file")
public class FileController {
@Autowired
private ObsService obsService;
private static final Logger log = LoggerFactory.getLogger(FileController.class);
private static final String DEFAULT_UPLOADER = "system";
private static final Long DEFAULT_TENANT_CODE = 1L;
@PostMapping("/upload")
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file, @RequestParam(value = "category", required = false) String category) {
(file.isEmpty()) {
ResponseEntity.badRequest().body();
}
{
obsService.uploadFile(file, category, DEFAULT_UPLOADER, DEFAULT_TENANT_CODE);
obsService.generatePresignedUrl(meta.getId(), );
ResponseEntity.ok().header(, meta.getId().toString()).body(url);
} (Exception e) {
log.error(, e);
ResponseEntity.status().body( + e.getMessage());
}
}
{
{
obsService.getFileMetadata(fileId);
(meta == ) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, );
;
}
obsService.generatePresignedUrl(fileId, );
meta.getContentType();
(contentType == || contentType.isEmpty()) {
contentType = ;
}
response.setContentType(contentType);
URLEncoder.encode(meta.getOriginalFilename(), StandardCharsets.UTF_8.toString()).replace(, );
response.setHeader(, + meta.getOriginalFilename() + + encodedName);
( (presignedUrl).openStream()) {
in.transferTo(response.getOutputStream());
}
} (Exception e) {
log.error(, e);
{
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, );
} (Exception ignored) {
}
}
}
IOException {
requestBody.get();
(fileId == ) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, );
;
}
obsService.generatePresignedUrl(fileId, );
fileMetadataService.selectById(fileId);
(metadata == || metadata.getDeleted()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, );
;
}
response.setContentType(metadata.getContentType());
encodeFileName(metadata.getOriginalFilename());
response.setHeader(, contentDisposition);
( (presignedUrl).openStream(); response.getOutputStream()) {
[] buffer = [];
bytesRead;
((bytesRead = in.read(buffer)) != -) {
out.write(buffer, , bytesRead);
}
out.flush();
} (Exception e) {
log.error(, e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, );
}
}
String {
fileName.replaceAll(, );
URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace(, );
+ asciiName + + utf8Encoded;
}
ResponseEntity<?> delete( Long fileId) {
obsService.deleteFile(fileId);
(success) {
ResponseEntity.ok();
} {
ResponseEntity.status().body();
}
}
}
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/your_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
file:
obs:
endpoint: https://obs.cn-north-4.myhuaweicloud.com
ak: YOUR_HUAWEI_CLOUD_ACCESS_KEY
sk: YOUR_HUAWEI_CLOUD_SECRET_KEY
bucket-name: your-bucket-name
storage-root-directory: /files/
确保包含以下关键依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</>
com.huaweicloud
esdk-obs-java
3.22.8
# 上传
curl -F "[email protected]" http://localhost:8080/api/file/upload
# 预览(替换 {id})
curl http://localhost:8080/api/file/preview/1
启动应用:
mvn spring-boot:run
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>文件预览测试(POST 方式)</title>
<style>
body { font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f7fa; }
.container { text-align: center; padding: 30px; border: 1px solid #ddd; border-radius: 8px; background: white; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 90%; max-width: ; }
{ : ; : ; }
{ : ; : ; : ; : solid ; : ; : border-box; : ; }
{ : ; : ; : white; : none; : ; : pointer; : ; : ; }
{ : ; }
{ : ; : not-allowed; }
{ : ; : ; : ; }
{ : ; : ; : ; }
文件预览测试(POST 接口)
请输入文件 ID(fileId):
🔍 预览文件
后端接口需为 POST /api/file/preview,接收 { "fileId": 69 },返回文件流(Content-Disposition: inline)

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online