数据识别导出下载
This commit is contained in:
parent
62b8f1c934
commit
a579dd6418
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -18,4 +18,5 @@ public class ExportTask {
|
|||
private String downloadUrl;
|
||||
private String fileName;
|
||||
private String finalFilePath; // 服务器本地存储路径
|
||||
private String filePath; // 服务器本地临时文件路径
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue