From 766899bb510e7c5c38a58a146fccb23c32aee990 Mon Sep 17 00:00:00 2001 From: tangwei Date: Fri, 7 Aug 2026 13:38:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E7=BB=BC=E5=90=88?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=8C=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qgc_base/domain/vo/EngVmsstbprptVo.java | 2 + .../service/impl/SdEngInfoBHServiceImpl.java | 2 + .../controller/QgcExportController.java | 148 ++++++++++-- .../qgc_export/domain/ExportTask.java | 58 +++++ .../qgc_export/service/ExportTaskService.java | 227 ++++++++++++++++++ 5 files changed, 419 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/domain/ExportTask.java create mode 100644 backend/src/main/java/com/yfd/platform/qgc_export/service/ExportTaskService.java diff --git a/backend/src/main/java/com/yfd/platform/qgc_base/domain/vo/EngVmsstbprptVo.java b/backend/src/main/java/com/yfd/platform/qgc_base/domain/vo/EngVmsstbprptVo.java index e82d20ac..efd19e93 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_base/domain/vo/EngVmsstbprptVo.java +++ b/backend/src/main/java/com/yfd/platform/qgc_base/domain/vo/EngVmsstbprptVo.java @@ -16,6 +16,8 @@ public class EngVmsstbprptVo implements Serializable { private String id; private String sttp; private String sttpCode; + private String sttpMap; + private String logo; private String sttpName; private String sttpFullPath; private String sttpTreeLevel; diff --git a/backend/src/main/java/com/yfd/platform/qgc_base/service/impl/SdEngInfoBHServiceImpl.java b/backend/src/main/java/com/yfd/platform/qgc_base/service/impl/SdEngInfoBHServiceImpl.java index cec79c4d..03472886 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_base/service/impl/SdEngInfoBHServiceImpl.java +++ b/backend/src/main/java/com/yfd/platform/qgc_base/service/impl/SdEngInfoBHServiceImpl.java @@ -1079,6 +1079,7 @@ public class SdEngInfoBHServiceImpl extends ServiceImpl - * 前端传参示例: - *
{@code
- * POST /qgcExport/exportData
- * {
- *   "tmDimension": "month",
- *   "dataField": "v,q,z",
- *   "months": "2026-08,2026-07",
- *   "stcd": "00001,00002",
- *   "sysId": "qgc",
- *   "isDailyDimension": true,
- *   "isCalcQec": true
- * }
- * }
*/ @RestController @RequestMapping("/qgcExport") @@ -38,8 +30,14 @@ public class QgcExportController { @Resource private IQgcExportService qgcExportService; + @Resource + private ExportTaskService exportTaskService; + + /** + * 原有同步导出接口(保留兼容) + */ @PostMapping("/exportData") - @Operation(summary = "综合数据导出(ZIP 含多 Sheet Excel)") + @Operation(summary = "综合数据导出(ZIP 含多 Sheet Excel)— 同步返回文件流") public void exportData(@RequestParam(required = false) String tmDimension, @RequestParam(required = false) String dataField, @RequestParam(required = false) String months, @@ -52,7 +50,6 @@ public class QgcExportController { @RequestParam(required = false) String baseId, HttpServletResponse response) { - // 解析逗号分隔参数 List dataFields = parseCsv(dataField); List monthList = parseCsv(months); List stationList = parseCsv(stcd); @@ -62,7 +59,6 @@ public class QgcExportController { tmDimension = "month"; } - // 年度导出分支 if ("year".equals(tmDimension)) { List yearStatTypeList = parseCsv(yearStatType); List yearList = parseCsv(year); @@ -74,6 +70,122 @@ public class QgcExportController { isDailyDimension, tmDimension, response); } + // ======================== 异步导出三接口 ======================== + + /** + * 接口一:创建导出任务,返回唯一任务 ID + */ + @PostMapping("/create") + @Operation(summary = "创建异步导出任务,返回 taskId") + public Map createTask(@RequestParam(required = false) String tmDimension, + @RequestParam(required = false) String dataField, + @RequestParam(required = false) String months, + @RequestParam(required = false) String stcd, + @RequestParam(defaultValue = "true") boolean isDailyDimension, + @RequestParam(required = false) String yearStatType, + @RequestParam(required = false) String year, + @RequestParam(required = false) String baseId) { + + List dataFields = parseCsv(dataField); + List monthList = parseCsv(months); + List stationList = parseCsv(stcd); + List baseIdList = parseCsv(baseId); + + if (tmDimension == null || tmDimension.isEmpty()) { + tmDimension = "month"; + } + + String taskId; + if ("year".equals(tmDimension)) { + List yearStatTypeList = parseCsv(yearStatType); + List yearList = parseCsv(year); + taskId = exportTaskService.submitYearExport(yearStatTypeList, yearList, stationList, baseIdList); + } else { + taskId = exportTaskService.submitMonthExport(dataFields, monthList, stationList, + isDailyDimension, tmDimension); + } + + Map result = new HashMap<>(); + result.put("taskId", taskId); + return result; + } + + /** + * 接口二:查询任务状态 + */ + @GetMapping("/status") + @Operation(summary = "查询异步导出任务状态") + public Map getStatus(@RequestParam String taskId) { + ExportTask task = exportTaskService.getTask(taskId); + Map result = new HashMap<>(); + if (task == null) { + result.put("exists", false); + return result; + } + result.put("exists", true); + result.put("taskId", task.getTaskId()); + result.put("status", task.getStatus().name()); + result.put("statusDesc", task.getStatus().getDesc()); + if (task.getErrorMessage() != null) { + result.put("errorMessage", task.getErrorMessage()); + } + return result; + } + + /** + * 接口三:下载导出文件(参数与 /exportData 一致 + taskId,文件名根据 tmDimension 生成) + */ + @PostMapping("/download") + @Operation(summary = "下载异步导出的 ZIP 文件") + public void download(@RequestParam String taskId, + @RequestParam(required = false) String tmDimension, + @RequestParam(required = false) String dataField, + @RequestParam(required = false) String months, + @RequestParam(required = false) String stcd, + @RequestParam(defaultValue = "true") boolean isDailyDimension, + @RequestParam(required = false) String yearStatType, + @RequestParam(required = false) String year, + @RequestParam(required = false) String baseId, + HttpServletResponse response) { + ExportTask task = exportTaskService.getTask(taskId); + if (task == null || task.getStatus() != ExportTask.Status.DONE) { + response.setStatus(404); + return; + } + + if (tmDimension == null || tmDimension.isEmpty()) { + tmDimension = "month"; + } + + String zipFileName = getFileNamePrefix(tmDimension) + ".zip"; + response.setContentType("application/zip"); + response.setCharacterEncoding("UTF-8"); + try { + response.setHeader("Content-Disposition", + "attachment; filename=" + URLEncoder.encode(zipFileName, "UTF-8")); + } catch (IOException e) { + response.setHeader("Content-Disposition", "attachment; filename=" + zipFileName); + } + + try (OutputStream os = response.getOutputStream()) { + exportTaskService.downloadTask(taskId, os); + } catch (IOException e) { + response.setStatus(500); + } + } + + // ======================== 工具方法 ======================== + + private static String getFileNamePrefix(String tmDimension) { + return switch (tmDimension) { + case "year" -> "年度报表"; + case "month" -> "月度报表"; + case "day" -> "日度报表"; + case "hour" -> "小时报表"; + default -> "未知报表"; + }; + } + private List parseCsv(String value) { if (StrUtil.isBlank(value)) { return Collections.emptyList(); diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/ExportTask.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/ExportTask.java new file mode 100644 index 00000000..60e7a9bb --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/ExportTask.java @@ -0,0 +1,58 @@ +package com.yfd.platform.qgc_export.domain; + +import java.io.File; + +/** + * 导出任务 — 用于异步导出 + */ +public class ExportTask { + + public enum Status { + CREATED("已创建"), + PROCESSING("处理中"), + DONE("已完成"), + ERROR("失败"); + + private final String desc; + + Status(String desc) { this.desc = desc; } + + public String getDesc() { return desc; } + } + + /** 任务唯一 ID */ + private String taskId; + /** 任务状态 */ + private Status status; + /** 导出完成的临时文件 */ + private File file; + /** ZIP 文件名 */ + private String fileName; + /** 错误信息(status=ERROR 时) */ + private String errorMessage; + /** 创建时间戳 */ + private long createTime; + + public ExportTask(String taskId) { + this.taskId = taskId; + this.status = Status.CREATED; + this.createTime = System.currentTimeMillis(); + } + + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + + public File getFile() { return file; } + public void setFile(File file) { this.file = file; } + + public String getFileName() { return fileName; } + public void setFileName(String fileName) { this.fileName = fileName; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public long getCreateTime() { return createTime; } +} diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/ExportTaskService.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/ExportTaskService.java new file mode 100644 index 00000000..c606d326 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/ExportTaskService.java @@ -0,0 +1,227 @@ +package com.yfd.platform.qgc_export.service; + +import com.yfd.platform.qgc_export.domain.ExportTask; +import jakarta.annotation.Resource; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 导出任务管理服务 — 异步导出 + 状态查询 + 文件下载 + */ +@Service +public class ExportTaskService { + + private static final Logger log = LoggerFactory.getLogger(ExportTaskService.class); + + @Resource + private IQgcExportService qgcExportService; + + /** 任务缓存(taskId → task) */ + private final Map taskMap = new ConcurrentHashMap<>(); + + /** + * 创建月度/小时/日导出任务,返回任务 ID + */ + public String submitMonthExport(List dataFields, List months, + List stationCodes, boolean isDailyDimension, + String tmDimension) { + String taskId = UUID.randomUUID().toString().replace("-", ""); + ExportTask task = new ExportTask(taskId); + task.setStatus(ExportTask.Status.PROCESSING); + taskMap.put(taskId, task); + + CompletableFuture.runAsync(() -> { + File tempFile = null; + try { + tempFile = File.createTempFile("qgc_export_", ".zip"); + FileOutputStream fos = new FileOutputStream(tempFile); + CapturingResponse capturing = new CapturingResponse(fos); + qgcExportService.exportData(dataFields, months, stationCodes, + isDailyDimension, tmDimension, capturing); + capturing.flushAndClose(); + fos.close(); + task.setFile(tempFile); + task.setStatus(ExportTask.Status.DONE); + log.info("导出任务 {} 完成,文件: {}", taskId, tempFile.getAbsolutePath()); + } catch (Exception e) { + log.error("导出任务 {} 失败", taskId, e); + task.setErrorMessage(e.getMessage()); + task.setStatus(ExportTask.Status.ERROR); + if (tempFile != null) { + try { Files.deleteIfExists(tempFile.toPath()); } catch (IOException ignored) {} + } + } + }); + + return taskId; + } + + /** + * 创建年度导出任务,返回任务 ID + */ + public String submitYearExport(List yearStatType, List years, + List stationCodes, List baseIds) { + String taskId = UUID.randomUUID().toString().replace("-", ""); + ExportTask task = new ExportTask(taskId); + task.setStatus(ExportTask.Status.PROCESSING); + taskMap.put(taskId, task); + + CompletableFuture.runAsync(() -> { + File tempFile = null; + try { + tempFile = File.createTempFile("qgc_export_", ".zip"); + FileOutputStream fos = new FileOutputStream(tempFile); + CapturingResponse capturing = new CapturingResponse(fos); + qgcExportService.exportYearData(yearStatType, years, stationCodes, + baseIds, capturing); + capturing.flushAndClose(); + fos.close(); + task.setFile(tempFile); + task.setStatus(ExportTask.Status.DONE); + log.info("年度导出任务 {} 完成,文件: {}", taskId, tempFile.getAbsolutePath()); + } catch (Exception e) { + log.error("年度导出任务 {} 失败", taskId, e); + task.setErrorMessage(e.getMessage()); + task.setStatus(ExportTask.Status.ERROR); + if (tempFile != null) { + try { Files.deleteIfExists(tempFile.toPath()); } catch (IOException ignored) {} + } + } + }); + + return taskId; + } + + /** + * 查询任务状态 + */ + public ExportTask getTask(String taskId) { + return taskMap.get(taskId); + } + + /** + * 将任务文件写入 OutputStream(下载),写完后删除临时文件和任务缓存 + */ + public void downloadTask(String taskId, OutputStream os) throws IOException { + ExportTask task = taskMap.get(taskId); + if (task == null || task.getStatus() != ExportTask.Status.DONE || task.getFile() == null) { + throw new IllegalStateException("任务未完成或文件不存在"); + } + try (FileInputStream fis = new FileInputStream(task.getFile())) { + byte[] buf = new byte[8192]; + int len; + while ((len = fis.read(buf)) != -1) { + os.write(buf, 0, len); + } + } finally { + try { Files.deleteIfExists(task.getFile().toPath()); } catch (IOException e) { + log.warn("删除临时文件失败: {}", task.getFile(), e); + } + taskMap.remove(taskId); + } + } + + /** + * 清理超时任务(可由定时任务调用) + */ + public void cleanExpiredTasks(long timeoutMs) { + long now = System.currentTimeMillis(); + taskMap.entrySet().removeIf(entry -> { + ExportTask task = entry.getValue(); + if (now - task.getCreateTime() > timeoutMs) { + if (task.getFile() != null) { + try { Files.deleteIfExists(task.getFile().toPath()); } catch (IOException ignored) {} + } + return true; + } + return false; + }); + } + + // ==================== 内部类 ==================== + + /** + * 最小化 HttpServletResponse — 基于 HttpServletResponseWrapper,仅重写导出用到的 5 个方法 + */ + private static class CapturingResponse extends HttpServletResponseWrapper { + private final FileOutputStream fos; + private final ServletOutputStream sos; + + /** 兜底 response,仅用于满足 super() 非 null 约束,实际所有调用都被本类重写 */ + private static final HttpServletResponse NOOP = new HttpServletResponse() { + @Override public ServletOutputStream getOutputStream() { throw new UnsupportedOperationException(); } + @Override public java.io.PrintWriter getWriter() { throw new UnsupportedOperationException(); } + @Override public String getCharacterEncoding() { return null; } + @Override public String getContentType() { return null; } + @Override public void setCharacterEncoding(String charset) {} + @Override public void setContentLength(int len) {} + @Override public void setContentLengthLong(long len) {} + @Override public void setContentType(String type) {} + @Override public void setBufferSize(int size) {} + @Override public int getBufferSize() { return 0; } + @Override public void flushBuffer() {} + @Override public void resetBuffer() {} + @Override public boolean isCommitted() { return false; } + @Override public void reset() {} + @Override public void setLocale(java.util.Locale loc) {} + @Override public java.util.Locale getLocale() { return java.util.Locale.getDefault(); } + @Override public void addCookie(jakarta.servlet.http.Cookie cookie) {} + @Override public boolean containsHeader(String name) { return false; } + @Override public String encodeURL(String url) { return url; } + @Override public String encodeRedirectURL(String url) { return url; } + @Override public void sendError(int sc, String msg) {} + @Override public void sendError(int sc) {} + @Override public void sendRedirect(String location) {} + @Override public void sendRedirect(String location, int sc, boolean clearBuffer) {} + @Override public void setDateHeader(String name, long date) {} + @Override public void addDateHeader(String name, long date) {} + @Override public void setHeader(String name, String value) {} + @Override public void addHeader(String name, String value) {} + @Override public void setIntHeader(String name, int value) {} + @Override public void addIntHeader(String name, int value) {} + @Override public void setStatus(int sc) {} + @Override public int getStatus() { return 200; } + @Override public String getHeader(String name) { return null; } + @Override public java.util.Collection getHeaders(String name) { return java.util.Collections.emptyList(); } + @Override public java.util.Collection getHeaderNames() { return java.util.Collections.emptyList(); } + }; + + CapturingResponse(FileOutputStream fos) { + super(NOOP); + this.fos = fos; + this.sos = new ServletOutputStream() { + @Override public void write(int b) throws IOException { fos.write(b); } + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(WriteListener listener) {} + }; + } + + void flushAndClose() throws IOException { + sos.flush(); + fos.flush(); + } + + @Override public ServletOutputStream getOutputStream() { return sos; } + @Override public void setContentType(String type) {} + @Override public void setCharacterEncoding(String charset) {} + @Override public void setHeader(String name, String value) {} + @Override public String getCharacterEncoding() { return "UTF-8"; } + } +}