跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Javajava

Spring Boot 集成华为云 OBS 实现文件上传与预览功能

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

雾岛听风发布于 2026/3/22更新于 2026/5/2327 浏览
Spring Boot 集成华为云 OBS 实现文件上传与预览功能

Spring Boot 集成华为云 OBS 实现文件上传与预览功能

一、背景与需求

在现代 Web 应用中,文件上传与访问是常见需求。出于安全性考虑,我们通常将文件存储在私有桶(Private Bucket)中,禁止直接公开访问。此时,需要通过预签名 URL(Presigned URL)机制临时授权用户访问特定文件。

本文将演示如何:

  • 配置华为云 OBS 连接
  • 限制上传文件类型
  • 将文件上传至 OBS 并记录元数据
  • 生成 1 小时有效的预签名 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 // 数据库操作

三、项目依赖(Maven)

<dependency>
    <groupId>com.huaweicloud</groupId>
    <artifactId>esdk-obs-java</artifactId>
    <version>3.22.8</version>
</dependency>

四、配置文件:application.yml

file:
  obs:
    endpoint: https://obs.cn-north-4.myhuaweicloud.com # 替换为你所在区域的 Endpoint
    ak: YOUR_ACCESS_KEY_ID # 华为云 AK(生产环境请用环境变量)
    sk: YOUR_SECRET_ACCESS_KEY # 华为云 SK
      
      

目录

  1. Spring Boot 集成华为云 OBS 实现文件上传与预览功能
  2. 一、背景与需求
  3. 二、项目结构概览
  4. 三、项目依赖(Maven)
  5. 四、配置文件:application.yml
  6. 五、核心配置:ObsConfig
  7. 六、文件上传逻辑
  8. 1. 安全校验
  9. 2. 调用 OBS 服务上传
  10. 3. 返回预签名 URL
  11. 七、预签名 URL 生成
  12. 八、安全预览接口
  13. 关键实现细节:
  14. 1. 设置正确的 Content-Type
  15. 2. 安全设置 Content-Disposition
  16. 3. 后端下载并转发
  17. 九、文件删除
  18. 十、安全与最佳实践
  19. 十一、总结
  20. 十二、完整代码示例
  21. 1. 主启动类:CskjApplication.java
  22. 2. OBS 配置类:ObsConfig.java
  23. 3. 实体类:FileMetadata.java
  24. 4. Mapper 接口:FileMetadataMapper.java
  25. 5. Mapper XML:FileMetadataMapper.xml
  26. 6. Service 接口:ObsService.java
  27. 7. Service 实现:ObsServiceImpl.java
  28. 8. Controller:FileController.java
  29. 9. 配置文件:application.yml
  30. 10. Maven 依赖(pom.xml)
  31. 11. 测试
  32. 上传
  33. 预览(替换 {id})
  34. 12. 前端验证 HTML
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • ROS2 下 CMU 自主探索算法与 MID-360 雷达实车部署记录
  • 手写高性能日志模块:基于策略模式与线程安全设计
  • 银河麒麟 V11-2503 安装指南:6.6 内核与 AI 特性详解
  • OSCP 实战笔记:获取并破解 Net-NTLMv2 哈希
  • LazyLLM 多 Agent 应用实践:源码部署与 Web 调试指南
  • JDK 25 Windows 安装与环境变量配置指南
  • Instant-NGP 多分辨率哈希编码技术解析
  • 腾讯 HunyuanOCR 1B 模型本地部署与测试指南
  • Vue3 + PlayCanvas 实战:3D 地图自由巡视与多关卡闯关系统
  • 大模型时代对普通人生活与工作的影响及学习路径
  • Flutter 跨平台开发实战指南:从基础到源码深度解析
  • C 语言初阶算法习题(一)
  • Python 工程师常见基础面试题
  • OpenClaw 集成 MCP 协议:构建自托管 AI 助手工具链
  • CentOS 7 系统镜像下载与版本选择指南
  • 基于 OpenClaw 与飞书搭建多 Agent AI 助理团队
  • 基于 YOLO26 深度学习的无人机视角路面病害检测识别系统
  • 国内股票分析 AI 开源项目精选:GitHub 热门榜单
  • Docker 镜像构建优化与 MySQL 主从集群容器化部署
  • Python 数据库操作指南:使用 SQLAlchemy ORM 实现高效开发

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

bucket-name:
your-bucket-name
# OBS 桶名称
storage-root-directory:
/files/
# 存储根路径,结尾带斜杠

五、核心配置:ObsConfig

/**
 * 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}。

六、文件上传逻辑

1. 安全校验

  • 检查文件是否为空
  • 校验文件扩展名(仅允许常见办公/图片格式)
private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList("xls", "xlsx", "doc", "docx", "pdf", "jpg", "jpeg", "png", "gif", "bmp", "txt", "csv"));

2. 调用 OBS 服务上传

  • 使用 MultipartFile 获取输入流
  • 构建唯一文件名(UUID + 原始名清洗)
  • 按年月 + 分类组织存储路径(如 resume/2025/12/xxx.pdf)
  • 保存元数据到数据库(含原始名、大小、类型、上传人、租户等)

3. 返回预签名 URL

上传成功后,立即生成一个 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"
  }
}

七、预签名 URL 生成

提供独立接口,支持按需生成不同有效期的访问链接:

@PostMapping("/generatePresignedUrl")
public Result<String> generatePresignedUrl(@RequestParam("fileId") Integer fileId, @RequestParam(value = "expirationSeconds", defaultValue = "3600") int expirationSeconds)

用途:前端在需要时动态获取新链接(如刷新过期链接),避免长期暴露 URL。

八、安全预览接口

虽然前端可直接使用预签名 URL 访问文件,但存在以下问题:

  • 跨域问题(CORS)
  • 无法统一添加鉴权逻辑
  • 浏览器对某些 Content-Type 处理不一致(如 PDF 强制下载而非预览)

因此,我们提供后端代理式预览接口:

@GetMapping("/preview/{fileId}")
public void previewFile(@PathVariable Integer fileId, HttpServletRequest request, HttpServletResponse response) throws IOException
关键实现细节:
1. 设置正确的 Content-Type

从数据库读取 contentType(如 application/pdf),确保浏览器正确渲染。

2. 安全设置 Content-Disposition

使用 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 编码的文件名

3. 后端下载并转发

通过 new URL(presignedUrl).openStream() 从 OBS 拉取文件,写入 HttpServletResponse 输出流,实现'透明代理'。

九、文件删除

  • 先删除 OBS 中的对象
  • 再标记数据库记录为已删除(软删除)
  • 避免因网络问题导致数据不一致

十、安全与最佳实践

  1. 不要暴露 AK/SK:使用 IAM 角色或临时凭证更安全。
  2. 预签名 URL 有效期不宜过长:默认 1 小时,敏感文件可缩短至 5~10 分钟。
  3. 文件名清洗:防止路径穿越(如 ../../../etc/passwd)。
  4. 租户隔离:多租户系统中,storageRootDirectory 可包含 tenantCode。
  5. 日志审计:记录上传/删除操作,便于追踪。

十一、总结

本文完整实现了基于华为云 OBS 的文件上传与安全预览方案,兼顾功能性、安全性与用户体验。核心亮点包括:

  • 严格的文件类型校验
  • 按分类 + 时间组织存储结构
  • 支持中文文件名的安全预览
  • 预签名 URL 动态管理
  • 后端代理解决跨域与权限问题

适用场景:HR 系统简历上传、OA 附件管理、医疗影像存储、教育资料分发等。

十二、完整代码示例

以下是完整、可直接运行的 Spring Boot 项目代码,包含 MySQL 表结构适配、MyBatis Mapper 接口 + XML、华为云 OBS 上传/预览/软删除、Controller 支持上传与预览、配置文件完整。

1. 主启动类:CskjApplication.java

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);
    }
}

2. OBS 配置类:ObsConfig.java

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/";
}

3. 实体类:FileMetadata.java

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;
}

4. Mapper 接口:FileMetadataMapper.java

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

5. Mapper XML:FileMetadataMapper.xml

<?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" property="category"/>
        <result column="upload_time" property="uploadTime"/>
        <result column="uploader" property="uploader"/>
        <result column="tenant_code" property="tenantCode"/>
        <result column="is_deleted" property="deleted" javaType="boolean" jdbcType="TINYINT"/>
    </resultMap>
    <insert id="save" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
        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)
    </insert>
    <select id="selectById" resultMap="FileMetadataResultMap">
        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
    </select>
    <update id="deletedFileMetadata">
        UPDATE file_metadata SET is_deleted = 1 WHERE id = #{id} AND is_deleted = 0
    </update>
</mapper>

6. Service 接口:ObsService.java

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);
}

7. Service 实现:ObsServiceImpl.java

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());
    }

    @Override
    public FileMetadata uploadFile(MultipartFile file, String category, String uploader, Long tenantCode) throws Exception {
        String originalFilename = file.getOriginalFilename();
        if (originalFilename == null) originalFilename = "unknown";
        String safeFilename = buildSafeFilename(category, originalFilename);
        String objectKey = obsConfig.getStorageRootDirectory() + safeFilename;
        try (InputStream in = file.getInputStream()) {
            PutObjectResult result = obsClient.putObject(obsConfig.getBucketName(), objectKey, in);
            log.info("OBS 上传成功:key={}, size={}, ETag={}", objectKey, file.getSize(), result.getEtag());
        }
        FileMetadata metadata = new FileMetadata();
        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(false);
        fileMetadataMapper.save(metadata);
        return metadata;
    }

    @Override
    public FileMetadata getFileMetadata(Long fileId) {
        return fileMetadataMapper.selectById(fileId);
    }

    @Override
    public String generatePresignedUrl(Long fileId, int expirationSeconds) throws Exception {
        FileMetadata meta = getFileMetadata(fileId);
        if (meta == null) {
            throw new RuntimeException("文件不存在或已被删除");
        }
        String objectKey = obsConfig.getStorageRootDirectory() + meta.getSafeFilename();
        TemporarySignatureRequest request = new TemporarySignatureRequest(HttpMethodEnum.GET, expirationSeconds);
        request.setBucketName(meta.getBucketName());
        request.setObjectKey(objectKey);
        TemporarySignatureResponse response = obsClient.createTemporarySignature(request);
        return response.getSignedUrl();
    }

    @Override
    public boolean deleteFile(Long fileId) {
        FileMetadata meta = getFileMetadata(fileId);
        if (meta == null) return false;
        try {
            String objectKey = obsConfig.getStorageRootDirectory() + meta.getSafeFilename();
            obsClient.deleteObject(obsConfig.getBucketName(), objectKey);
            log.info("OBS 文件删除成功:{}", objectKey);
        } catch (Exception e) {
            log.error("OBS 删除失败", e);
            return false;
        }
        fileMetadataMapper.deletedFileMetadata(fileId);
        return true;
    }

    private String buildSafeFilename(String category, String originalFilename) {
        String cleanName = originalFilename.replaceAll("[^a-zA-Z0-9._\\-]", "_");
        String timestamp = String.valueOf(System.currentTimeMillis());
        String filename = timestamp + "_" + cleanName;
        String year = String.valueOf(LocalDate.now().getYear());
        String month = String.format("%02d", LocalDate.now().getMonthValue());
        if (category != null && !category.trim().isEmpty()) {
            return category + "/" + year + "/" + month + "/" + filename;
        } else {
            return year + "/" + month + "/" + filename;
        }
    }
}

8. Controller:FileController.java

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) {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body("文件为空");
        }
        try {
            FileMetadata meta = obsService.uploadFile(file, category, DEFAULT_UPLOADER, DEFAULT_TENANT_CODE);
            String url = obsService.generatePresignedUrl(meta.getId(), 3600);
            return ResponseEntity.ok().header("X-File-Id", meta.getId().toString()).body(url);
        } catch (Exception e) {
            log.error("上传失败", e);
            return ResponseEntity.status(500).body("上传失败:" + e.getMessage());
        }
    }

    @GetMapping("/preview/{fileId}")
    public void preview(@PathVariable Long fileId, HttpServletResponse response) {
        try {
            FileMetadata meta = obsService.getFileMetadata(fileId);
            if (meta == null) {
                response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
                return;
            }
            String presignedUrl = obsService.generatePresignedUrl(fileId, 3600);
            String contentType = meta.getContentType();
            if (contentType == null || contentType.isEmpty()) {
                contentType = "application/octet-stream";
            }
            response.setContentType(contentType);
            String encodedName = URLEncoder.encode(meta.getOriginalFilename(), StandardCharsets.UTF_8.toString()).replace("+", "%20");
            response.setHeader("Content-Disposition", "inline; filename=\"" + meta.getOriginalFilename() + "\"; filename*=UTF-8''" + encodedName);
            try (InputStream in = new URL(presignedUrl).openStream()) {
                in.transferTo(response.getOutputStream());
            }
        } catch (Exception e) {
            log.error("预览失败", e);
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "预览失败");
            } catch (Exception ignored) {
            }
        }
    }

    @PostMapping("/preview")
    public void previewFilePost(@RequestBody Map<String, Integer> requestBody, HttpServletResponse response) throws IOException {
        Integer fileId = requestBody.get("fileId");
        if (fileId == null) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少文件 ID");
            return;
        }
        String presignedUrl = obsService.generatePresignedUrl(fileId, 3600);
        FileMetadata metadata = fileMetadataService.selectById(fileId);
        if (metadata == null || metadata.getDeleted()) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
            return;
        }
        response.setContentType(metadata.getContentType());
        String contentDisposition = encodeFileName(metadata.getOriginalFilename());
        response.setHeader("Content-Disposition", contentDisposition);
        try (InputStream in = new URL(presignedUrl).openStream(); OutputStream out = response.getOutputStream()) {
            byte[] buffer = new byte[8192];
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
            }
            out.flush();
        } catch (Exception e) {
            log.error("预览文件失败", e);
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件读取失败");
        }
    }

    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;
    }

    @DeleteMapping("/{fileId}")
    public ResponseEntity<?> delete(@PathVariable Long fileId) {
        boolean success = obsService.deleteFile(fileId);
        if (success) {
            return ResponseEntity.ok("删除成功");
        } else {
            return ResponseEntity.status(404).body("文件不存在或已删除");
        }
    }
}

9. 配置文件:application.yml

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/

10. Maven 依赖(pom.xml)

确保包含以下关键依赖:

<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</optional>
    </dependency>
    <dependency>
        <groupId>com.huaweicloud</groupId>
        <artifactId>esdk-obs-java</artifactId>
        <version>3.22.8</version>
    </dependency>
</dependencies>

11. 测试

# 上传
curl -F "[email protected]" http://localhost:8080/api/file/upload
# 预览(替换 {id})
curl http://localhost:8080/api/file/preview/1

启动应用:

mvn spring-boot:run

12. 前端验证 HTML

<!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: 500px; }
        h2 { color: #333; margin-bottom: 20px; }
        input[type="number"] { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; font-size: 16px; }
        button { padding: 10px 20px; background-color: #409eff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; width: 100%; }
        button:hover { background-color: #53a8ff; }
        button:disabled { background-color: #ccc; cursor: not-allowed; }
        .tip { color: #666; font-size: 14px; margin-top: 15px; }
        .status { margin-top: 10px; font-size: 14px; color: #e6a23c; }
    </style>
</head>
<body>
<div class="container">
    <h2>文件预览测试(POST 接口)</h2>
    <p>请输入文件 ID(fileId):</p>
    <input type="number" id="fileIdInput" placeholder="例如:69" min="1"/><br/>
    <button id="previewBtn" onclick="previewFile()">🔍 预览文件</button>
    <div id="status" class="status"></div>
    <div class="tip">后端接口需为 POST /api/file/preview,接收 { "fileId": 69 },返回文件流(Content-Disposition: inline)</div>
</div>
<script>
function setStatus(msg, isError = false) {
    const statusEl = document.getElementById('status');
    statusEl.textContent = msg;
    statusEl.style.color = isError ? '#f56565' : '#e6a23c';
}
async function previewFile() {
    const fileIdStr = document.getElementById('fileIdInput').value.trim();
    const btn = document.getElementById('previewBtn');
    if (!fileIdStr) { alert('请输入文件 ID'); return; }
    const fileId = parseInt(fileIdStr, 10);
    if (isNaN(fileId) || fileId <= 0) { alert('请输入有效的正整数文件 ID'); return; }
    btn.disabled = true;
    setStatus('正在请求文件...');
    try {
        const response = await fetch('http://localhost:8080/app/api/file/preview', {
            method: 'POST',
            headers: { 'Content-Type':  },
            : .({ : fileId })
        });
         (!response.) {
             errorMsg =  response.().( );
              ();
        }
         contentType = response..() || ;
         (!contentType.() && !contentType.() && !contentType.()) {
             (!()) {
                btn. = ;
                ();
                ;
            }
        }
         blob =  response.();
         url = ..(blob);
         win = .(url, );
         (!win) { (); }
        ();
    }  (error) {
        .(, error);
        ( + (error. || ));
        (, );
    }  {
        btn. = ;
        ( (), );
    }
}
.().(,  {
     (e. === ) { (); }
});
</script>
</body>
</html>
'application/json'
body
JSON
stringify
fileId
if
ok
const
await
text
catch
() =>
'未知错误'
throw
new
Error
`HTTP ${response.status}: ${errorMsg}`
const
headers
get
'content-type'
'application/octet-stream'
if
startsWith
'image/'
includes
'pdf'
includes
'text/'
if
confirm
'该文件类型可能无法在浏览器中直接预览,是否仍要下载?'
disabled
false
setStatus
''
return
const
await
blob
const
window
URL
createObjectURL
const
window
open
'_blank'
if
alert
'浏览器阻止了弹出窗口,请允许后重试'
setStatus
'预览已打开 ✅'
catch
console
error
'预览失败:'
alert
'预览失败:'
message
'网络或服务器错误'
setStatus
'预览失败 ❌'
true
finally
disabled
false
setTimeout
() =>
setStatus
''
3000
document
getElementById
'fileIdInput'
addEventListener
'keypress'
(e) =>
if
key
'Enter'
previewFile