✨ 将应收账单预览导出改为异步处理

This commit is contained in:
caojia
2026-11-11 17:55:57 +08:00
parent b5299e4490
commit 27715b3e13
7 changed files with 334 additions and 27 deletions
@@ -0,0 +1,20 @@
package com.bms.receivable.domain;
import java.io.Serializable;
import lombok.Data;
/** 应收账单生成预览导出任务的临时状态。 */
@Data
public class ArBillGeneratePreviewExportTask implements Serializable {
private static final long serialVersionUID = 1L;
private String taskId;
private Long userId;
private String status;
private int percent;
private String message;
private String error;
private int rowCount;
private String fileName;
private String storageKey;
}
@@ -69,8 +69,12 @@ public class ArBillScopeResolver {
}
public BmsCustomerOption requireCustomerAccessible(Long customerId) {
ArBillRowScope scope = resolve();
BmsCustomerOption customer = customerId == null ? null
return requireCustomerAccessible(customerId, resolve());
}
/** 异步任务使用提交时由服务端解析的权限范围。 */
public BmsCustomerOption requireCustomerAccessible(Long customerId, ArBillRowScope scope) {
BmsCustomerOption customer = customerId == null || scope == null ? null
: withoutPagination(() -> mapper.selectAccessibleCustomer(customerId, scope));
if (customer == null) throw new ServiceException("客户不存在或无权访问");
return customer;
@@ -15,6 +15,7 @@ import com.bms.receivable.domain.vo.ArBillGenerateQuery;
import com.bms.receivable.domain.vo.ArBillActionRequest;
import com.bms.receivable.domain.vo.ArBillQuery;
import com.bms.receivable.domain.vo.ArBillSummary;
import com.bms.receivable.security.ArBillRowScope;
import com.bms.price.domain.PrcOrderBill;
/**
@@ -40,6 +41,8 @@ public interface IArBillService
List<PrcOrderBill> previewBill(ArBillGenerateQuery query);
List<PrcOrderBill> previewBill(ArBillGenerateQuery query, ArBillRowScope scope);
List<PrcOrderBill> previewExcludedBill(ArBillGenerateQuery query);
BigDecimal sumPreviewBill(ArBillGenerateQuery query);
@@ -73,6 +73,7 @@ import com.bms.receivable.mapper.ArBillExportMapper;
import com.bms.receivable.mapper.ArBillLogMapper;
import com.bms.receivable.mapper.ArBillMapper;
import com.bms.receivable.security.ArBillScopeResolver;
import com.bms.receivable.security.ArBillRowScope;
import com.bms.customer.customerinfo.domain.BmsCustomerOption;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
@@ -333,9 +334,14 @@ public class ArBillServiceImpl implements IArBillService {
*/
@Override
public List<PrcOrderBill> previewBill(ArBillGenerateQuery query) {
return previewBill(query, billScope.resolve());
}
@Override
public List<PrcOrderBill> previewBill(ArBillGenerateQuery query, ArBillRowScope scope) {
long startedAt = System.nanoTime();
validatePreviewQuery(query);
BmsCustomerOption customer = billScope.requireCustomerAccessible(query.getCustomerId());
BmsCustomerOption customer = billScope.requireCustomerAccessible(query.getCustomerId(), scope);
query.setCustomerCode(customer.getCustomerCode());
List<String> orderIdentifiers = normalizeOrderIdentifiers(query.getOrderOrTrackingNo());
List<String> excludeOrderIdentifiers = normalizeOrderIdentifiers(query.getExcludeOrderOrTrackingNo());
@@ -0,0 +1,174 @@
package com.bms.receivable.task;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import com.bms.common.config.BmsConfig;
import com.bms.common.core.redis.RedisCache;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.file.FileUtils;
import com.bms.common.utils.poi.ExcelUtil;
import com.bms.common.utils.uuid.IdUtils;
import com.bms.price.domain.PrcOrderBill;
import com.bms.receivable.domain.ArBillGeneratePreviewExportTask;
import com.bms.receivable.domain.vo.ArBillGeneratePreviewExportRow;
import com.bms.receivable.domain.vo.ArBillGenerateQuery;
import com.bms.receivable.enums.ArBillDateType;
import com.bms.receivable.security.ArBillRowScope;
import com.bms.receivable.security.ArBillScopeResolver;
import com.bms.receivable.service.IArBillService;
/** 生成账单预览的异步导出;使用发起人的权限查询订单。 */
@Component
public class ArBillGeneratePreviewExportRunner {
private static final Logger log = LoggerFactory.getLogger(ArBillGeneratePreviewExportRunner.class);
private static final String CACHE_PREFIX = "ar:bill:generate:preview:export:";
private static final String REL_DIR = "arBillGeneratePreviewExport";
private static final int TTL_MINUTES = 60;
private static final long FILE_RETENTION_MILLIS = TimeUnit.HOURS.toMillis(24);
private final IArBillService billService;
private final RedisCache redisCache;
private final ThreadPoolTaskExecutor executor;
private final ArBillScopeResolver billScope;
public ArBillGeneratePreviewExportRunner(IArBillService billService, RedisCache redisCache, ArBillScopeResolver billScope,
@Qualifier("arBillExportTaskExecutor") ThreadPoolTaskExecutor executor) {
this.billService = billService;
this.redisCache = redisCache;
this.billScope = billScope;
this.executor = executor;
}
public String submit(ArBillGenerateQuery query, Long userId) {
String taskId = IdUtils.fastSimpleUUID();
String fileName = "应收账单待生成订单_" + DateUtils.dateTimeNow() + ".xlsx";
ArBillRowScope scope = billScope.resolve();
put(taskId, userId, "PENDING", 0, "排队中", null, 0, fileName, null);
try {
executor.execute(() -> run(taskId, userId, query, fileName, scope));
} catch (TaskRejectedException e) {
put(taskId, userId, "FAIL", 100, "导出失败", "导出任务较多,请稍后重试", 0, fileName, null);
}
return taskId;
}
private void run(String taskId, Long userId, ArBillGenerateQuery query, String fileName,
ArBillRowScope scope) {
Path file = null;
try {
put(taskId, userId, "RUNNING", 5, "正在查询订单", null, 0, fileName, null);
List<PrcOrderBill> bills = billService.previewBill(query, scope);
put(taskId, userId, "RUNNING", 40, "正在生成 Excel", null, bills.size(), fileName, null);
List<ArBillGeneratePreviewExportRow> rows = new ArrayList<>(bills.size());
for (PrcOrderBill bill : bills) {
rows.add(ArBillGeneratePreviewExportRow.from(bill));
}
ExcelUtil<ArBillGeneratePreviewExportRow> util = new ExcelUtil<>(ArBillGeneratePreviewExportRow.class);
if (ArBillDateType.PICKUP_DATE == ArBillDateType.of(query.getDateType())) {
util.hideColumn("bizDate");
} else {
util.hideColumn("pickupDate");
}
Path dir = Paths.get(BmsConfig.getDownloadPath(), REL_DIR).toAbsolutePath().normalize();
Files.createDirectories(dir);
file = dir.resolve(taskId + ".xlsx").normalize();
if (!file.startsWith(dir)) {
throw new IllegalStateException("invalid export path");
}
try (OutputStream out = Files.newOutputStream(file)) {
util.exportExcelToStream(rows, "应收账单待生成订单", out);
}
put(taskId, userId, "SUCCESS", 100, "导出完成,正在下载", null, bills.size(), fileName,
REL_DIR + "/" + taskId + ".xlsx");
} catch (Exception e) {
log.error("应收账单生成预览导出失败 taskId={}", taskId, e);
if (file != null) {
try { Files.deleteIfExists(file); } catch (Exception cleanupError) {
log.warn("清理导出文件失败 taskId={}", taskId, cleanupError);
}
}
String error = e instanceof ServiceException ? e.getMessage() : "导出失败,请稍后重试";
put(taskId, userId, "FAIL", 100, "导出失败", error, 0, fileName, null);
}
}
public ArBillGeneratePreviewExportTask getTask(String taskId, Long userId) {
ArBillGeneratePreviewExportTask task = redisCache.getCacheObject(CACHE_PREFIX + taskId);
if (task == null || !userId.equals(task.getUserId())) {
throw new ServiceException("导出任务不存在或已过期");
}
return task;
}
public void download(String taskId, Long userId, HttpServletResponse response) throws Exception {
ArBillGeneratePreviewExportTask task = getTask(taskId, userId);
String storageKey = task.getStorageKey();
if (!"SUCCESS".equals(task.getStatus()) || storageKey == null || !FileUtils.checkAllowDownload(storageKey)) {
throw new ServiceException("导出文件尚未生成");
}
Path root = Paths.get(BmsConfig.getDownloadPath()).toAbsolutePath().normalize();
Path file = root.resolve(storageKey).normalize();
if (!file.startsWith(root) || !Files.isRegularFile(file)) {
throw new ServiceException("导出文件不存在");
}
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
FileUtils.setAttachmentResponseHeader(response, task.getFileName());
FileUtils.writeBytes(file.toString(), response.getOutputStream());
}
/** 清理未下载或因客户端断开而遗留的过期文件。 */
@Scheduled(cron = "0 15 * * * ?")
public void cleanupExpiredFiles() {
Path dir = Paths.get(BmsConfig.getDownloadPath(), REL_DIR).toAbsolutePath().normalize();
if (!Files.isDirectory(dir)) {
return;
}
long cutoff = System.currentTimeMillis() - FILE_RETENTION_MILLIS;
try (Stream<Path> files = Files.list(dir)) {
files.filter(file -> file.getFileName().toString().matches("[a-f0-9]{32}\\.xlsx"))
.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS))
.forEach(file -> {
try {
if (Files.getLastModifiedTime(file, LinkOption.NOFOLLOW_LINKS).toMillis() < cutoff) {
Files.deleteIfExists(file);
}
} catch (Exception e) {
log.warn("清理过期导出文件失败: {}", file, e);
}
});
} catch (Exception e) {
log.warn("扫描过期导出文件失败: {}", dir, e);
}
}
private void put(String taskId, Long userId, String status, int percent, String message, String error,
int rowCount, String fileName, String storageKey) {
ArBillGeneratePreviewExportTask task = new ArBillGeneratePreviewExportTask();
task.setTaskId(taskId);
task.setUserId(userId);
task.setStatus(status);
task.setPercent(percent);
task.setMessage(message);
task.setError(error);
task.setRowCount(rowCount);
task.setFileName(fileName);
task.setStorageKey(storageKey);
redisCache.setCacheObject(CACHE_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES);
}
}