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>
相关推荐
一嘴一个橘子1 小时前
mybatis - 动态语句、批量注册mapper、分页插件
java
组合缺一1 小时前
Json Dom 怎么玩转?
java·json·dom·snack4
危险、1 小时前
一套提升 Spring Boot 项目的高并发、高可用能力的 Cursor 专用提示词
java·spring boot·提示词
kaico20181 小时前
JDK11新特性
java
钊兵1 小时前
java实现GeoJSON地理信息对经纬度点的匹配
java·开发语言
jiayong231 小时前
Tomcat性能优化面试题
java·性能优化·tomcat
秋刀鱼程序编程1 小时前
Java基础入门(五)----面向对象(上)
java·开发语言
sunnyday04261 小时前
基于Netty构建WebSocket服务器实战指南
服务器·spring boot·websocket·网络协议
纪莫2 小时前
技术面:MySQL篇(InnoDB的锁机制)
java·数据库·java面试⑧股
Remember_9932 小时前
【LeetCode精选算法】滑动窗口专题二
java·开发语言·数据结构·算法·leetcode