PVC抑烟剂技术原理与配方优化实践
2026/10/15 9:48:54
在国企日常办公场景中,OA系统经常需要处理红头文件、工程图纸、会议纪要等大体积附件。传统单次上传方式存在三个致命缺陷:网络闪断导致重传、服务器内存溢出风险、用户无法感知传输进度。某央企实际案例显示,当上传500MB以上的CAD图纸时,传统方式失败率高达34%。
分块上传技术将大文件切割为2-8MB的标准块(国企内网通常采用4MB分块),通过以下机制确保可靠性:
// 国企OA系统常用Vue+ElementUI实现 async handleUpload(file) { const chunkSize = 4 * 1024 * 1024 // 国企标准分块4MB const chunks = Math.ceil(file.size / chunkSize) const fileMd5 = await calculateMD5(file) // SparkMD5库计算 // 秒传验证请求 const { data } = await checkExist(fileMd5) if (data.exist) { this.$message.success('秒传成功!') return } // 分块上传 for (let i = 0; i < chunks; i++) { const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize) await uploadChunk(chunk, i, fileMd5) } }// 秒传验证接口 @PostMapping("/checkExist") public Result checkExist(@RequestParam String fileMd5) { // 查询文件记录表 FileRecord record = fileRecordMapper.selectByMd5(fileMd5); if (record != null && record.getStatus() == 2) { // 2-已完成 return Result.success().data("exist", true); } // 查询分块记录表 List<Integer> chunkList = chunkRecordMapper.selectChunks(fileMd5); return Result.success() .data("exist", false) .data("uploaded", chunkList); }@PostMapping("/uploadChunk") public Result uploadChunk( @RequestParam MultipartFile chunk, @RequestParam Integer chunkIndex, @RequestParam String fileMd5) throws IOException { // 校验分块MD5(国企安全要求) String chunkMd5 = DigestUtils.md5Hex(chunk.getInputStream()); if (!chunkMd5.equals(request.getHeader("Chunk-MD5"))) { throw new SecurityException("分块校验失败"); } // 存储分块到临时目录 String chunkPath = "/tmp/chunks/" + fileMd5 + "/" + chunkIndex; FileUtils.writeByteArrayToFile(new File(chunkPath), chunk.getBytes()); // 记录分块信息 ChunkRecord record = new ChunkRecord(); record.setFileMd5(fileMd5); record.setChunkIndex(chunkIndex); record.setChunkMd5(chunkMd5); chunkRecordMapper.insert(record); // 检查是否全部完成 int totalChunks = Integer.parseInt(request.getHeader("Total-Chunks")); if (chunkRecordMapper.countChunks(fileMd5) == totalChunks) { mergeChunks(fileMd5, totalChunks); } return Result.success(); }CREATE TABLE `t_file_record` ( `id` bigint NOT NULL COMMENT '主键', `file_md5` varchar(32) NOT NULL COMMENT '文件指纹', `file_name` varchar(255) NOT NULL COMMENT '原始文件名', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-上传中 1-合并中 2-已完成', `progress` int DEFAULT '0' COMMENT '上传进度百分比', `create_time` datetime NOT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_md5` (`file_md5`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_chunk_record` ( `id` bigint NOT NULL COMMENT '主键', `file_md5` varchar(32) NOT NULL COMMENT '关联文件指纹', `chunk_index` int NOT NULL COMMENT '分块序号', `chunk_md5` varchar(32) NOT NULL COMMENT '分块校验值', `create_time` datetime NOT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_md5_index` (`file_md5`,`chunk_index`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;private void mergeChunks(String fileMd5, int totalChunks) throws IOException { // 标记文件为合并中状态 updateFileStatus(fileMd5, 1); // 创建最终文件 String destPath = "/data/files/" + fileMd5.substring(0,2) + "/" + fileMd5; try (RandomAccessFile destFile = new RandomAccessFile(destPath, "rw")) { byte[] buffer = new byte[4096]; // 按序号合并所有分块 for (int i = 0; i < totalChunks; i++) { String chunkPath = "/tmp/chunks/" + fileMd5 + "/" + i; try (FileInputStream fis = new FileInputStream(chunkPath)) { int len; while ((len = fis.read(buffer)) != -1) { destFile.write(buffer, 0, len); } } } } // 更新文件状态 updateFileStatus(fileMd5, 2); // 清理临时分块(建议异步执行) FileUtils.deleteDirectory(new File("/tmp/chunks/" + fileMd5)); }@Aspect @Component public class UploadLogAspect { @AfterReturning("execution(* com..*.upload*(..)) && args(..,principal)") public void logUpload(Principal principal) { String username = principal.getName(); String operation = joinPoint.getSignature().getName(); auditLogService.log(username, operation); } }// 在application.yml中配置 server: ssl: enabled: true key-store: classpath:keystore.jks key-store-password: 国企密码规范 // 文件内容加密 public String encryptChunk(byte[] data) { return Base64.getEncoder().encodeToString( RSAUtils.encrypt(data, publicKey) ); }# 限制单分块内存占用 spring.servlet.multipart.max-file-size=4MB spring.servlet.multipart.max-request-size=4MB # Tomcat临时文件阈值 server.tomcat.max-swallow-size=4MB@PostMapping("/streamUpload") public void streamUpload(@RequestParam String fileMd5, InputStream inputStream) { try (OutputStream out = new FileOutputStream("/tmp/" + fileMd5)) { byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { out.write(buffer, 0, bytesRead); } } }// 存储到多个节点(国企通常要求至少双副本) @Async public void saveWithReplica(byte[] data, String path) { // 主存储 FileUtils.writeByteArrayToFile(new File("/node1" + path), data); // 备份存储 FileUtils.writeByteArrayToFile(new File("/node2" + path), data); }// 每日凌晨校验未完成文件 @Scheduled(cron = "0 0 3 * * ?") public void checkUnfinishedFiles() { List<FileRecord> records = fileRecordMapper.selectUnfinished(); records.forEach(record -> { int uploaded = chunkRecordMapper.countChunks(record.getFileMd5()); int progress = (int) (uploaded * 100.0 / record.getTotalChunks()); fileRecordMapper.updateProgress(record.getId(), progress); }); }public void checkSensitiveContent(File file) { // 检查文件头特征 byte[] header = FileUtils.readFileToByteArray(file, 0, 8); if (isOfficeFile(header)) { // 使用POI解析检查关键词 checkOfficeContent(file); } // 图片文件需检测隐写 if (isImageFile(header)) { checkImageSteganography(file); } }@PreAuthorize("hasPermission(#fileMd5, 'download')") @GetMapping("/download") public ResponseEntity<Resource> download( @RequestParam String fileMd5, @CurrentUser User user) { // 校验部门权限 if (!departmentService.checkAccess(user, fileMd5)) { throw new AccessDeniedException(); } // 返回文件流 File file = fileService.getByMd5(fileMd5); return ResponseEntity.ok() .header("Content-Disposition", "attachment") .body(new FileSystemResource(file)); }