Spring Boot集成华为云OBS实现文件上传与预览功能(含安全下载)

Spring Boot集成华为云OBS实现文件上传与预览功能(含安全下载)

本文基于Spring Boot + 华为云OBS(对象存储服务)实现一套完整的文件上传、预签名URL生成、安全预览/下载功能,并附带详细代码解析,适用于企业级应用中的文档管理、图片存储等场景。


一、背景与需求

在现代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)

xml 复制代码
<dependency>
    <groupId>com.huaweicloud</groupId>
    <artifactId>esdk-obs-java</artifactId>
    <version>3.22.8</version> <!-- 请使用最新版 -->
</dependency>

四、配置文件:application.yml

yaml 复制代码
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/                     # 存储根路径,结尾带斜杠

五、核心配置:ObsConfig

java 复制代码
/**
 * 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属于敏感信息,建议通过配置中心或环境变量注入,避免硬编码。推荐使用:

yaml 复制代码
ak: ${OBS_AK}
sk: ${OBS_SK}

六、文件上传逻辑(FileController.uploadFile)

1. 安全校验

  • 检查文件是否为空
  • 校验文件扩展名(仅允许常见办公/图片格式)
java 复制代码
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,供前端直接预览或下载:

json 复制代码
{
  "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生成(generatePresignedUrl)

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

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

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


六、安全预览接口(previewFile)

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

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

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

java 复制代码
@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 标准 支持中文文件名,兼容新旧浏览器:

java 复制代码
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 输出流,实现"透明代理"。


七、文件删除(deleteFile)

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

八、安全与最佳实践

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

九、总结

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

✅ 严格的文件类型校验

✅ 按分类+时间组织存储结构

✅ 支持中文文件名的安全预览

✅ 预签名URL动态管理

✅ 后端代理解决跨域与权限问题

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


以下是完整、可直接运行的 Spring Boot 项目代码,包含:

  • ✅ MySQL 表结构适配(BIGINT idis_deleted TINYINT
  • ✅ MyBatis Mapper 接口 + XML
  • ✅ 华为云 OBS 上传/预览/软删除
  • ✅ Controller 支持上传与预览(从数据库查元数据)
  • ✅ 配置文件完整

📁 项目结构(Maven)

复制代码
src/main/java/org/cskj/
├── CskjApplication.java
├── config/ObsConfig.java
├── controller/FileController.java
├── entity/FileMetadata.java
├── mapper/FileMetadataMapper.java
├── service/ObsService.java
└── service/impl/ObsServiceImpl.java

src/main/resources/
├── application.yml
└── mapper/FileMetadataMapper.xml

1️⃣ 主启动类:CskjApplication.java

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

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

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; // is_deleted -> Boolean
}

4️⃣ Mapper 接口:FileMetadataMapper.java

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 复制代码
<?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

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

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); // ID 自动回填
        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

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;
/**
 * 文件管理控制器
 *
 * 提供文件上传、预览、下载、删除等完整的文件管理功能。
 * 通过集成 OBS(对象存储服务)实现文件的云端存储和管理,
 * 同时维护文件元数据信息,确保文件操作的安全性和可追溯性。
 *
 * 主要功能包括:
 * 1. 文件上传:支持多种格式文件上传至OBS,并记录文件元数据
 * 2. 文件预览(包含get和post两中方式):提供文件在线预览功能,支持多种文件格式
 * 3. 预签名URL生成:生成临时访问URL,确保文件访问安全
 * 4. 文件删除:安全删除OBS中的文件及对应的元数据
 * 5. 文件类型验证:限制上传文件类型,确保系统安全
 *
 * @author cskj
 * @date 2025/12/30 19:00
 */
@RestController
@RequestMapping("/api/file")
public class FileController {

    @Autowired
    private ObsService obsService;

    private static final Logger log = LoggerFactory.getLogger(FileController.class);

    // 模拟用户信息(实际应从 Token 获取)
    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) {}
        }
    }
		/**
     * 文件预览接口(POST方式)
     *
     * 通过POST请求体传递文件ID,返回文件内容用于预览
     *
     * @param requestBody 请求体,包含fileId参数
     * @param response HTTP响应对象
     * @throws IOException 文件读取异常
     */

    @PostMapping("/preview")
    public void previewFilePost(@RequestBody Map<String, Integer> requestBody, HttpServletResponse response) throws IOException {
        // 从请求体中获取fileId
        Integer fileId = requestBody.get("fileId");

        if (fileId == null) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少文件ID");
            return;
        }

        String presignedUrl = obsService.generatePresignedGetUrl(fileId, 3600); // 1小时有效

        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, "文件读取失败");
        }
    }

    @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

yaml 复制代码
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/

🔧 Maven 依赖(pom.xml)

确保包含以下关键依赖:

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

✅ 如何运行

  1. 创建数据库并执行你的建表语句

  2. 修改 application.yml 中的数据库和 OBS 凭据

  3. 启动应用:

    bash 复制代码
    mvn spring-boot:run
  4. 测试:

    bash 复制代码
    # 上传
    curl -F "file=@test.jpg" http://localhost:8080/api/file/upload
    
    # 预览(替换 {id})
    curl http://localhost:8080/api/file/preview/1

可直接复制运行

如有疑问,欢迎评论区交流!

提供一个前端验证的html文件

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': 'application/json',
            // 如果有认证 token,记得加上:
            // 'Authorization': 'Bearer xxx'
          },
          body: JSON.stringify({ fileId: fileId })
        });

        if (!response.ok) {
          const errorMsg = await response.text().catch(() => '未知错误');
          throw new Error(`HTTP ${response.status}: ${errorMsg}`);
        }

        // 获取文件的 MIME 类型(用于判断是否可预览)
        const contentType = response.headers.get('content-type') || 'application/octet-stream';

        // 检查是否是可内联预览的类型(如 PDF、图片等)
        if (!contentType.startsWith('image/') && 
            !contentType.includes('pdf') && 
            !contentType.includes('text/')) {
          if (!confirm('该文件类型可能无法在浏览器中直接预览,是否仍要下载?')) {
            btn.disabled = false;
            setStatus('');
            return;
          }
        }

        // 将响应转为 Blob
        const blob = await response.blob();
        const url = window.URL.createObjectURL(blob);

        // 在新窗口打开预览
        const win = window.open(url, '_blank');
        if (!win) {
          alert('浏览器阻止了弹出窗口,请允许后重试');
        } else {
          // 可选:监听窗口关闭后释放 URL(非必须)
          // win.addEventListener('beforeunload', () => window.URL.revokeObjectURL(url));
        }

        setStatus('预览已打开 ✅');
      } catch (error) {
        console.error('预览失败:', error);
        alert('预览失败:' + (error.message || '网络或服务器错误'));
        setStatus('预览失败 ❌', true);
      } finally {
        btn.disabled = false;
        // 3秒后清空状态
        setTimeout(() => setStatus(''), 3000);
      }
    }

    // 支持回车键触发
    document.getElementById('fileIdInput').addEventListener('keypress', (e) => {
      if (e.key === 'Enter') {
        previewFile();
      }
    });
  </script>
</body>
</html>
相关推荐
曹轲恒2 小时前
jvm 局部变量表slot复用问题
java·开发语言·jvm
小王师傅662 小时前
【轻松入门SpringBoot】actuator健康检查(中)-group,livenessState,readinessState
java·spring boot·后端
青w韵2 小时前
最新SpringAI-1.1.2接入openai兼容模型
java·学习·ai·springai
222you2 小时前
SpringMVC的单文件上传
java·开发语言
培培说证2 小时前
2026大专跨境电商专业,想好就业考哪些证书比较好?
java
计算机毕设指导62 小时前
基于微信小程序的旅游线路定制系统【源码文末联系】
java·spring boot·微信小程序·小程序·tomcat·maven·旅游
goodlook01232 小时前
监控平台搭建-监控指标展示-Grafana篇(五)
java·算法·docker·grafana·prometheus
qq_12498707532 小时前
基于Spring Boot的微信小程序的智慧商场系统的设计与实现
java·spring boot·spring·微信小程序·小程序·毕业设计·计算机毕业设计
椰羊~王小美2 小时前
通用的导入、导出方法
java·spring boot