数据识别导出下载

This commit is contained in:
cwchen 2025-12-30 18:08:50 +08:00
parent 62b8f1c934
commit a579dd6418
8 changed files with 469 additions and 1 deletions

View File

@ -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<String, Object> startExport(ParamsDto dto) {
// 启动异步任务获取TaskId
String taskId = exportService.createExportTask(dto);
Map<String, Object> data = new HashMap<>();
data.put("taskId", taskId);
return MapUtil.builder(new HashMap<String, Object>())
.put("code", 200)
.put("msg", "操作成功")
.put("data", data)
.build();
}
// 2. 轮询进度
@GetMapping("/progress") // 注意如果前端封装是POST这里改为PostMapping
public Map<String, Object> getProgress(@RequestParam("taskId") String taskId) {
ExportTask task = exportService.getTask(taskId);
if (task == null) {
return MapUtil.builder(new HashMap<String, Object>())
.put("code", 500)
.put("msg", "任务不存在")
.build();
}
return MapUtil.builder(new HashMap<String, Object>())
.put("code", 200)
.put("data", task)
.build();
}
// 3. 下载文件
@GetMapping("/download")
public ResponseEntity<FileSystemResource> 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));
}
}

View File

@ -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<String, Object> 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<File> subFiles = new ArrayList<>();
List<Date> 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<DataRecognitionExportVO> dbDataList = mockDbQuery(date, pageNo, pageSize);
List<DataRecognitionExportVO> 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<Date> getDatesBetween(Date start, Date end) {
List<Date> 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<DataRecognitionExportVO> mockDbQuery(Date date, int pageNo, int pageSize) {
List<DataRecognitionExportVO> 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;
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -216,6 +216,13 @@
<version>5.8.11</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<!-- 连接池 -->
<!--<dependency>
<groupId>com.rabbitmq</groupId>

View File

@ -0,0 +1,55 @@
package com.bonus.common.domain.data.vo;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.write.style.*;
import lombok.Data;
import com.alibaba.excel.annotation.ExcelProperty;
import java.io.File;
import java.util.Date;
import com.alibaba.excel.enums.poi.HorizontalAlignmentEnum;
import com.alibaba.excel.enums.poi.VerticalAlignmentEnum;
/**
* @className:DataRecognitionExportVO
* @author:cwchen
* @date:2025-12-30-17:10
* @version:1.0
* @description:识别数据导出-vo
*/
@Data
@ContentRowHeight(80)
@HeadRowHeight(40) // 设置表头行高
// 设置表头居中
@HeadStyle(horizontalAlignment = HorizontalAlignmentEnum.CENTER, verticalAlignment = VerticalAlignmentEnum.CENTER)
// 设置内容居中
@ContentStyle(horizontalAlignment = HorizontalAlignmentEnum.CENTER, verticalAlignment = VerticalAlignmentEnum.CENTER)
public class DataRecognitionExportVO {
@ExcelProperty("序号")
@ColumnWidth(10)
private Integer index;
@ExcelProperty("识别地点")
@ColumnWidth(25)
private String location;
@ExcelProperty("识别时间")
@ColumnWidth(20)
private Date recognitionTime;
@ExcelProperty("车牌颜色")
@ColumnWidth(15)
private String plateColor;
@ExcelProperty("车辆类型")
@ColumnWidth(15)
private String vehicleType;
@ExcelProperty("识别照片")
@ColumnWidth(25)
private File photoFile;
@ExcelIgnore
private String photoPath;
}

View File

@ -18,4 +18,5 @@ public class ExportTask {
private String downloadUrl;
private String fileName;
private String finalFilePath; // 服务器本地存储路径
private String filePath; // 服务器本地临时文件路径
}

View File

@ -42,6 +42,7 @@
<commons-fileupload.version>1.4</commons-fileupload.version>
<io.jsonwebtoken.version>0.9.1</io.jsonwebtoken.version>
<amqp-client.version>5.16.0</amqp-client.version>
<easyexcel.version>3.3.2</easyexcel.version>
</properties>
<!-- 依赖声明 -->