diff --git a/bonus-admin/src/main/java/com/bonus/web/controller/data/ExportExcelController.java b/bonus-admin/src/main/java/com/bonus/web/controller/data/ExportExcelController.java new file mode 100644 index 0000000..d9ad2fa --- /dev/null +++ b/bonus-admin/src/main/java/com/bonus/web/controller/data/ExportExcelController.java @@ -0,0 +1,96 @@ +package com.bonus.web.controller.data; + +import cn.hutool.core.map.MapUtil; +import com.bonus.common.domain.data.dto.ParamsDto; +import com.bonus.common.domain.data.vo.ExportTask; +import com.bonus.web.service.data.ExportExcelService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * @className:ExportExcelController + * @author:cwchen + * @date:2025-12-30-17:02 + * @version:1.0 + * @description:识别数据导出-web层 + */ +@RestController +@RequestMapping("/device/data-recognition") +public class ExportExcelController { + + @Autowired + private ExportExcelService exportService; + + // 1. 启动导出任务 + @GetMapping("/export") + public Map startExport(ParamsDto dto) { + // 启动异步任务,获取TaskId + String taskId = exportService.createExportTask(dto); + + Map data = new HashMap<>(); + data.put("taskId", taskId); + return MapUtil.builder(new HashMap()) + .put("code", 200) + .put("msg", "操作成功") + .put("data", data) + .build(); + } + + // 2. 轮询进度 + @GetMapping("/progress") // 注意:如果前端封装是POST,这里改为PostMapping + public Map getProgress(@RequestParam("taskId") String taskId) { + ExportTask task = exportService.getTask(taskId); + if (task == null) { + return MapUtil.builder(new HashMap()) + .put("code", 500) + .put("msg", "任务不存在") + .build(); + } + return MapUtil.builder(new HashMap()) + .put("code", 200) + .put("data", task) + .build(); + } + + // 3. 下载文件 + @GetMapping("/download") + public ResponseEntity download(@RequestParam(required = false) String taskId) throws Exception { + ExportTask task = exportService.getTask(taskId); + + if (task == null || !"completed".equals(task.getStatus())) { + throw new RuntimeException("文件未准备好"); + } + + File file = new File(task.getFilePath()); + if (!file.exists()) { + throw new RuntimeException("文件已过期"); + } + + // 处理中文文件名乱码 + String encodedFileName = URLEncoder.encode(task.getFileName(), StandardCharsets.UTF_8.toString()); + + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Disposition", "attachment; filename=" + encodedFileName); + headers.add("Access-Control-Expose-Headers", "Content-Disposition"); // 允许前端获取文件名 + + MediaType mediaType = task.getFileName().endsWith(".zip") + ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + return ResponseEntity.ok() + .headers(headers) + .contentLength(file.length()) + .contentType(mediaType) + .body(new FileSystemResource(file)); + } +} diff --git a/bonus-admin/src/main/java/com/bonus/web/service/data/ExportExcelService.java b/bonus-admin/src/main/java/com/bonus/web/service/data/ExportExcelService.java new file mode 100644 index 0000000..cf873c8 --- /dev/null +++ b/bonus-admin/src/main/java/com/bonus/web/service/data/ExportExcelService.java @@ -0,0 +1,236 @@ +package com.bonus.web.service.data; + +import com.alibaba.excel.write.handler.SheetWriteHandler; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import com.bonus.common.domain.data.dto.ParamsDto; +import com.bonus.common.domain.data.vo.DataRecognitionExportVO; +import com.bonus.common.domain.data.vo.ExportTask; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.date.DateUnit; +import cn.hutool.core.util.ZipUtil; +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; + +import javax.annotation.Resource; +import java.io.File; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 识别数据导出-业务逻辑层 (已修复 IO 关闭异常) + */ +@Service(value = "ExportExcelService") +@Slf4j +public class ExportExcelService { + + @Resource + private RedisTemplate redisTemplate; + + private static final String REDIS_KEY_PREFIX = "export:task:"; + private static final String TEMP_PATH = System.getProperty("java.io.tmpdir") + File.separator + "export_task" + File.separator; + + public String createExportTask(ParamsDto dto) { + String taskId = UUID.randomUUID().toString(); + ExportTask task = new ExportTask(); + task.setTaskId(taskId); + task.setStatus("running"); + task.setProgress(0); + redisTemplate.opsForValue().set(REDIS_KEY_PREFIX + taskId, task, 24, TimeUnit.HOURS); + this.runAsyncExport(taskId, dto); + return taskId; + } + + public ExportTask getTask(String taskId) { + return (ExportTask) redisTemplate.opsForValue().get(REDIS_KEY_PREFIX + taskId); + } + + private void updateTaskInRedis(ExportTask task) { + redisTemplate.opsForValue().set(REDIS_KEY_PREFIX + task.getTaskId(), task, 24, TimeUnit.HOURS); + } + + @Async("taskExecutor") + public void runAsyncExport(String taskId, ParamsDto dto) { + try { + Date start = dto.getStartTime(); + Date end = dto.getEndTime(); + long dayDiff = DateUtil.between(start, end, DateUnit.DAY); + log.info("临时文件地址:{}", TEMP_PATH); + File tempDir = new File(TEMP_PATH); + if (!tempDir.exists()) tempDir.mkdirs(); + + File finalResultFile; + String downloadFileName; + + if (dayDiff <= 0) { + downloadFileName = "数据识别_" + DateUtil.formatDate(start) + ".xlsx"; + finalResultFile = new File(TEMP_PATH + taskId + "_" + downloadFileName); + generateExcel(finalResultFile, start, taskId, 0, 100); + } else { + List subFiles = new ArrayList<>(); + List dateRange = getDatesBetween(start, end); + int totalDays = dateRange.size(); + + for (int i = 0; i < totalDays; i++) { + Date currentDate = dateRange.get(i); + File subFile = new File(TEMP_PATH + taskId + "_" + i + ".xlsx"); + int startPct = (int) ((double) i / totalDays * 95); + int endPct = (int) ((double) (i + 1) / totalDays * 95); + generateExcel(subFile, currentDate, taskId, startPct, endPct); + subFiles.add(subFile); + } + + updateStatusProgress(taskId, 98, "running"); + downloadFileName = "批量数据识别_" + DateUtil.formatDate(new Date()) + ".zip"; + finalResultFile = new File(TEMP_PATH + taskId + ".zip"); + ZipUtil.zip(finalResultFile, false, subFiles.toArray(new File[0])); + subFiles.forEach(File::delete); + } + + ExportTask task = getTask(taskId); + if (task != null) { + task.setFilePath(finalResultFile.getAbsolutePath()); + task.setFileName(downloadFileName); + task.setProgress(100); + task.setStatus("completed"); + updateTaskInRedis(task); + } + } catch (Exception e) { + log.error("导出异步任务失败", e); + ExportTask task = getTask(taskId); + if (task != null) { + task.setStatus("failed"); + task.setMessage("导出异常: " + e.getMessage()); + updateTaskInRedis(task); + } + } + } + + private void generateExcel(File file, Date date, String taskId, int baseProgress, int maxProgress) { + // 定义冻结表头的拦截器 + SheetWriteHandler freezePaneHandler = new SheetWriteHandler() { + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + // 参数说明:(列数冻结, 行数冻结, 右侧起始列, 下方起始行) + // 冻结第一行(表头): + writeSheetHolder.getSheet().createFreezePane(0, 1, 0, 1); + } + }; + ExcelWriter excelWriter = null; + try { + // 修复点1: 显式构建并管理 ExcelWriter,不完全依赖外部 try-with-resources + excelWriter = EasyExcel.write(file, DataRecognitionExportVO.class).registerWriteHandler(freezePaneHandler).build(); + WriteSheet writeSheet = EasyExcel.writerSheet("识别数据").build(); + + int pageNo = 1; + int pageSize = 200; // 修复点2: 有图片时减小 pageSize (建议200),降低 IO 负载 + long totalCount = mockDbCount(date); + int totalPages = (int) Math.ceil((double) totalCount / pageSize); + + if (totalCount == 0) { + excelWriter.write(new ArrayList<>(), writeSheet); + return; + } + + while (pageNo <= totalPages) { + List dbDataList = mockDbQuery(date, pageNo, pageSize); + List voList = new ArrayList<>(); + + for (int i = 0; i < dbDataList.size(); i++) { + DataRecognitionExportVO entity = dbDataList.get(i); + DataRecognitionExportVO vo = new DataRecognitionExportVO(); + + // 属性映射 + vo.setIndex((pageNo - 1) * pageSize + i + 1); + vo.setLocation(entity.getLocation()); + vo.setRecognitionTime(entity.getRecognitionTime()); + vo.setPlateColor(entity.getPlateColor()); + vo.setVehicleType(entity.getVehicleType()); + + // 修复点3: 图片路径防御性处理,防止读取流失败中断整个写入 + try { + String absolutePath = entity.getPhotoPath(); + if (absolutePath != null && !absolutePath.isEmpty()) { + File imageFile = new File(absolutePath); + if (imageFile.exists() && imageFile.canRead() && imageFile.isFile()) { + vo.setPhotoFile(imageFile); + } + } + } catch (Exception imgEx) { + log.warn("行 {} 图片读取失败,跳过图片插入: {}", vo.getIndex(), imgEx.getMessage()); + } + + voList.add(vo); + } + + excelWriter.write(voList, writeSheet); + + // 更新进度 + double fileRatio = (double) pageNo / totalPages; + int currentProgress = baseProgress + (int) (fileRatio * (maxProgress - baseProgress)); + if (currentProgress >= 100) currentProgress = 99; + updateStatusProgress(taskId, currentProgress, "running"); + + voList.clear(); + pageNo++; + } + } catch (Exception e) { + log.error("Excel 生成核心逻辑失败: ", e); + throw e; + } finally { + // 修复点4: 确保在 finally 中安全关闭,并捕获关闭时的异常 + if (excelWriter != null) { + try { + excelWriter.finish(); // finish 内部会调用 close() 并清理 IO + } catch (Exception closeEx) { + log.error("关闭 ExcelWriter IO 失败: ", closeEx); + } + } + } + } + + private void updateStatusProgress(String taskId, int progress, String status) { + ExportTask task = getTask(taskId); + if (task != null) { + task.setProgress(progress); + task.setStatus(status); + updateTaskInRedis(task); + } + } + + private List getDatesBetween(Date start, Date end) { + List list = new ArrayList<>(); + Calendar cal = Calendar.getInstance(); + cal.setTime(start); + while (cal.getTime().getTime() <= end.getTime()) { + list.add(cal.getTime()); + cal.add(Calendar.DATE, 1); + } + return list; + } + + private long mockDbCount(Date date) { + return 1000; // 模拟少量数据测试 + } + + private List mockDbQuery(Date date, int pageNo, int pageSize) { + List list = new ArrayList<>(); + for (int i = 0; i < pageSize; i++) { + DataRecognitionExportVO vo = new DataRecognitionExportVO(); + vo.setIndex((pageNo - 1) * pageSize + i + 1); + vo.setLocation("地点-" + i); + vo.setRecognitionTime(date); + vo.setPlateColor("蓝色"); + vo.setVehicleType("轿车"); + // 生产环境下确保此路径存在 + vo.setPhotoPath("C:\\Users\\10488\\Desktop\\test_image.png"); + list.add(vo); + } + return list; + } +} \ No newline at end of file diff --git a/bonus-admin/src/main/java/com/bonus/web/service/data/ExportService.java b/bonus-admin/src/main/java/com/bonus/web/service/data/ExportService.java index 211925e..1a2acaa 100644 --- a/bonus-admin/src/main/java/com/bonus/web/service/data/ExportService.java +++ b/bonus-admin/src/main/java/com/bonus/web/service/data/ExportService.java @@ -40,7 +40,7 @@ public class ExportService { private static final long EXPIRE_TIME = 24; // 临时文件存放目录 - private static final String TEMP_DIR = System.getProperty("java.io.tmpdir") + "/export_tasks/"; + private static final String TEMP_DIR = System.getProperty("java.io.tmpdir") + "/export_task/"; /** * 创建任务并立即返回 TaskID diff --git a/bonus-admin/src/main/java/com/bonus/web/task/FileCleanupTask.java b/bonus-admin/src/main/java/com/bonus/web/task/FileCleanupTask.java new file mode 100644 index 0000000..71db660 --- /dev/null +++ b/bonus-admin/src/main/java/com/bonus/web/task/FileCleanupTask.java @@ -0,0 +1,72 @@ +package com.bonus.web.task; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.concurrent.TimeUnit; +/** + * @className:FileCleanupTask + * @author:cwchen + * @date:2025-12-30-17:07 + * @version:1.0 + * @description:文件清理任务类 + */ + + +@Slf4j +@Component +public class FileCleanupTask { + + // 使用之前定义的临时目录路径 + private static final String TEMP_PATH = System.getProperty("java.io.tmpdir") + File.separator + "export_task" + File.separator; + + /** + * 每天凌晨 2 点执行清理任务 + * cron 表达式: 秒 分 时 日 月 周 + */ + @Scheduled(cron = "0 0 2 * * ?") + public void cleanupTempFiles() { + log.info("开始执行导出临时文件清理任务..."); + File directory = new File(TEMP_PATH); + + if (!directory.exists() || !directory.isDirectory()) { + log.info("临时目录不存在,跳过清理。"); + return; + } + + File[] files = directory.listFiles(); + if (files == null || files.length == 0) { + log.info("临时目录为空,无需清理。"); + return; + } + + long currentTimeMillis = System.currentTimeMillis(); + long twentyFourHoursMillis = TimeUnit.HOURS.toMillis(24); + int deleteCount = 0; + + for (File file : files) { + try { + // 获取文件属性中的创建时间 + BasicFileAttributes attr = Files.readAttributes(file.toPath(), BasicFileAttributes.class); + long creationTime = attr.creationTime().toMillis(); + + // 如果当前时间 - 创建时间 > 24小时,则删除 + if (currentTimeMillis - creationTime > twentyFourHoursMillis) { + if (file.delete()) { + deleteCount++; + log.debug("已删除过期临时文件: {}", file.getName()); + } else { + log.warn("无法删除文件: {}", file.getName()); + } + } + } catch (Exception e) { + log.error("处理文件时出错: {}, 错误: {}", file.getName(), e.getMessage()); + } + } + + log.info("清理任务完成,共删除 {} 个过期文件。", deleteCount); + } +} \ No newline at end of file diff --git a/bonus-common/pom.xml b/bonus-common/pom.xml index 982b2cc..6eaf20c 100644 --- a/bonus-common/pom.xml +++ b/bonus-common/pom.xml @@ -216,6 +216,13 @@ 5.8.11 + + com.alibaba + easyexcel + ${easyexcel.version} + + +