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中的对象
- 再标记数据库记录为已删除(软删除)
- 避免因网络问题导致数据不一致
八、安全与最佳实践
- 不要暴露AK/SK:使用IAM角色或临时凭证更安全。
- 预签名URL有效期不宜过长:默认1小时,敏感文件可缩短至5~10分钟。
- 文件名清洗 :防止路径穿越(如
../../../etc/passwd)。 - 租户隔离 :多租户系统中,
storageRootDirectory可包含tenantCode。 - 日志审计:记录上传/删除操作,便于追踪。
九、总结
本文完整实现了基于华为云OBS的文件上传与安全预览方案,兼顾功能性、安全性与用户体验。核心亮点包括:
✅ 严格的文件类型校验
✅ 按分类+时间组织存储结构
✅ 支持中文文件名的安全预览
✅ 预签名URL动态管理
✅ 后端代理解决跨域与权限问题
适用场景:HR系统简历上传、OA附件管理、医疗影像存储、教育资料分发等。
以下是完整、可直接运行的 Spring Boot 项目代码,包含:
- ✅ MySQL 表结构适配(
BIGINT id,is_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>
✅ 如何运行
-
创建数据库并执行你的建表语句
-
修改
application.yml中的数据库和 OBS 凭据 -
启动应用:
bashmvn spring-boot:run -
测试:
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>