fix: 优化综合导出,增加了异步导出功能

This commit is contained in:
tangwei 2026-08-07 13:38:56 +08:00
parent c84a405453
commit 766899bb51
5 changed files with 419 additions and 18 deletions

View File

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

View File

@ -1079,6 +1079,7 @@ public class SdEngInfoBHServiceImpl extends ServiceImpl<SdEngInfoBHMapper, SdEng
return "SELECT " +
"eng.STCD AS id, " +
"'ENG' AS sttpCode, " +
"'ENG' AS sttpMap, " +
"sttp.FULL_PATH AS sttpFullPath, " +
"sttp.TREE_LEVEL AS sttpTreeLevel, " +
"sttp.ID AS sttp, " +
@ -1095,6 +1096,7 @@ public class SdEngInfoBHServiceImpl extends ServiceImpl<SdEngInfoBHMapper, SdEng
"eng.HBRVCD AS hbrvcd, " +
"hbrv.HBRVNM AS hbrvcdName, " +
"eng.RVCD AS rvcd, " +
"eng.LOGO AS logo, " +
"rv.RVNM AS rvcdName, " +
"rv.PATH AS rvcdFullPath, " +
"addv.ADDVNM AS addvcdName, " +

View File

@ -1,34 +1,26 @@
package com.yfd.platform.qgc_export.controller;
import cn.hutool.core.util.StrUtil;
import com.yfd.platform.annotation.Log;
import com.yfd.platform.qgc_export.domain.ExportTask;
import com.yfd.platform.qgc_export.service.IQgcExportService;
import com.yfd.platform.qgc_export.service.ExportTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 综合导出控制器
* <p>
* 前端传参示例
* <pre>{@code
* POST /qgcExport/exportData
* {
* "tmDimension": "month",
* "dataField": "v,q,z",
* "months": "2026-08,2026-07",
* "stcd": "00001,00002",
* "sysId": "qgc",
* "isDailyDimension": true,
* "isCalcQec": true
* }
* }</pre>
*/
@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<String> dataFields = parseCsv(dataField);
List<String> monthList = parseCsv(months);
List<String> stationList = parseCsv(stcd);
@ -62,7 +59,6 @@ public class QgcExportController {
tmDimension = "month";
}
// 年度导出分支
if ("year".equals(tmDimension)) {
List<String> yearStatTypeList = parseCsv(yearStatType);
List<String> yearList = parseCsv(year);
@ -74,6 +70,122 @@ public class QgcExportController {
isDailyDimension, tmDimension, response);
}
// ======================== 异步导出三接口 ========================
/**
* 接口一创建导出任务返回唯一任务 ID
*/
@PostMapping("/create")
@Operation(summary = "创建异步导出任务,返回 taskId")
public Map<String, Object> 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<String> dataFields = parseCsv(dataField);
List<String> monthList = parseCsv(months);
List<String> stationList = parseCsv(stcd);
List<String> baseIdList = parseCsv(baseId);
if (tmDimension == null || tmDimension.isEmpty()) {
tmDimension = "month";
}
String taskId;
if ("year".equals(tmDimension)) {
List<String> yearStatTypeList = parseCsv(yearStatType);
List<String> yearList = parseCsv(year);
taskId = exportTaskService.submitYearExport(yearStatTypeList, yearList, stationList, baseIdList);
} else {
taskId = exportTaskService.submitMonthExport(dataFields, monthList, stationList,
isDailyDimension, tmDimension);
}
Map<String, Object> result = new HashMap<>();
result.put("taskId", taskId);
return result;
}
/**
* 接口二查询任务状态
*/
@GetMapping("/status")
@Operation(summary = "查询异步导出任务状态")
public Map<String, Object> getStatus(@RequestParam String taskId) {
ExportTask task = exportTaskService.getTask(taskId);
Map<String, Object> 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<String> parseCsv(String value) {
if (StrUtil.isBlank(value)) {
return Collections.emptyList();

View File

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

View File

@ -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<String, ExportTask> taskMap = new ConcurrentHashMap<>();
/**
* 创建月度/小时/日导出任务返回任务 ID
*/
public String submitMonthExport(List<String> dataFields, List<String> months,
List<String> 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<String> yearStatType, List<String> years,
List<String> stationCodes, List<String> 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<String> getHeaders(String name) { return java.util.Collections.emptyList(); }
@Override public java.util.Collection<String> 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"; }
}
}