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)
<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 # 替换为你所在区域的Endpointak: YOUR_ACCESS_KEY_ID # 华为云AK(生产环境请用环境变量)sk: YOUR_SECRET_ACCESS_KEY # 华为云SKbucket-name: your-bucket-name # OBS桶名称storage-root-directory: /files/ # 存储根路径,结尾带斜杠五、核心配置:ObsConfig
/** * OBS对象存储配置 */@Configuration@ConfigurationProperties(prefix ="file.obs")@DatapublicclassObsConfig{/** * OBS endpoint */privateString endpoint;/** * Access Key */privateString ak;/** * Secret Key */privateString sk;/** * Bucket名称 */privateString bucketName;/** * 存储根目录 */privateString storageRootDirectory;}注意:
🔐 安全提示:AK/SK属于敏感信息,建议通过配置中心或环境变量注入,避免硬编码。推荐使用:
ak: ${OBS_AK}sk: ${OBS_SK}六、文件上传逻辑(FileController.uploadFile)
1. 安全校验
- 检查文件是否为空
- 校验文件扩展名(仅允许常见办公/图片格式)
privatestaticfinalSet<String>ALLOWED_EXTENSIONS=newHashSet<>(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生成(generatePresignedUrl)
提供独立接口,支持按需生成不同有效期的访问链接:
@PostMapping("/generatePresignedUrl")publicResult<String>generatePresignedUrl(@RequestParam("fileId")Integer fileId,@RequestParam(value ="expirationSeconds", defaultValue ="3600")int expirationSeconds)用途:前端在需要时动态获取新链接(如刷新过期链接),避免长期暴露URL。
六、安全预览接口(previewFile)
虽然前端可直接使用预签名URL访问文件,但存在以下问题:
- 跨域问题(CORS)
- 无法统一添加鉴权逻辑
- 浏览器对某些Content-Type处理不一致(如PDF强制下载而非预览)
因此,我们提供后端代理式预览接口:
@GetMapping("/preview/{fileId}")publicvoidpreviewFile(@PathVariableInteger fileId,HttpServletRequest request,HttpServletResponse response)throwsIOException关键实现细节:
1. 设置正确的 Content-Type
从数据库读取 contentType(如 application/pdf),确保浏览器正确渲染。
2. 安全设置 Content-Disposition
使用 RFC 5987 标准 支持中文文件名,兼容新旧浏览器:
privateStringencodeFileName(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
packageorg.cskj;importorg.mybatis.spring.annotation.MapperScan;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication@MapperScan("org.cskj.mapper")publicclassCskjApplication{publicstaticvoidmain(String[] args){SpringApplication.run(CskjApplication.class, args);}}2️⃣ OBS 配置类:ObsConfig.java
packageorg.cskj.config;importlombok.Data;importorg.springframework.boot.context.properties.ConfigurationProperties;importorg.springframework.stereotype.Component;@Component@ConfigurationProperties(prefix ="file.obs")@DatapublicclassObsConfig{privateString endpoint;privateString ak;privateString sk;privateString bucketName;privateString storageRootDirectory ="/files/";}3️⃣ 实体类:FileMetadata.java
packageorg.cskj.entity;importlombok.Data;importjava.time.LocalDateTime;@DatapublicclassFileMetadata{privateLong id;privateString originalFilename;privateString safeFilename;privateString bucketName;privateLong fileSize;privateString contentType;privateString category;privateLocalDateTime uploadTime;privateString uploader;privateLong tenantCode;privateBoolean deleted;// is_deleted -> Boolean}4️⃣ Mapper 接口:FileMetadataMapper.java
packageorg.cskj.mapper;importorg.apache.ibatis.annotations.Param;importorg.cskj.entity.FileMetadata;publicinterfaceFileMetadataMapper{intsave(FileMetadata record);FileMetadataselectById(@Param("id")Long id);voiddeletedFileMetadata(@Param("id")Long id);}🔔 注意:id类型为Long,匹配BIGINT
5️⃣ Mapper XML:FileMetadataMapper.xml
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="org.cskj.mapper.FileMetadataMapper"><resultMapid="FileMetadataResultMap"type="org.cskj.entity.FileMetadata"><idcolumn="id"property="id"/><resultcolumn="original_filename"property="originalFilename"/><resultcolumn="safe_filename"property="safeFilename"/><resultcolumn="bucket_name"property="bucketName"/><resultcolumn="file_size"property="fileSize"/><resultcolumn="content_type"property="contentType"/><resultcolumn="category"property="category"/><resultcolumn="upload_time"property="uploadTime"/><resultcolumn="uploader"property="uploader"/><resultcolumn="tenant_code"property="tenantCode"/><resultcolumn="is_deleted"property="deleted"javaType="boolean"jdbcType="TINYINT"/></resultMap><insertid="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><selectid="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><updateid="deletedFileMetadata"> UPDATE file_metadata SET is_deleted = 1 WHERE id = #{id} AND is_deleted = 0 </update></mapper>6️⃣ Service 接口:ObsService.java
packageorg.cskj.service;importorg.cskj.entity.FileMetadata;importorg.springframework.web.multipart.MultipartFile;publicinterfaceObsService{FileMetadatauploadFile(MultipartFile file,String category,String uploader,Long tenantCode)throwsException;FileMetadatagetFileMetadata(Long fileId);StringgeneratePresignedUrl(Long fileId,int expirationSeconds)throwsException;booleandeleteFile(Long fileId);}7️⃣ Service 实现:ObsServiceImpl.java
packageorg.cskj.service.impl;importcom.obs.services.ObsClient;importcom.obs.services.model.HttpMethodEnum;importcom.obs.services.model.PutObjectResult;importcom.obs.services.model.TemporarySignatureRequest;importcom.obs.services.model.TemporarySignatureResponse;importorg.cskj.config.ObsConfig;importorg.cskj.entity.FileMetadata;importorg.cskj.mapper.FileMetadataMapper;importorg.cskj.service.ObsService;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Service;importorg.springframework.web.multipart.MultipartFile;importjavax.annotation.PostConstruct;importjava.io.InputStream;importjava.time.LocalDate;@ServicepublicclassObsServiceImplimplementsObsService{@AutowiredprivateObsConfig obsConfig;@AutowiredprivateFileMetadataMapper fileMetadataMapper;privateObsClient obsClient;privatestaticfinalLogger log =LoggerFactory.getLogger(ObsServiceImpl.class);@PostConstructpublicvoidinit(){this.obsClient =newObsClient(obsConfig.getAk(), obsConfig.getSk(), obsConfig.getEndpoint());}@OverridepublicFileMetadatauploadFile(MultipartFile file,String category,String uploader,Long tenantCode)throwsException{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 =newFileMetadata(); 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;}@OverridepublicFileMetadatagetFileMetadata(Long fileId){return fileMetadataMapper.selectById(fileId);}@OverridepublicStringgeneratePresignedUrl(Long fileId,int expirationSeconds)throwsException{FileMetadata meta =getFileMetadata(fileId);if(meta ==null){thrownewRuntimeException("文件不存在或已被删除");}String objectKey = obsConfig.getStorageRootDirectory()+ meta.getSafeFilename();TemporarySignatureRequest request =newTemporarySignatureRequest(HttpMethodEnum.GET, expirationSeconds); request.setBucketName(meta.getBucketName()); request.setObjectKey(objectKey);TemporarySignatureResponse response = obsClient.createTemporarySignature(request);return response.getSignedUrl();}@OverridepublicbooleandeleteFile(Long fileId){FileMetadata meta =getFileMetadata(fileId);if(meta ==null)returnfalse;try{String objectKey = obsConfig.getStorageRootDirectory()+ meta.getSafeFilename(); obsClient.deleteObject(obsConfig.getBucketName(), objectKey); log.info("OBS 文件删除成功: {}", objectKey);}catch(Exception e){ log.error("OBS 删除失败", e);returnfalse;} fileMetadataMapper.deletedFileMetadata(fileId);returntrue;}privateStringbuildSafeFilename(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
packageorg.cskj.controller;importorg.cskj.entity.FileMetadata;importorg.cskj.service.ObsService;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.*;importorg.springframework.web.multipart.MultipartFile;importjavax.servlet.http.HttpServletResponse;importjava.io.InputStream;importjava.net.URL;importjava.net.URLEncoder;importjava.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")publicclassFileController{@AutowiredprivateObsService obsService;privatestaticfinalLogger log =LoggerFactory.getLogger(FileController.class);// 模拟用户信息(实际应从 Token 获取)privatestaticfinalStringDEFAULT_UPLOADER="system";privatestaticfinalLongDEFAULT_TENANT_CODE=1L;@PostMapping("/upload")publicResponseEntity<?>upload(@RequestParam("file")MultipartFile file,@RequestParam(value ="category", required =false)String category){if(file.isEmpty()){returnResponseEntity.badRequest().body("文件为空");}try{FileMetadata meta = obsService.uploadFile(file, category,DEFAULT_UPLOADER,DEFAULT_TENANT_CODE);String url = obsService.generatePresignedUrl(meta.getId(),3600);returnResponseEntity.ok().header("X-File-Id", meta.getId().toString()).body(url);}catch(Exception e){ log.error("上传失败", e);returnResponseEntity.status(500).body("上传失败: "+ e.getMessage());}}@GetMapping("/preview/{fileId}")publicvoidpreview(@PathVariableLong 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 =newURL(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")publicvoidpreviewFilePost(@RequestBodyMap<String,Integer> requestBody,HttpServletResponse response)throwsIOException{// 从请求体中获取fileIdInteger 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 =newURL(presignedUrl).openStream();OutputStream out = response.getOutputStream()){byte[] buffer =newbyte[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}")publicResponseEntity<?>delete(@PathVariableLong fileId){boolean success = obsService.deleteFile(fileId);if(success){returnResponseEntity.ok("删除成功");}else{returnResponseEntity.status(404).body("文件不存在或已删除");}}}9️⃣ 配置文件:application.yml
server:port:8080spring:datasource:url: jdbc:mysql://localhost:3306/your_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghaiusername: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis:mapper-locations: classpath:mapper/*.xmlconfiguration:map-underscore-to-camel-case:truefile: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)
确保包含以下关键依赖:
<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 凭据
测试:
# 上传curl-F"[email protected]" http://localhost:8080/api/file/upload # 预览(替换 {id})curl http://localhost:8080/api/file/preview/1 启动应用:
mvn spring-boot:run ✅ 可直接复制运行。
如有疑问,欢迎评论区交流!
✅ 提供一个前端验证的html文件
<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"/><metaname="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><divclass="container"><h2>文件预览测试(POST 接口)</h2><p>请输入文件 ID(fileId):</p><inputtype="number"id="fileIdInput"placeholder="例如:69"min="1"/><br/><buttonid="previewBtn"onclick="previewFile()">🔍 预览文件</button><divid="status"class="status"></div><divclass="tip"> 💡 后端接口需为 POST /api/file/preview,接收 { "fileId": 69 },返回文件流(Content-Disposition: inline) </div></div><script>functionsetStatus(msg, isError =false){const statusEl = document.getElementById('status'); statusEl.textContent = msg; statusEl.style.color = isError ?'#f56565':'#e6a23c';}asyncfunctionpreviewFile(){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 =awaitfetch('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(()=>'未知错误');thrownewError(`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;}}// 将响应转为 Blobconst 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>